Skip to content

Commit 44a2c97

Browse files
DavertMikclaude
andcommitted
fix(pause): ignore step failures inside tryTo and retryTo
Effects track how many are active; pause on=fail skips step failures while tryTo or a retryTo attempt that will be retried is running. Fixes #4516 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ff47c22 commit 44a2c97

4 files changed

Lines changed: 182 additions & 3 deletions

File tree

‎docs/debugging.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ For automated debugging without modifying test code, use the `pause` plugin. It
120120

121121
### Pause on Failure
122122

123-
Automatically enters interactive pause when a step fails:
123+
Automatically enters interactive pause when a step fails. Failures inside `tryTo` and `retryTo` attempts that will be retried are ignored:
124124

125125
```bash
126126
npx codeceptjs run -p pause

‎lib/effects.js‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ import MetaStep from './step/meta.js'
77
import { empty } from './assert/empty.js'
88
import { isAsyncFunction } from './utils.js'
99

10+
let activeEffects = 0
11+
12+
/**
13+
* Returns true while steps are executed inside tryTo or a retryTo attempt that will be retried
14+
* @return {boolean}
15+
*/
16+
function isEffectActive() {
17+
return activeEffects > 0
18+
}
19+
1020
/**
1121
* @param {CodeceptJS.LocatorOrString} context
1222
* @param {Function} fn
@@ -117,6 +127,7 @@ class WithinStep extends MetaStep {
117127
let hopeThatFailures = []
118128
event.dispatcher.on(event.test.before, () => {
119129
hopeThatFailures = []
130+
activeEffects = 0
120131
})
121132

122133
async function hopeThat(callback) {
@@ -214,6 +225,8 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
214225

215226
const tryBlock = async () => {
216227
tries++
228+
const willRetry = tries < maxTries
229+
if (willRetry) activeEffects++
217230
recorder.session.start(`${sessionName} ${tries}`)
218231
try {
219232
await callback(tries)
@@ -223,12 +236,14 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
223236

224237
// Call done if no errors
225238
recorder.add(() => {
239+
if (willRetry) activeEffects--
226240
recorder.session.restore(`${sessionName} ${tries}`)
227241
done(null)
228242
})
229243

230244
// Catch errors and retry
231245
recorder.session.catch(err => {
246+
if (willRetry) activeEffects--
232247
recorder.session.restore(`${sessionName} ${tries}`)
233248
if (tries < maxTries) {
234249
output.debug(`Error ${err}... Retrying`)
@@ -288,6 +303,7 @@ async function tryTo(callback) {
288303
isAutoRetriesEnabled = store.autoRetries
289304
if (isAutoRetriesEnabled) output.debug('Auto retries disabled inside tryTo effect')
290305
store.autoRetries = false
306+
activeEffects++
291307
callback()
292308
recorder.add(() => {
293309
result = true
@@ -305,6 +321,7 @@ async function tryTo(callback) {
305321
'result',
306322
() => {
307323
store.autoRetries = isAutoRetriesEnabled
324+
activeEffects--
308325
return result
309326
},
310327
true,
@@ -316,11 +333,12 @@ async function tryTo(callback) {
316333
)
317334
}
318335

319-
export { hopeThat, retryTo, tryTo, within }
336+
export { hopeThat, retryTo, tryTo, within, isEffectActive }
320337

321338
export default {
322339
hopeThat,
323340
retryTo,
324341
tryTo,
325342
within,
343+
isEffectActive,
326344
}

‎lib/plugin/pause.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import event from '../event.js'
22
import pause from '../pause.js'
33
import recorder from '../recorder.js'
44
import output from '../output.js'
5+
import { isEffectActive } from '../effects.js'
56
import {
67
parsePluginArgs,
78
resolveTrigger,
@@ -27,7 +28,8 @@ import {
2728
*
2829
* #### `on=` modes
2930
*
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
3133
* * **test** — pause after each test
3234
* * **step** — pause before the first step (interactive walk-through)
3335
* * **file** — pause when execution reaches `path=...[;line=...]`
@@ -69,6 +71,7 @@ function initFailMode() {
6971
})
7072

7173
event.dispatcher.on(event.step.failed, () => {
74+
if (isEffectActive()) return
7275
failed = true
7376
})
7477

‎test/unit/plugin/pause_test.js‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { expect } from 'chai'
2+
import pausePlugin from '../../../lib/plugin/pause.js'
3+
import { setPauseHandler } from '../../../lib/pause.js'
4+
import { hopeThat, isEffectActive, retryTo, tryTo } from '../../../lib/effects.js'
5+
import recordStep from '../../../lib/step/record.js'
6+
import Step from '../../../lib/step.js'
7+
import event from '../../../lib/event.js'
8+
import recorder from '../../../lib/recorder.js'
9+
import store from '../../../lib/store.js'
10+
import { createTest } from '../../../lib/mocha/test.js'
11+
12+
const trackedEvents = [event.test.before, event.test.started, event.test.failed, event.test.after, event.step.failed]
13+
14+
const failingStep = () => recordStep(new Step({ fail: () => Promise.reject(new Error('Ups')) }, 'fail'), [])
15+
16+
describe('pause plugin on=fail', () => {
17+
let pauses
18+
let listenersBefore
19+
20+
beforeEach(() => {
21+
store.dryRun = false
22+
pauses = 0
23+
setPauseHandler(() => {
24+
pauses++
25+
return Promise.resolve()
26+
})
27+
listenersBefore = Object.fromEntries(trackedEvents.map(name => [name, event.dispatcher.listeners(name)]))
28+
recorder.reset()
29+
recorder.start()
30+
pausePlugin({})
31+
})
32+
33+
afterEach(() => {
34+
for (const name of trackedEvents) {
35+
for (const listener of event.dispatcher.listeners(name)) {
36+
if (!listenersBefore[name].includes(listener)) event.dispatcher.removeListener(name, listener)
37+
}
38+
}
39+
event.dispatcher.emit(event.test.finished, createTest('cleanup'))
40+
setPauseHandler(null)
41+
recorder.reset()
42+
})
43+
44+
const finishTest = async test => {
45+
event.dispatcher.emit(event.test.finished, test)
46+
event.dispatcher.emit(event.test.after, test)
47+
await recorder.promise()
48+
}
49+
50+
it('does not pause when a step failure is swallowed by tryTo (#4516)', async () => {
51+
const test = createTest('tryTo')
52+
event.dispatcher.emit(event.test.started, test)
53+
const result = await tryTo(failingStep)
54+
await recorder.promise()
55+
expect(result).to.equal(false)
56+
event.dispatcher.emit(event.test.passed, test)
57+
await finishTest(test)
58+
expect(pauses).to.equal(0)
59+
})
60+
61+
it('pauses when a hopeThat soft assertion fails', async () => {
62+
const test = createTest('hopeThat')
63+
event.dispatcher.emit(event.test.started, test)
64+
const result = await hopeThat(failingStep)
65+
await recorder.promise()
66+
expect(result).to.equal(false)
67+
event.dispatcher.emit(event.test.passed, test)
68+
await finishTest(test)
69+
expect(pauses).to.equal(1)
70+
})
71+
72+
it('does not pause when retryTo succeeds after a failed attempt', async () => {
73+
const test = createTest('retryTo')
74+
event.dispatcher.emit(event.test.started, test)
75+
await retryTo(tries => (tries === 1 ? failingStep() : recorder.add(() => 'ok')), 2, 0)
76+
await recorder.promise()
77+
event.dispatcher.emit(event.test.passed, test)
78+
await finishTest(test)
79+
expect(pauses).to.equal(0)
80+
})
81+
82+
it('pauses when retryTo exhausts all attempts', async () => {
83+
const test = createTest('retryTo exhausted')
84+
event.dispatcher.emit(event.test.started, test)
85+
const err = await retryTo(failingStep, 2, 0).catch(e => e)
86+
expect(err).to.be.instanceOf(Error)
87+
recorder.reset()
88+
recorder.start()
89+
event.dispatcher.emit(event.test.failed, test, err)
90+
await finishTest(test)
91+
expect(pauses).to.equal(1)
92+
expect(isEffectActive()).to.equal(false)
93+
})
94+
95+
it('leaves no active effect after nested tryTo', async () => {
96+
await tryTo(() => tryTo(failingStep))
97+
await recorder.promise()
98+
expect(isEffectActive()).to.equal(false)
99+
})
100+
101+
it('pauses when a failed step fails the test', async () => {
102+
const test = createTest('failing step')
103+
event.dispatcher.emit(event.test.started, test)
104+
const err = await failingStep().catch(e => e)
105+
recorder.reset()
106+
recorder.start()
107+
event.dispatcher.emit(event.test.failed, test, err)
108+
await finishTest(test)
109+
expect(pauses).to.equal(1)
110+
})
111+
112+
it('pauses when a Before hook fails', async () => {
113+
const test = createTest('before hook')
114+
event.dispatcher.emit(event.test.before, test)
115+
const err = await failingStep().catch(e => e)
116+
recorder.reset()
117+
recorder.start()
118+
event.dispatcher.emit(event.test.failed, test, err, 'Before')
119+
await finishTest(test)
120+
expect(pauses).to.equal(1)
121+
})
122+
123+
it('pauses when an After hook fails a passed test', async () => {
124+
const test = createTest('after hook')
125+
event.dispatcher.emit(event.test.started, test)
126+
event.dispatcher.emit(event.test.passed, test)
127+
const err = await failingStep().catch(e => e)
128+
recorder.reset()
129+
recorder.start()
130+
event.dispatcher.emit(event.test.failed, test, err, 'After')
131+
await finishTest(test)
132+
expect(pauses).to.equal(1)
133+
})
134+
135+
it('does not pause when the test fails without a failed step', async () => {
136+
const test = createTest('plain error')
137+
event.dispatcher.emit(event.test.started, test)
138+
event.dispatcher.emit(event.test.failed, test, new Error('Ups'))
139+
await finishTest(test)
140+
expect(pauses).to.equal(0)
141+
})
142+
143+
it('does not carry a failure over to the next test', async () => {
144+
const failed = createTest('failed')
145+
event.dispatcher.emit(event.test.started, failed)
146+
const err = await failingStep().catch(e => e)
147+
recorder.reset()
148+
recorder.start()
149+
event.dispatcher.emit(event.test.failed, failed, err)
150+
await finishTest(failed)
151+
152+
const passed = createTest('passed')
153+
event.dispatcher.emit(event.test.started, passed)
154+
event.dispatcher.emit(event.test.passed, passed)
155+
await finishTest(passed)
156+
expect(pauses).to.equal(1)
157+
})
158+
})

0 commit comments

Comments
 (0)