Skip to content

Commit eba4a62

Browse files
DavertMikDavertMikclaude
authored
fix(core): re-land asyncWrapper + promise-core error-path fixes onto 4.x (#5624, #5633) (#5637)
* fix(mocha): fail fast when test hook import chain rejects In 4.x the per-test setup()/teardown() hooks load ./test.js via a dynamic import() with no .catch(). If that import rejects, or the .then body throws (e.g. enhanceMochaTest on an undefined test, a listener throwing), done() is never called and the mocha hook hangs forever — the silent-hang failure mode the 4.0 release is most concerned with. This mirrors the existing suiteSetup()/suiteTeardown() shape exactly: append a .catch(err => doneFn(err)) to both import chains. No recorder.errHandler is added (the per-test handler owns the single errFn slot). makeDoneCallableOnce already guards against a double done() call. Adds 3 regression tests: setup() and teardown() with a throwing then-body call done with the error within 1s instead of hanging, plus a happy-path check. Reverting the fix makes the two error-path tests hang (verified). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit ac59a40) * fix(core): restore sessions and propagate errors on session/within/retryTo error paths Fixes four of the latent error-path divergences characterized in #5622, by making the error paths symmetric with the success paths. The characterization assertions are updated to the corrected behavior in the same commit. - within() async error (lib/effects.js): the catch now calls finishHelpers() (so helpers' _withinEnd runs on error, not just success) and returns recorder.promise() (so the error propagates through within() instead of being detached onto a trailing task that a caller awaiting within() never sees). - session() async error (lib/session.js): schedules a recorder task that runs recorder.session.restore so the recorder session is restored on error (it was only restored on success, leaking the session id). The existing restoreVars/listener cleanup is kept as-is — switching to finalize()'s real restoreVars() closes the browser context under BROWSER_RESTART=session. - session() sync error (lib/session.js): the finally recorder.catch now calls recorder.session.restore before re-throwing (the only place that runs on a rejected chain). - retryTo() (lib/effects.js): a thrown callback no longer reject()s the outer promise prematurely — it routes through recorder.throw so the retry logic owns the outcome (retry, or reject once maxTries is exhausted). A callback that throws then succeeds on a later attempt now resolves instead of rejecting. tries now starts at 1 on the first attempt (was 2); the retry count is preserved (tries < maxTries). Verified: unit 748/0, runner 273/0 (incl. all retryFailedStep/rerun tests), acceptance within/session/els green under both BROWSER_RESTART=browser and =session. Not changed here (would be unsafe or out of scope, see PR): recorder.retries clearing (retryFailedStep depends on it), the stopped-recorder no-op contract, and nested cross-level restore ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> (cherry picked from commit dca7626) --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 20607b7 commit eba4a62

5 files changed

Lines changed: 101 additions & 39 deletions

File tree

lib/effects.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ function within(context, fn) {
5050
return recorder.promise().then(() => res)
5151
})
5252
.catch(e => {
53+
finishHelpers()
5354
finalize()
5455
recorder.throw(e)
56+
return recorder.promise()
5557
})
5658
}
5759

@@ -203,7 +205,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
203205
const sessionName = 'retryTo'
204206

205207
return new Promise((done, reject) => {
206-
let tries = 1
208+
let tries = 0
207209

208210
function handleRetryException(err) {
209211
recorder.throw(err)
@@ -216,7 +218,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
216218
try {
217219
await callback(tries)
218220
} catch (err) {
219-
handleRetryException(err)
221+
recorder.throw(err)
220222
}
221223

222224
// Call done if no errors
@@ -228,7 +230,7 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
228230
// Catch errors and retry
229231
recorder.session.catch(err => {
230232
recorder.session.restore(`${sessionName} ${tries}`)
231-
if (tries <= maxTries) {
233+
if (tries < maxTries) {
232234
output.debug(`Error ${err}... Retrying`)
233235
recorder.add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
234236
} else {

lib/mocha/asyncWrapper.js

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,23 +197,31 @@ export function setup(suite) {
197197
return function (done) {
198198
const doneFn = makeDoneCallableOnce(done)
199199
recorder.startUnlessRunning()
200-
import('./test.js').then(testModule => {
201-
const { enhanceMochaTest } = testModule.default || testModule
202-
event.emit(event.test.before, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest))
203-
recorder.add(() => doneFn())
204-
})
200+
import('./test.js')
201+
.then(testModule => {
202+
const { enhanceMochaTest } = testModule.default || testModule
203+
event.emit(event.test.before, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest))
204+
recorder.add(() => doneFn())
205+
})
206+
.catch(err => {
207+
doneFn(err)
208+
})
205209
}
206210
}
207211

208212
export function teardown(suite) {
209213
return function (done) {
210214
const doneFn = makeDoneCallableOnce(done)
211215
recorder.startUnlessRunning()
212-
import('./test.js').then(testModule => {
213-
const { enhanceMochaTest } = testModule.default || testModule
214-
event.emit(event.test.after, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest))
215-
recorder.add(() => doneFn())
216-
})
216+
import('./test.js')
217+
.then(testModule => {
218+
const { enhanceMochaTest } = testModule.default || testModule
219+
event.emit(event.test.after, enhanceMochaTest(suite?.ctx?.currentTest ?? suite?.currentTest))
220+
recorder.add(() => doneFn())
221+
})
222+
.catch(err => {
223+
doneFn(err)
224+
})
217225
}
218226
}
219227

lib/session.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ function session(sessionName, config, fn) {
109109
output.stepShift = 0
110110
session.restoreVars(sessionName)
111111
event.dispatcher.removeListener(event.step.after, addContextToStep)
112+
recorder.add('restore session on error', () => recorder.session.restore(`session:${sessionName}`))
112113
recorder.throw(e)
113114
return recorder.promise()
114115
})
@@ -124,6 +125,7 @@ function session(sessionName, config, fn) {
124125
session.restoreVars(sessionName)
125126
output.stepShift = 0
126127
event.dispatcher.removeListener(event.step.after, addContextToStep)
128+
recorder.session.restore(`session:${sessionName}`)
127129
throw e
128130
})
129131
}

test/unit/mocha/asyncWrapper_test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,43 @@ describe('AsyncWrapper', () => {
230230
expect(suiteTests[0].err, 'the suite test got the hook error attached').to.be.instanceof(Error)
231231
})
232232
})
233+
234+
describe('setup/teardown import hardening (regression)', () => {
235+
beforeEach(() => recorder.start())
236+
237+
it('setup(): a throwing then-body calls done with the error instead of hanging', async () => {
238+
const suite = {
239+
ctx: {
240+
get currentTest() {
241+
throw new Error('setup-boom')
242+
},
243+
},
244+
}
245+
const { count, arg } = await runHook(setup(suite), 1000)
246+
expect(count).to.equal(1)
247+
expect(arg).to.be.instanceof(Error)
248+
expect(arg.message).to.equal('setup-boom')
249+
})
250+
251+
it('teardown(): a throwing then-body calls done with the error instead of hanging', async () => {
252+
const suite = {
253+
ctx: {
254+
get currentTest() {
255+
throw new Error('teardown-boom')
256+
},
257+
},
258+
}
259+
const { count, arg } = await runHook(teardown(suite), 1000)
260+
expect(count).to.equal(1)
261+
expect(arg).to.be.instanceof(Error)
262+
expect(arg.message).to.equal('teardown-boom')
263+
})
264+
265+
it('setup(): happy path calls done with no error', async () => {
266+
const suite = { ctx: { currentTest: { title: 'a sample test' } } }
267+
const { count, arg } = await runHook(setup(suite), 1000)
268+
expect(count).to.equal(1)
269+
expect(arg, 'done called with no error').to.be.undefined
270+
})
271+
})
233272
})

test/unit/session_composition_test.js

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,28 @@ describe('promise-core composition (characterization)', () => {
9494
expect(recorder.getCurrentSessionId()).to.equal(null)
9595
})
9696

97-
it('FINDING: async callback error leaks the session id and never calls recorder.session.restore', async () => {
97+
it('async callback error surfaces the error and restores the session id', async () => {
9898
session('asyncerr', async () => {
9999
throw new Error('boom')
100100
})
101-
let rejected
102-
await settles(recorder.promise()).catch(e => (rejected = e))
103-
// characterized behavior, see plans/001-findings.md
104-
expect(rejected, 'outer chain rejects').to.be.instanceof(Error)
105-
expect(rejected.message).to.equal('boom')
106-
expect(recorder.getCurrentSessionId(), 'session id leaks').to.equal('session:asyncerr')
101+
const err = await drain()
102+
expect(err, 'outer chain rejects').to.be.instanceof(Error)
103+
expect(err.message).to.equal('boom')
104+
expect(helper.calls.restoreVars, 'restoreVars called on error').to.be.greaterThan(0)
105+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
107106
})
108107

109-
it('FINDING: sync callback whose queued task throws restores vars but leaks the session id', async () => {
108+
it('sync callback whose queued task throws surfaces the error and restores the session id', async () => {
110109
session('syncerr', () => {
111110
recorder.add(() => {
112111
throw new Error('boomsync')
113112
})
114113
})
115114
const err = await drain()
116-
// The finally recorder.catch re-throws before finalize() runs
117-
// recorder.session.restore, so the session id leaks. See plans/001-findings.md
118115
expect(err, 'error surfaces after draining').to.be.instanceof(Error)
119116
expect(err.message).to.equal('boomsync')
120-
expect(helper.calls.restoreVars, 'restoreVars called by the finally handler').to.equal(1)
121-
expect(recorder.getCurrentSessionId(), 'session id leaks').to.equal('session:syncerr')
117+
expect(helper.calls.restoreVars, 'restoreVars called by the finally handler').to.be.greaterThan(0)
118+
expect(recorder.getCurrentSessionId(), 'session id restored on error').to.equal(null)
122119
})
123120
})
124121

@@ -134,22 +131,40 @@ describe('promise-core composition (characterization)', () => {
134131
expect(inside).to.equal(true)
135132
})
136133

137-
it('FINDING: async callback error skips _withinEnd and detaches the error onto a trailing task', async () => {
134+
it('async callback error runs _withinEnd and propagates the error', async () => {
138135
within('ctx', async () => {
139136
throw new Error('boomwithin')
140137
})
141138
const err = await drain()
142-
// The error is only visible after draining trailing tasks because within()'s
143-
// async catch omits `return recorder.promise()`. See plans/001-findings.md
144-
expect(err, 'error surfaces after draining').to.be.instanceof(Error)
139+
expect(err, 'error surfaces').to.be.instanceof(Error)
145140
expect(err.message).to.equal('boomwithin')
146141
expect(helper.calls.withinBegin, '_withinBegin ran').to.be.greaterThan(0)
147-
expect(helper.calls.withinEnd, '_withinEnd skipped on error').to.equal(0)
142+
expect(helper.calls.withinEnd, '_withinEnd runs on error').to.be.greaterThan(0)
148143
})
149144
})
150145

151146
describe('retryTo()', () => {
152-
it('FINDING: a synchronously-throwing callback rejects but keeps retrying past the rejection', async () => {
147+
it('retries a throwing callback and resolves when a later attempt succeeds', async () => {
148+
let firstTries
149+
let calls = 0
150+
let rejected = null
151+
await settles(
152+
retryTo(
153+
tries => {
154+
if (firstTries === undefined) firstTries = tries
155+
calls++
156+
if (tries < 3) throw new Error('not yet')
157+
},
158+
5,
159+
20,
160+
),
161+
).catch(e => (rejected = e))
162+
expect(rejected, 'resolves once an attempt succeeds (no premature reject)').to.equal(null)
163+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
164+
expect(calls, 'ran until the succeeding attempt').to.equal(3)
165+
})
166+
167+
it('a callback that always throws rejects only after exhausting maxTries', async () => {
153168
let firstTries
154169
let calls = 0
155170
let rejected
@@ -165,15 +180,11 @@ describe('promise-core composition (characterization)', () => {
165180
),
166181
3000,
167182
).catch(e => (rejected = e))
168-
await new Promise(r => setTimeout(r, 300))
169-
const finalCalls = calls
170-
await new Promise(r => setTimeout(r, 300))
171-
// characterized behavior, see plans/001-findings.md
172-
expect(rejected, 'retryTo rejects with the real error').to.be.instanceof(Error)
183+
await new Promise(r => setTimeout(r, 200))
184+
expect(rejected, 'rejects with the real error').to.be.instanceof(Error)
173185
expect(rejected.message).to.equal('always')
174-
expect(firstTries, 'first attempt receives tries === 2 (tries starts at 1, incremented before callback)').to.equal(2)
175-
expect(calls, 'retrying continues past the first rejection').to.be.greaterThan(1)
176-
expect(calls, 'retries stop once drained (no perpetual loop)').to.equal(finalCalls)
186+
expect(firstTries, 'first attempt receives tries === 1').to.equal(1)
187+
expect(calls, 'retried exactly maxTries times').to.equal(3)
177188
})
178189

179190
it('retries via recorder failures then resolves', async () => {

0 commit comments

Comments
 (0)