diff --git a/lib/container.js b/lib/container.js index 89e80f3d8..f16144237 100644 --- a/lib/container.js +++ b/lib/container.js @@ -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 || {}) diff --git a/lib/effects.js b/lib/effects.js index 94db32069..e83fda349 100644 --- a/lib/effects.js +++ b/lib/effects.js @@ -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()) }) } @@ -47,11 +55,13 @@ 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) }) } @@ -59,16 +69,18 @@ function within(context, fn) { 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, @@ -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}`) @@ -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 @@ -206,13 +218,13 @@ 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) { @@ -220,17 +232,17 @@ async function retryTo(callback, maxTries, pollInterval = 200) { } // 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) @@ -238,10 +250,12 @@ async function retryTo(callback, maxTries, pollInterval = 200) { }) } - 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) + }) }) } @@ -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 diff --git a/lib/recorder.js b/lib/recorder.js index 2f55ee093..80e69dd01 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -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() }, diff --git a/test/data/effects-tsx-cjs/codecept.conf.ts b/test/data/effects-tsx-cjs/codecept.conf.ts new file mode 100644 index 000000000..eb2abfbd2 --- /dev/null +++ b/test/data/effects-tsx-cjs/codecept.conf.ts @@ -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"] +}; diff --git a/test/data/effects-tsx-cjs/effects_helper.js b/test/data/effects-tsx-cjs/effects_helper.js new file mode 100644 index 000000000..94619cfca --- /dev/null +++ b/test/data/effects-tsx-cjs/effects_helper.js @@ -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 diff --git a/test/data/effects-tsx-cjs/effects_test.ts b/test/data/effects-tsx-cjs/effects_test.ts new file mode 100644 index 000000000..d0b00a86d --- /dev/null +++ b/test/data/effects-tsx-cjs/effects_test.ts @@ -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"); +}); diff --git a/test/data/effects-tsx-cjs/package.json b/test/data/effects-tsx-cjs/package.json new file mode 100644 index 000000000..fd8abe5f7 --- /dev/null +++ b/test/data/effects-tsx-cjs/package.json @@ -0,0 +1,8 @@ +{ + "name": "effects-tsx-cjs", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "tsx": "^4.20.6" + } +} diff --git a/test/data/effects-tsx-cjs/tsconfig.json b/test/data/effects-tsx-cjs/tsconfig.json new file mode 100644 index 000000000..ed768c564 --- /dev/null +++ b/test/data/effects-tsx-cjs/tsconfig.json @@ -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"] +} diff --git a/test/runner/effects_tsx_test.js b/test/runner/effects_tsx_test.js new file mode 100644 index 000000000..2dbd3186f --- /dev/null +++ b/test/runner/effects_tsx_test.js @@ -0,0 +1,41 @@ +import * as chai from 'chai' +chai.should() +import path from 'path' +import { exec } from 'child_process' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const runner = path.join(__dirname, '/../../bin/codecept.js') +const codecept_dir = path.join(__dirname, '/../data/effects-tsx-cjs') +const codecept_run = `${runner} run --config ${codecept_dir}/codecept.conf.ts` + +// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5632 +// When effects are imported through a CommonJS loader (tsx/cjs) a second, disconnected +// copy of recorder/container was loaded, so within() and tryTo() silently did nothing. +describe('CodeceptJS effects under tsx/cjs', function () { + this.timeout(40000) + + // `timeout` kills the child if a regression makes retryTo() hang forever (its + // manual promise never resolves on a disconnected recorder), so a broken fix + // fails on the missing markers instead of hanging the whole suite. + it('executes within(), tryTo(), hopeThat() and retryTo() when imported via tsx/cjs', done => { + exec(`${codecept_run}`, { timeout: 30000 }, (err, stdout) => { + stdout.should.include('4 passed') + // tryTo ran its callback and resolved to false (a failed try), instead of + // returning undefined from a disconnected, never-started recorder + stdout.should.include('EFFECTS_TRYTO result=false') + // within() applied its context so the inner step saw _withinBegin + stdout.should.include('EFFECTS_CLICK withinActive=true') + // hopeThat() ran its callback and resolved to true + stdout.should.include('EFFECTS_PASS ran') + stdout.should.include('EFFECTS_HOPETHAT result=true') + // retryTo() retried the flaky callback until it passed + stdout.should.include('EFFECTS_FLAKY try=2') + stdout.should.include('EFFECTS_RETRY done') + chai.expect(err).to.be.null + done() + }) + }) +})