Skip to content

Commit 5f51d3e

Browse files
DavertMikclaude
andcommitted
test(core): characterize promise-core error and hang paths
Adds browser-free characterization tests that pin down the error and settle-guarantees of the promise composition core, so it can be fixed or refactored safely. No lib/ code is changed — current behavior (including known-bad behavior) is frozen. - recorder_test.js: errHandler/catch routing, catchWithoutStop terminal vs normal errors, ignoreErr, nested-session id semantics, unbalanced session restore, and task timeout (success + failure). - session_composition_test.js (new): session()/within()/retryTo()/hopeThat() composition with a fake helper registered through the real container, a settles()/drain() harness that turns deadlocks into fast named failures, and hermetic per-test isolation of the recorder singleton. - mocha/asyncWrapper_test.js: test() lifecycle (queued-step failure, sync throw, test.throws pass) and a rejecting injected() before-hook. Characterized divergences (session-id leak on error, within skipping _withinEnd, retryTo retrying past rejection, recorder.retries leaking across runs, stopped-recorder hang) are documented for the follow-up fix plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a70c814 commit 5f51d3e

3 files changed

Lines changed: 404 additions & 1 deletion

File tree

test/unit/mocha/asyncWrapper_test.js

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai'
22
import sinon from 'sinon'
3-
import { test as testWrapper, setup, teardown, suiteSetup, suiteTeardown } from '../../../lib/mocha/asyncWrapper.js'
3+
import { test as testWrapper, injected, setup, teardown, suiteSetup, suiteTeardown } from '../../../lib/mocha/asyncWrapper.js'
44
import recorder from '../../../lib/recorder.js'
55
import event from '../../../lib/event.js'
66
import Container from '../../../lib/container.js'
@@ -14,6 +14,27 @@ let afterSuite
1414
let failed
1515
let started
1616

17+
// Runs a wrapped test/hook fn and resolves with how many times its done
18+
// callback fired and the argument it received. A done that never fires becomes
19+
// a fast, named rejection instead of a mocha timeout.
20+
function runHook(hookFn, ms = 2000) {
21+
return new Promise((resolve, reject) => {
22+
let count = 0
23+
let arg
24+
const timer = setTimeout(() => reject(new Error('done callback was never called')), ms)
25+
timer.unref?.()
26+
hookFn(err => {
27+
count++
28+
arg = err
29+
const settle = setTimeout(() => {
30+
clearTimeout(timer)
31+
resolve({ count, arg })
32+
}, 50)
33+
settle.unref?.()
34+
})
35+
})
36+
}
37+
1738
describe('AsyncWrapper', () => {
1839
beforeEach(async () => {
1940
test = { timeout: () => {} }
@@ -140,4 +161,73 @@ describe('AsyncWrapper', () => {
140161
.catch(() => null)
141162
})
142163
})
164+
165+
describe('test() lifecycle (characterization)', () => {
166+
beforeEach(() => recorder.start())
167+
168+
it('calls done once with the error and fires test.failed when a queued step throws', async () => {
169+
const onFailed = sinon.spy()
170+
event.dispatcher.on(event.test.failed, onFailed)
171+
test.fn = () => {
172+
recorder.add(() => {
173+
throw new Error('stepfail')
174+
})
175+
}
176+
const { count, arg } = await runHook(testWrapper(test).fn)
177+
expect(count).to.equal(1)
178+
expect(arg).to.be.instanceof(Error)
179+
expect(arg.message).to.equal('stepfail')
180+
expect(onFailed.called, 'test.failed fired').to.be.true
181+
})
182+
183+
it('calls done once with the error when the body throws synchronously', async () => {
184+
const onFailed = sinon.spy()
185+
event.dispatcher.on(event.test.failed, onFailed)
186+
test.fn = () => {
187+
throw new Error('syncthrow')
188+
}
189+
const { count, arg } = await runHook(testWrapper(test).fn)
190+
expect(count).to.equal(1)
191+
expect(arg).to.be.instanceof(Error)
192+
expect(arg.message).to.equal('syncthrow')
193+
expect(onFailed.called, 'test.failed fired').to.be.true
194+
})
195+
196+
it('passes (done with no error) and fires test.passed when test.throws matches', async () => {
197+
const onPassed = sinon.spy()
198+
event.dispatcher.on(event.test.passed, onPassed)
199+
test.throws = /boom/
200+
test.fn = () => {
201+
throw new Error('boom happened')
202+
}
203+
const { count, arg } = await runHook(testWrapper(test).fn)
204+
expect(count).to.equal(1)
205+
expect(arg, 'done called with no error').to.be.undefined
206+
expect(onPassed.called, 'test.passed fired').to.be.true
207+
})
208+
})
209+
210+
describe('injected() hooks (characterization)', () => {
211+
beforeEach(() => recorder.start())
212+
213+
it('a rejecting before-hook calls done with the error and fails the suite tests', async () => {
214+
const onFailed = sinon.spy()
215+
event.dispatcher.on(event.test.failed, onFailed)
216+
const suiteTests = [{ title: 'sample test' }]
217+
const suite = {
218+
opts: {},
219+
ctx: { test: { title: '"before each" hook' }, currentTest: suiteTests[0] },
220+
eachTest: cb => suiteTests.forEach(cb),
221+
}
222+
const fn = async () => {
223+
throw new Error('hookfail')
224+
}
225+
const hook = injected(fn, suite, 'before')
226+
const { arg } = await runHook(hook.bind({ test: {} }))
227+
expect(arg, 'done received the error').to.be.instanceof(Error)
228+
expect(arg.message).to.equal('hookfail')
229+
expect(onFailed.called, 'test.failed emitted for suite tests').to.be.true
230+
expect(suiteTests[0].err, 'the suite test got the hook error attached').to.be.instanceof(Error)
231+
})
232+
})
143233
})

test/unit/recorder_test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect } from 'chai'
22
import recorder from '../../lib/recorder.js'
3+
import { TimeoutError } from '../../lib/timeout.js'
34

45
describe('Recorder', () => {
56
beforeEach(() => recorder.start())
@@ -168,4 +169,107 @@ describe('Recorder', () => {
168169
return recorder.promise()
169170
})
170171
})
172+
173+
describe('#error paths (characterization)', () => {
174+
it('routes a task error to errFn and stops when catch() has no args', async () => {
175+
let caught
176+
recorder.errHandler(err => (caught = err))
177+
recorder.add(() => {
178+
throw new Error('boom')
179+
})
180+
recorder.catch()
181+
await recorder.promise()
182+
expect(caught).to.be.instanceof(Error)
183+
expect(caught.message).to.equal('boom')
184+
expect(recorder.isRunning()).to.equal(false)
185+
})
186+
187+
it('catchWithoutStop runs fn for a normal error and the chain continues', async () => {
188+
let handled
189+
let after = false
190+
recorder.add(() => {
191+
throw new Error('soft')
192+
})
193+
recorder.catchWithoutStop(err => (handled = err.message))
194+
recorder.add(() => (after = true))
195+
await recorder.promise()
196+
expect(handled).to.equal('soft')
197+
expect(after).to.equal(true)
198+
})
199+
200+
it('catchWithoutStop re-throws a terminal error past fn', async () => {
201+
let fnCalled = false
202+
const err = new Error('terminal')
203+
err.isTerminal = true
204+
recorder.add(() => {
205+
throw err
206+
})
207+
recorder.catchWithoutStop(() => (fnCalled = true))
208+
let rejected
209+
await recorder.promise().catch(e => (rejected = e))
210+
expect(fnCalled).to.equal(false)
211+
expect(rejected).to.equal(err)
212+
})
213+
214+
it('throw() after ignoreErr() does not reject the chain', async () => {
215+
const err = new Error('ignored')
216+
recorder.ignoreErr(err)
217+
recorder.throw(err)
218+
recorder.add(() => 'ok')
219+
let rejected = false
220+
await recorder.promise().catch(() => (rejected = true))
221+
expect(rejected).to.equal(false)
222+
})
223+
224+
it('two levels of nested sessions restore the session id to parent then null', () => {
225+
const ids = []
226+
recorder.add(() => {
227+
recorder.session.start('outer')
228+
ids.push(recorder.getCurrentSessionId())
229+
recorder.session.start('inner')
230+
ids.push(recorder.getCurrentSessionId())
231+
recorder.session.restore('inner')
232+
ids.push(recorder.getCurrentSessionId())
233+
recorder.session.restore('outer')
234+
ids.push(recorder.getCurrentSessionId())
235+
})
236+
return recorder.promise().then(() => {
237+
expect(ids).to.deep.equal(['outer', 'inner', 'outer', null])
238+
})
239+
})
240+
241+
it('characterizes an unbalanced session start (no matching restore)', async () => {
242+
recorder.add(() => {
243+
recorder.session.start('orphan')
244+
})
245+
recorder.add(() => 'x')
246+
await recorder.promise()
247+
expect(recorder.getCurrentSessionId()).to.equal('orphan')
248+
recorder.reset()
249+
expect(recorder.getCurrentSessionId()).to.equal(null)
250+
})
251+
252+
it('rejects with TimeoutError when a task exceeds its timeout', async () => {
253+
recorder.retries = []
254+
recorder.add('slow', () => new Promise(r => setTimeout(r, 200)), false, false, 50)
255+
let err
256+
await recorder.promise().catch(e => (err = e))
257+
expect(err).to.be.instanceof(TimeoutError)
258+
})
259+
260+
it('does not reject later when a fast task finishes within its timeout', async () => {
261+
recorder.retries = []
262+
const unhandled = []
263+
const onUnhandled = e => unhandled.push(e)
264+
process.on('unhandledRejection', onUnhandled)
265+
try {
266+
recorder.add('fast', () => new Promise(r => setTimeout(r, 10)), false, false, 50)
267+
await recorder.promise()
268+
await new Promise(r => setTimeout(r, 120))
269+
expect(unhandled).to.have.length(0)
270+
} finally {
271+
process.removeListener('unhandledRejection', onUnhandled)
272+
}
273+
})
274+
})
171275
})

0 commit comments

Comments
 (0)