Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ class Container {
this.createMocha = () => (container.mocha = MochaFactory.create(mochaConfig, opts || {}))
this.createMocha()

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

// create support objects
container.support = {}
container.helpers = await createHelpers(config.helpers || {})
Expand Down
88 changes: 51 additions & 37 deletions lib/effects.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,46 @@ import MetaStep from './step/meta.js'
import { empty } from './assert/empty.js'
import { isAsyncFunction } from './utils.js'

// When a test imports effects through a CommonJS loader (e.g. tsx/cjs), a second,
// disconnected copy of this module and its `recorder`/`container` singletons is loaded.
// That copy's recorder is never started and its container has no helpers, so effects
// would silently do nothing. Resolve the live instances registered on globalThis by the
// runner instead, falling back to the local singletons when running purely under ESM.
const _getRecorder = () => (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_recorder) || recorder
const _getContainer = () => (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_container) || container

/**
* @param {CodeceptJS.LocatorOrString} context
* @param {Function} fn
* @return {Promise<*> | undefined}
*/
function within(context, fn) {
const helpers = store.dryRun ? {} : container.helpers()
const helpers = store.dryRun ? {} : _getContainer().helpers()
const locator = typeof context === 'object' ? JSON.stringify(context) : context

return recorder.add(
return _getRecorder().add(
'register within wrapper',
() => {
const metaStep = new WithinStep(locator, fn)
const defineMetaStep = step => (step.metaStep = metaStep)
recorder.session.start('within')
_getRecorder().session.start('within')

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

Object.keys(helpers).forEach(helper => {
if (helpers[helper]._withinBegin) recorder.add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context))
if (helpers[helper]._withinBegin) _getRecorder().add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context))
})

const finalize = () => {
event.dispatcher.removeListener(event.step.before, defineMetaStep)
recorder.add('Finalize session within session', () => {
_getRecorder().add('Finalize session within session', () => {
output.stepShift = 1
recorder.session.restore('within')
_getRecorder().session.restore('within')
})
}
const finishHelpers = () => {
Object.keys(helpers).forEach(helper => {
if (helpers[helper]._withinEnd) recorder.add(`[${helper}] finish within`, () => helpers[helper]._withinEnd())
if (helpers[helper]._withinEnd) _getRecorder().add(`[${helper}] finish within`, () => helpers[helper]._withinEnd())
})
}

Expand All @@ -47,28 +55,32 @@ function within(context, fn) {
.then(res => {
finishHelpers()
finalize()
return recorder.promise().then(() => res)
return _getRecorder()
.promise()
.then(() => res)
})
.catch(e => {
finalize()
recorder.throw(e)
_getRecorder().throw(e)
})
}

let res
try {
res = fn()
} catch (err) {
recorder.throw(err)
_getRecorder().throw(err)
} finally {
finishHelpers()
recorder.catch(err => {
_getRecorder().catch(err => {
output.stepShift = 1
throw err
})
}
finalize()
return recorder.promise().then(() => res)
return _getRecorder()
.promise()
.then(() => res)
},
false,
false,
Expand Down Expand Up @@ -122,18 +134,18 @@ async function hopeThat(callback) {
const sessionName = 'hopeThat'

let result = false
return recorder.add(
return _getRecorder().add(
'hopeThat',
() => {
recorder.session.start(sessionName)
_getRecorder().session.start(sessionName)
store.hopeThat = true
callback()
recorder.add(() => {
_getRecorder().add(() => {
result = true
recorder.session.restore(sessionName)
_getRecorder().session.restore(sessionName)
return result
})
recorder.session.catch(err => {
_getRecorder().session.catch(err => {
result = false
const msg = err.inspect ? err.inspect() : err.toString()
output.debug(`Unsuccessful assertion > ${msg}`)
Expand All @@ -142,10 +154,10 @@ async function hopeThat(callback) {
if (!test.notes) test.notes = []
test.notes.push({ type: 'conditionalError', text: msg })
})
recorder.session.restore(sessionName)
_getRecorder().session.restore(sessionName)
return result
})
return recorder.add(
return _getRecorder().add(
'result',
() => {
store.hopeThat = undefined
Expand Down Expand Up @@ -206,42 +218,44 @@ async function retryTo(callback, maxTries, pollInterval = 200) {
let tries = 1

function handleRetryException(err) {
recorder.throw(err)
_getRecorder().throw(err)
reject(err)
}

const tryBlock = async () => {
tries++
recorder.session.start(`${sessionName} ${tries}`)
_getRecorder().session.start(`${sessionName} ${tries}`)
try {
await callback(tries)
} catch (err) {
handleRetryException(err)
}

// Call done if no errors
recorder.add(() => {
recorder.session.restore(`${sessionName} ${tries}`)
_getRecorder().add(() => {
_getRecorder().session.restore(`${sessionName} ${tries}`)
done(null)
})

// Catch errors and retry
recorder.session.catch(err => {
recorder.session.restore(`${sessionName} ${tries}`)
_getRecorder().session.catch(err => {
_getRecorder().session.restore(`${sessionName} ${tries}`)
if (tries <= maxTries) {
output.debug(`Error ${err}... Retrying`)
recorder.add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
_getRecorder().add(`${sessionName} ${tries}`, () => setTimeout(tryBlock, pollInterval))
} else {
// if maxTries reached
handleRetryException(err)
}
})
}

recorder.add(sessionName, tryBlock).catch(err => {
console.error('An error occurred:', err)
done(null)
})
_getRecorder()
.add(sessionName, tryBlock)
.catch(err => {
console.error('An error occurred:', err)
done(null)
})
})
}

Expand Down Expand Up @@ -279,27 +293,27 @@ async function tryTo(callback) {

let result = false
let isAutoRetriesEnabled = store.autoRetries
return recorder.add(
return _getRecorder().add(
sessionName,
() => {
recorder.session.start(sessionName)
_getRecorder().session.start(sessionName)
isAutoRetriesEnabled = store.autoRetries
if (isAutoRetriesEnabled) output.debug('Auto retries disabled inside tryTo effect')
store.autoRetries = false
callback()
recorder.add(() => {
_getRecorder().add(() => {
result = true
recorder.session.restore(sessionName)
_getRecorder().session.restore(sessionName)
return result
})
recorder.session.catch(err => {
_getRecorder().session.catch(err => {
result = false
const msg = err.inspect ? err.inspect() : err.toString()
output.debug(`Unsuccessful try > ${msg}`)
recorder.session.restore(sessionName)
_getRecorder().session.restore(sessionName)
return result
})
return recorder.add(
return _getRecorder().add(
'result',
() => {
store.autoRetries = isAutoRetriesEnabled
Expand Down
5 changes: 5 additions & 0 deletions lib/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export default {
running = true
asyncErr = null
errFn = null
// Register this instance globally so that a duplicate copy of the framework
// (loaded as CommonJS when a test does `import { tryTo } from 'codeceptjs/effects'`
// through a CJS loader such as tsx/cjs) can reach the running recorder instead of
// its own disconnected one. Only the instance started by the runner registers itself.
if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_recorder = this
this.reset()
},

Expand Down
11 changes: 11 additions & 0 deletions test/data/effects-tsx-cjs/codecept.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const config: CodeceptJS.MainConfig = {
tests: "./*_test.ts",
output: "./output",
helpers: {
EffectsHelper: {
require: "./effects_helper.js"
}
},
name: "effects-tsx-cjs-test",
require: ["tsx/cjs"]
};
38 changes: 38 additions & 0 deletions test/data/effects-tsx-cjs/effects_helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import HelperModule from '../../../lib/helper.js'
const Helper = HelperModule.default || HelperModule

class EffectsHelper extends Helper {
constructor(config) {
super(config)
this._withinActive = false
this._tries = 0
}

_withinBegin() {
this._withinActive = true
}

_withinEnd() {
this._withinActive = false
}

seeMissing() {
throw new Error('element not found')
}

clickInside() {
console.log(`EFFECTS_CLICK withinActive=${this._withinActive}`)
}

pass() {
console.log('EFFECTS_PASS ran')
}

flaky() {
this._tries++
console.log(`EFFECTS_FLAKY try=${this._tries}`)
if (this._tries < 2) throw new Error('not ready yet')
}
}

export default EffectsHelper
37 changes: 37 additions & 0 deletions test/data/effects-tsx-cjs/effects_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5632
// Importing effects through a CommonJS loader (tsx/cjs) used to load a second,
// disconnected copy of recorder/container, so within() and tryTo() silently did
// nothing. The relative path is loaded as CJS here while the runner loads the same
// module as ESM, reproducing the dual-instance split that the globalThis bridge fixes.
import { within, tryTo, hopeThat, retryTo } from "../../../lib/effects.js";

Feature("effects under tsx/cjs");

Scenario("tryTo executes the failing step and returns false", async ({ I }) => {
const ok = await tryTo(() => {
I.seeMissing();
});
console.log(`EFFECTS_TRYTO result=${ok}`);
});

Scenario("within applies the context to inner steps", ({ I }) => {
within("body", () => {
I.clickInside();
});
});

Scenario("hopeThat executes the soft assertion and returns true", async ({ I }) => {
const ok = await hopeThat(() => {
I.pass();
});
console.log(`EFFECTS_HOPETHAT result=${ok}`);
});

// Kept last on purpose: when the recorder is disconnected, retryTo never resolves
// and hangs, so the earlier markers are already flushed before the timeout fires.
Scenario("retryTo runs the callback until it succeeds", async ({ I }) => {
await retryTo(() => {
I.flaky();
}, 3);
console.log("EFFECTS_RETRY done");
});
8 changes: 8 additions & 0 deletions test/data/effects-tsx-cjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "effects-tsx-cjs",
"version": "1.0.0",
"type": "module",
"devDependencies": {
"tsx": "^4.20.6"
}
}
14 changes: 14 additions & 0 deletions test/data/effects-tsx-cjs/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "DOM"],
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"strictNullChecks": false,
"types": ["codeceptjs", "node"],
"declaration": true,
"skipLibCheck": true
},
"exclude": ["node_modules"]
}
Loading
Loading