Skip to content

Commit fdcf074

Browse files
miraoclaude
andcommitted
fix(effects): make within/tryTo/hopeThat/retryTo work under tsx/cjs (#5632)
When a TypeScript test imports effects through a CommonJS loader (`import { tryTo } from 'codeceptjs/effects'` under `tsx/cjs`), Node loads a second, disconnected CJS copy of effects.js together with its own copies of the `recorder` and `container` singletons. That recorder is never started (`running=false`), so every `recorder.add()` short-circuits: tryTo() and hopeThat() return undefined without running their callback, within() silently skips its steps (and its empty container never calls `_withinBegin`), and retryTo() never resolves and hangs. This is the default situation for essentially every TS project, because CodeceptJS loads test files through Mocha's synchronous `require()` (CJS realm) while the framework itself runs as ESM. Fix by making the singletons realm-agnostic via the same globalThis bridge the framework already uses for `global.codeceptjs`: - recorder.js: start() registers the running instance on `globalThis.__codeceptjs_recorder` (only the started instance registers). - container.js: create() registers the live container on `globalThis.__codeceptjs_container`. - effects.js: resolve recorder/container through `_getRecorder()` / `_getContainer()`, which prefer the globalThis instances and fall back to the local imports under pure ESM (no behavior change there). Adds a runner regression test (test/data/effects-tsx-cjs + effects_tsx_test.js) that drives a real `tsx/cjs` project and asserts all four effects execute. It fails without the fix (and a timeout guard keeps a regressed retryTo() from hanging the suite) and passes with it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a615e9f commit fdcf074

9 files changed

Lines changed: 213 additions & 37 deletions

File tree

lib/container.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ class Container {
8080
this.createMocha = () => (container.mocha = MochaFactory.create(mochaConfig, opts || {}))
8181
this.createMocha()
8282

83+
// Expose this container globally so a duplicate copy of the framework (loaded as
84+
// CommonJS when a test does `import { within } from 'codeceptjs'` through a CJS
85+
// loader such as tsx/cjs) resolves helpers, support objects and plugins from the
86+
// live container that actually runs the tests. Without this bridge `within()` sees
87+
// an empty helpers map and never calls `_withinBegin`, so the within-context is
88+
// silently ignored. Mirrors the `globalThis.__codeceptjs_recorder` bridge in recorder.js.
89+
if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_container = Container
90+
8391
// create support objects
8492
container.support = {}
8593
container.helpers = await createHelpers(config.helpers || {})

lib/effects.js

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,46 @@ import MetaStep from './step/meta.js'
77
import { empty } from './assert/empty.js'
88
import { isAsyncFunction } from './utils.js'
99

10+
// When a test imports effects through a CommonJS loader (e.g. tsx/cjs), a second,
11+
// disconnected copy of this module and its `recorder`/`container` singletons is loaded.
12+
// That copy's recorder is never started and its container has no helpers, so effects
13+
// would silently do nothing. Resolve the live instances registered on globalThis by the
14+
// runner instead, falling back to the local singletons when running purely under ESM.
15+
const _getRecorder = () => (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_recorder) || recorder
16+
const _getContainer = () => (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_container) || container
17+
1018
/**
1119
* @param {CodeceptJS.LocatorOrString} context
1220
* @param {Function} fn
1321
* @return {Promise<*> | undefined}
1422
*/
1523
function within(context, fn) {
16-
const helpers = store.dryRun ? {} : container.helpers()
24+
const helpers = store.dryRun ? {} : _getContainer().helpers()
1725
const locator = typeof context === 'object' ? JSON.stringify(context) : context
1826

19-
return recorder.add(
27+
return _getRecorder().add(
2028
'register within wrapper',
2129
() => {
2230
const metaStep = new WithinStep(locator, fn)
2331
const defineMetaStep = step => (step.metaStep = metaStep)
24-
recorder.session.start('within')
32+
_getRecorder().session.start('within')
2533

2634
event.dispatcher.prependListener(event.step.before, defineMetaStep)
2735

2836
Object.keys(helpers).forEach(helper => {
29-
if (helpers[helper]._withinBegin) recorder.add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context))
37+
if (helpers[helper]._withinBegin) _getRecorder().add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context))
3038
})
3139

3240
const finalize = () => {
3341
event.dispatcher.removeListener(event.step.before, defineMetaStep)
34-
recorder.add('Finalize session within session', () => {
42+
_getRecorder().add('Finalize session within session', () => {
3543
output.stepShift = 1
36-
recorder.session.restore('within')
44+
_getRecorder().session.restore('within')
3745
})
3846
}
3947
const finishHelpers = () => {
4048
Object.keys(helpers).forEach(helper => {
41-
if (helpers[helper]._withinEnd) recorder.add(`[${helper}] finish within`, () => helpers[helper]._withinEnd())
49+
if (helpers[helper]._withinEnd) _getRecorder().add(`[${helper}] finish within`, () => helpers[helper]._withinEnd())
4250
})
4351
}
4452

@@ -47,28 +55,32 @@ function within(context, fn) {
4755
.then(res => {
4856
finishHelpers()
4957
finalize()
50-
return recorder.promise().then(() => res)
58+
return _getRecorder()
59+
.promise()
60+
.then(() => res)
5161
})
5262
.catch(e => {
5363
finalize()
54-
recorder.throw(e)
64+
_getRecorder().throw(e)
5565
})
5666
}
5767

5868
let res
5969
try {
6070
res = fn()
6171
} catch (err) {
62-
recorder.throw(err)
72+
_getRecorder().throw(err)
6373
} finally {
6474
finishHelpers()
65-
recorder.catch(err => {
75+
_getRecorder().catch(err => {
6676
output.stepShift = 1
6777
throw err
6878
})
6979
}
7080
finalize()
71-
return recorder.promise().then(() => res)
81+
return _getRecorder()
82+
.promise()
83+
.then(() => res)
7284
},
7385
false,
7486
false,
@@ -122,18 +134,18 @@ async function hopeThat(callback) {
122134
const sessionName = 'hopeThat'
123135

124136
let result = false
125-
return recorder.add(
137+
return _getRecorder().add(
126138
'hopeThat',
127139
() => {
128-
recorder.session.start(sessionName)
140+
_getRecorder().session.start(sessionName)
129141
store.hopeThat = true
130142
callback()
131-
recorder.add(() => {
143+
_getRecorder().add(() => {
132144
result = true
133-
recorder.session.restore(sessionName)
145+
_getRecorder().session.restore(sessionName)
134146
return result
135147
})
136-
recorder.session.catch(err => {
148+
_getRecorder().session.catch(err => {
137149
result = false
138150
const msg = err.inspect ? err.inspect() : err.toString()
139151
output.debug(`Unsuccessful assertion > ${msg}`)
@@ -142,10 +154,10 @@ async function hopeThat(callback) {
142154
if (!test.notes) test.notes = []
143155
test.notes.push({ type: 'conditionalError', text: msg })
144156
})
145-
recorder.session.restore(sessionName)
157+
_getRecorder().session.restore(sessionName)
146158
return result
147159
})
148-
return recorder.add(
160+
return _getRecorder().add(
149161
'result',
150162
() => {
151163
store.hopeThat = undefined
@@ -206,42 +218,44 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
206218
let tries = 1
207219

208220
function handleRetryException(err) {
209-
recorder.throw(err)
221+
_getRecorder().throw(err)
210222
reject(err)
211223
}
212224

213225
const tryBlock = async () => {
214226
tries++
215-
recorder.session.start(`${sessionName} ${tries}`)
227+
_getRecorder().session.start(`${sessionName} ${tries}`)
216228
try {
217229
await callback(tries)
218230
} catch (err) {
219231
handleRetryException(err)
220232
}
221233

222234
// Call done if no errors
223-
recorder.add(() => {
224-
recorder.session.restore(`${sessionName} ${tries}`)
235+
_getRecorder().add(() => {
236+
_getRecorder().session.restore(`${sessionName} ${tries}`)
225237
done(null)
226238
})
227239

228240
// Catch errors and retry
229-
recorder.session.catch(err => {
230-
recorder.session.restore(`${sessionName} ${tries}`)
241+
_getRecorder().session.catch(err => {
242+
_getRecorder().session.restore(`${sessionName} ${tries}`)
231243
if (tries <= maxTries) {
232244
output.debug(`Error ${err}... Retrying`)
233-
recorder.add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
245+
_getRecorder().add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
234246
} else {
235247
// if maxTries reached
236248
handleRetryException(err)
237249
}
238250
})
239251
}
240252

241-
recorder.add(sessionName, tryBlock).catch(err => {
242-
console.error('An error occurred:', err)
243-
done(null)
244-
})
253+
_getRecorder()
254+
.add(sessionName, tryBlock)
255+
.catch(err => {
256+
console.error('An error occurred:', err)
257+
done(null)
258+
})
245259
})
246260
}
247261

@@ -279,27 +293,27 @@ async function tryTo(callback) {
279293

280294
let result = false
281295
let isAutoRetriesEnabled = store.autoRetries
282-
return recorder.add(
296+
return _getRecorder().add(
283297
sessionName,
284298
() => {
285-
recorder.session.start(sessionName)
299+
_getRecorder().session.start(sessionName)
286300
isAutoRetriesEnabled = store.autoRetries
287301
if (isAutoRetriesEnabled) output.debug('Auto retries disabled inside tryTo effect')
288302
store.autoRetries = false
289303
callback()
290-
recorder.add(() => {
304+
_getRecorder().add(() => {
291305
result = true
292-
recorder.session.restore(sessionName)
306+
_getRecorder().session.restore(sessionName)
293307
return result
294308
})
295-
recorder.session.catch(err => {
309+
_getRecorder().session.catch(err => {
296310
result = false
297311
const msg = err.inspect ? err.inspect() : err.toString()
298312
output.debug(`Unsuccessful try > ${msg}`)
299-
recorder.session.restore(sessionName)
313+
_getRecorder().session.restore(sessionName)
300314
return result
301315
})
302-
return recorder.add(
316+
return _getRecorder().add(
303317
'result',
304318
() => {
305319
store.autoRetries = isAutoRetriesEnabled

lib/recorder.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export default {
4848
running = true
4949
asyncErr = null
5050
errFn = null
51+
// Register this instance globally so that a duplicate copy of the framework
52+
// (loaded as CommonJS when a test does `import { tryTo } from 'codeceptjs/effects'`
53+
// through a CJS loader such as tsx/cjs) can reach the running recorder instead of
54+
// its own disconnected one. Only the instance started by the runner registers itself.
55+
if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_recorder = this
5156
this.reset()
5257
},
5358

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const config: CodeceptJS.MainConfig = {
2+
tests: "./*_test.ts",
3+
output: "./output",
4+
helpers: {
5+
EffectsHelper: {
6+
require: "./effects_helper.js"
7+
}
8+
},
9+
name: "effects-tsx-cjs-test",
10+
require: ["tsx/cjs"]
11+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import HelperModule from '../../../lib/helper.js'
2+
const Helper = HelperModule.default || HelperModule
3+
4+
class EffectsHelper extends Helper {
5+
constructor(config) {
6+
super(config)
7+
this._withinActive = false
8+
this._tries = 0
9+
}
10+
11+
_withinBegin() {
12+
this._withinActive = true
13+
}
14+
15+
_withinEnd() {
16+
this._withinActive = false
17+
}
18+
19+
seeMissing() {
20+
throw new Error('element not found')
21+
}
22+
23+
clickInside() {
24+
console.log(`EFFECTS_CLICK withinActive=${this._withinActive}`)
25+
}
26+
27+
pass() {
28+
console.log('EFFECTS_PASS ran')
29+
}
30+
31+
flaky() {
32+
this._tries++
33+
console.log(`EFFECTS_FLAKY try=${this._tries}`)
34+
if (this._tries < 2) throw new Error('not ready yet')
35+
}
36+
}
37+
38+
export default EffectsHelper
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5632
2+
// Importing effects through a CommonJS loader (tsx/cjs) used to load a second,
3+
// disconnected copy of recorder/container, so within() and tryTo() silently did
4+
// nothing. The relative path is loaded as CJS here while the runner loads the same
5+
// module as ESM, reproducing the dual-instance split that the globalThis bridge fixes.
6+
import { within, tryTo, hopeThat, retryTo } from "../../../lib/effects.js";
7+
8+
Feature("effects under tsx/cjs");
9+
10+
Scenario("tryTo executes the failing step and returns false", async ({ I }) => {
11+
const ok = await tryTo(() => {
12+
I.seeMissing();
13+
});
14+
console.log(`EFFECTS_TRYTO result=${ok}`);
15+
});
16+
17+
Scenario("within applies the context to inner steps", ({ I }) => {
18+
within("body", () => {
19+
I.clickInside();
20+
});
21+
});
22+
23+
Scenario("hopeThat executes the soft assertion and returns true", async ({ I }) => {
24+
const ok = await hopeThat(() => {
25+
I.pass();
26+
});
27+
console.log(`EFFECTS_HOPETHAT result=${ok}`);
28+
});
29+
30+
// Kept last on purpose: when the recorder is disconnected, retryTo never resolves
31+
// and hangs, so the earlier markers are already flushed before the timeout fires.
32+
Scenario("retryTo runs the callback until it succeeds", async ({ I }) => {
33+
await retryTo(() => {
34+
I.flaky();
35+
}, 3);
36+
console.log("EFFECTS_RETRY done");
37+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "effects-tsx-cjs",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"devDependencies": {
6+
"tsx": "^4.20.6"
7+
}
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"target": "es2022",
4+
"lib": ["es2022", "DOM"],
5+
"esModuleInterop": true,
6+
"module": "esnext",
7+
"moduleResolution": "node",
8+
"strictNullChecks": false,
9+
"types": ["codeceptjs", "node"],
10+
"declaration": true,
11+
"skipLibCheck": true
12+
},
13+
"exclude": ["node_modules"]
14+
}

0 commit comments

Comments
 (0)