diff --git a/lib/config.js b/lib/config.js index eea2638d3..e5ef664ce 100644 --- a/lib/config.js +++ b/lib/config.js @@ -3,6 +3,7 @@ import path from 'path' import { createRequire } from 'module' import { fileExists, isFile, deepMerge, deepClone, resolveImportModulePath } from './utils.js' import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' +import { realmSingleton } from './realm.js' const defaultConfig = { output: './_output', @@ -35,7 +36,18 @@ const defaultConfig = { // Array<{ fn: (cfg) => void, ran: boolean, error?: Error }> let hooks = [] -let config = {} + +// Shared across realms (see realm.js). config is reassigned wholesale by create/append/ +// reset, so the live value lives in a stable holder object that every realm resolves to. +// A test, page object or fragment that does `import { config } from "codeceptjs"` is loaded +// as CommonJS (Mocha's require()) while the runner is ESM; without the shared holder the +// CJS copy reads its own empty `{}`. Helpers don't hit this — they load via import() (ESM). +const configHolder = realmSingleton('__codeceptjs_config', () => ({ value: {} })) + +function setConfig(value) { + configHolder.value = value + return value +} // Apply a single hook against `cfg`, swallowing errors so one broken hook // can't take down the whole run. The failure is logged through the @@ -67,13 +79,13 @@ class Config { * @return {Object} */ static create(newConfig) { - config = deepMerge(deepClone(defaultConfig), newConfig) + setConfig(deepMerge(deepClone(defaultConfig), newConfig)) // Re-apply every hook against the freshly built config; hooks added later // (e.g. from plugin boot) stay pending until runPendingHooks. Array // iterators re-check length on each step, so hooks pushed during a hook // execution are visited in this same pass. - for (const hook of hooks) applyHook(hook, config) - return config + for (const hook of hooks) applyHook(hook, configHolder.value) + return configHolder.value } /** @@ -137,10 +149,11 @@ class Config { * @return {*} */ static get(key, val) { + const cfg = configHolder.value if (key) { - return config[key] || val + return cfg[key] || val } - return config + return cfg } static addHook(fn) { @@ -160,7 +173,7 @@ class Config { * @param {Object} [cfg] target config (defaults to the live singleton) * @return {boolean} true if any hook ran */ - static runPendingHooks(cfg = config) { + static runPendingHooks(cfg = configHolder.value) { let ran = false for (const hook of hooks) { if (hook.ran) continue @@ -195,7 +208,7 @@ class Config { * @return {Object} */ static append(additionalConfig) { - return (config = deepMerge(config, additionalConfig)) + return setConfig(deepMerge(configHolder.value, additionalConfig)) } /** @@ -204,7 +217,7 @@ class Config { */ static reset() { hooks = [] - return (config = { ...defaultConfig }) + return setConfig({ ...defaultConfig }) } } diff --git a/lib/container.js b/lib/container.js index 89e80f3d8..dcbc9d7ee 100644 --- a/lib/container.js +++ b/lib/container.js @@ -25,6 +25,7 @@ import Result from './result.js' import ai from './ai.js' import actorFactory from './actor.js' import Config from './config.js' +import { realmSingleton } from './realm.js' let asyncHelperPromise @@ -52,6 +53,13 @@ let container = { tsFileMapping: null, // TypeScript file mapping for error stack fixing } +// Shared across realms (see realm.js): the runner (ESM) populates this state in +// Container.create(); a test/page object that does `import { container } from "codeceptjs"` +// is loaded as CommonJS and would otherwise read an empty, never-populated copy. Pointing +// the module variable at the shared object means the static accessors in every realm +// operate on the live helpers/support/plugins. +container = realmSingleton('__codeceptjs_container_state', () => container) + /** * Dependency Injection Container */ diff --git a/lib/event.js b/lib/event.js index 13a5bb4b5..d36df81a3 100644 --- a/lib/event.js +++ b/lib/event.js @@ -2,12 +2,18 @@ import debugModule from 'debug' const debug = debugModule('codeceptjs:event') import events from 'events' import output from './output.js' +import { realmSingleton } from './realm.js' const MAX_LISTENERS = 200 -const dispatcher = new events.EventEmitter() - -dispatcher.setMaxListeners(MAX_LISTENERS) +// Shared across realms so listeners registered from a test (CJS realm, via +// `import { event } from "codeceptjs"`) and events emitted by the runner (ESM realm) +// reach the same EventEmitter. Without this the test subscribes to a dead dispatcher. +const dispatcher = realmSingleton('__codeceptjs_dispatcher', () => { + const d = new events.EventEmitter() + d.setMaxListeners(MAX_LISTENERS) + return d +}) // Increase process max listeners to prevent warnings for beforeExit and other events if (typeof process.setMaxListeners === 'function') { diff --git a/lib/output.js b/lib/output.js index 13a84820b..52739dba9 100644 --- a/lib/output.js +++ b/lib/output.js @@ -1,6 +1,7 @@ import colors from 'chalk' import figures from 'figures' import { maskData, shouldMaskData, getMaskConfig } from './utils/mask_data.js' +import { realmSingleton } from './realm.js' const styles = { error: colors.bgRed.white.bold, @@ -304,7 +305,7 @@ const output = { }, } -export default output +export default realmSingleton('__codeceptjs_output', () => output) function print(...msg) { if (outputProcess) { diff --git a/lib/realm.js b/lib/realm.js new file mode 100644 index 000000000..052bdcc8a --- /dev/null +++ b/lib/realm.js @@ -0,0 +1,25 @@ +/** + * Cross-realm singleton sharing. + * + * CodeceptJS 4.x runs as native ESM, but test files, page objects and fragments are + * loaded through Mocha's synchronous require() (the CommonJS realm) under a CJS loader + * such as tsx/cjs (the setup the official Quickstart generates). So when user code does + * `import { recorder } from "codeceptjs"`, Node materializes a SECOND, disconnected CJS + * copy of the lib modules and their singletons — a copy the runner never populates, so + * the internal API silently reads empty/never-started state. Helpers don't hit this + * because they're loaded via import() (the ESM realm) and share the live singletons. + * + * Resolving the instance from globalThis (a single object shared across realms) makes + * every realm use the very object the runner operates on. The runner (ESM) always loads + * these modules first during bootstrap, so it wins the `key`; later CJS copies reuse it. + * Under pure ESM the module loads once, so there is no behavior change. + * + * @template T + * @param {string} key + * @param {() => T} factory + * @returns {T} + */ +export function realmSingleton(key, factory) { + if (globalThis[key] === undefined) globalThis[key] = factory() + return globalThis[key] +} diff --git a/lib/recorder.js b/lib/recorder.js index 2f55ee093..29f4d220f 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -5,6 +5,7 @@ import chalk from 'chalk' import { printObjectProperties } from './utils.js' import output from './output.js' import { TimeoutError } from './timeout.js' +import { realmSingleton } from './realm.js' const MAX_TASKS = 100 let promise @@ -30,7 +31,7 @@ const defaultRetryOptions = { * @alias recorder * @interface */ -export default { +const recorder = { /** * @type {Array>} * @inner @@ -425,6 +426,8 @@ export default { }, } +export default realmSingleton('__codeceptjs_recorder', () => recorder) + function getTimeoutPromise(timeoutMs, taskName) { let timer if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`) diff --git a/lib/store.js b/lib/store.js index a6472cf8c..ed1fbcb0e 100644 --- a/lib/store.js +++ b/lib/store.js @@ -1,3 +1,5 @@ +import { realmSingleton } from './realm.js' + /** * Global store for current session * @namespace @@ -112,4 +114,4 @@ const store = { }, } -export default store +export default realmSingleton('__codeceptjs_store', () => store) diff --git a/test/data/internal-api-tsx-cjs/codecept.conf.ts b/test/data/internal-api-tsx-cjs/codecept.conf.ts new file mode 100644 index 000000000..51e632ddd --- /dev/null +++ b/test/data/internal-api-tsx-cjs/codecept.conf.ts @@ -0,0 +1,12 @@ +export const config: CodeceptJS.MainConfig = { + tests: "./*_test.ts", + output: "./output", + helpers: { + ConfigHelper: { + require: "./internal_api_helper.js", + marker: "config-marker-123" + } + }, + name: "internal-api-tsx-cjs-test", + require: ["tsx/cjs"] +}; diff --git a/test/data/internal-api-tsx-cjs/effects_test.ts b/test/data/internal-api-tsx-cjs/effects_test.ts new file mode 100644 index 000000000..7fb1fff36 --- /dev/null +++ b/test/data/internal-api-tsx-cjs/effects_test.ts @@ -0,0 +1,41 @@ +// Effects regression under tsx/cjs (originally https://github.com/codeceptjs/CodeceptJS/issues/5632, +// first addressed in PR #5634). within/tryTo/hopeThat/retryTo are part of the internal API and +// delegate to the recorder/container singletons. Imported through a CommonJS loader (tsx/cjs) they +// used to load a second, disconnected copy: tryTo/hopeThat returned without running their callback, +// within skipped its inner steps, and retryTo hung forever. +// +// With the singletons shared at the source (lib/realm.js, #5635) the CJS copy of effects.js resolves +// the live recorder/container, so all four run. Kept as dedicated coverage so a future regression that +// re-splits the recorder/container fails here too. +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 runner-test 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/internal-api-tsx-cjs/internal_api_helper.js b/test/data/internal-api-tsx-cjs/internal_api_helper.js new file mode 100644 index 000000000..949306e0b --- /dev/null +++ b/test/data/internal-api-tsx-cjs/internal_api_helper.js @@ -0,0 +1,47 @@ +import HelperModule from '../../../lib/helper.js' +import ConfigModule from '../../../lib/config.js' + +const Helper = HelperModule.default || HelperModule +const Config = ConfigModule.default || ConfigModule + +class ConfigHelper extends Helper { + constructor(config) { + super(config) + this._withinActive = false + this._tries = 0 + } + + reportConfig() { + // Helper is loaded via import() (the ESM realm), so it has always shared the live config. + console.log(`API_HELPER marker=${Config.get().helpers.ConfigHelper.marker}`) + } + + // --- used by the effects scenarios --- + _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 ConfigHelper diff --git a/test/data/internal-api-tsx-cjs/internal_api_test.ts b/test/data/internal-api-tsx-cjs/internal_api_test.ts new file mode 100644 index 000000000..078f00cc6 --- /dev/null +++ b/test/data/internal-api-tsx-cjs/internal_api_test.ts @@ -0,0 +1,29 @@ +// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 +// The internal API (https://codecept.io/architecture#the-internal-api) is supposed to work +// from tests, page objects and fragments — not only helpers. But test files are loaded via +// Mocha's synchronous require() (the CommonJS realm) under a CJS loader such as tsx/cjs, +// while the framework runs as native ESM. So importing the internal API from a test used to +// load a second, disconnected CJS copy of each singleton: config.get() returned {}, the +// container had no helpers, the recorder was never started and the event dispatcher had no +// listeners. Helpers were unaffected because they load via import() (the ESM realm). +// +// Each singleton below is now shared across realms (see lib/realm.js), so the test reads the +// very instances the runner operates on. +import { config, container, recorder, event, store } from "../../../lib/index.js"; + +Feature("internal API under tsx/cjs"); + +Scenario("internal API resolves the live singletons from a test", ({ I }) => { + // config — the value the runner loaded, not an empty {} + console.log(`API_CONFIG name=${config.get("name")}`); + console.log(`API_CONFIG marker=${config.get().helpers.ConfigHelper.marker}`); + // container — the live helpers map the runner populated + console.log(`API_CONTAINER helper=${typeof container.helpers("ConfigHelper")}`); + // store — initialized by the runner + console.log(`API_STORE hasDir=${Boolean(store.codeceptDir)}`); + // recorder — started by the runner for this test (would be false on a disconnected copy) + console.log(`API_RECORDER running=${recorder.isRunning()}`); + // event — the live dispatcher the framework subscribes to (a disconnected copy has none) + console.log(`API_EVENT live=${event.dispatcher.eventNames().length > 0}`); + I.reportConfig(); +}); diff --git a/test/data/internal-api-tsx-cjs/package.json b/test/data/internal-api-tsx-cjs/package.json new file mode 100644 index 000000000..27330843b --- /dev/null +++ b/test/data/internal-api-tsx-cjs/package.json @@ -0,0 +1,8 @@ +{ + "name": "internal-api-tsx-cjs", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "tsx": "^4.20.6" + } +} diff --git a/test/data/internal-api-tsx-cjs/tsconfig.json b/test/data/internal-api-tsx-cjs/tsconfig.json new file mode 100644 index 000000000..ed768c564 --- /dev/null +++ b/test/data/internal-api-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/internal_api_tsx_test.js b/test/runner/internal_api_tsx_test.js new file mode 100644 index 000000000..854004163 --- /dev/null +++ b/test/runner/internal_api_tsx_test.js @@ -0,0 +1,66 @@ +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/internal-api-tsx-cjs') +const codecept_run = `${runner} run --config ${codecept_dir}/codecept.conf.ts` + +// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 +// The internal API (config/container/recorder/event/store, and the effects that delegate to them) +// must resolve to the live singletons when imported from a test through a CommonJS loader (tsx/cjs), +// not a second, disconnected copy. Drives a real tsx/cjs project once and asserts on its output. +describe('CodeceptJS internal API under tsx/cjs', function () { + this.timeout(40000) + + let stdout = '' + let runErr = null + + // Run the fixture once; the `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. + before(done => { + exec(`${codecept_run}`, { timeout: 30000 }, (err, out) => { + runErr = err + stdout = out + done() + }) + }) + + it('resolves config/container/recorder/event/store to the live singletons from a test', () => { + stdout.should.include('5 passed') + // config (#5635): the real config the runner loaded, not an empty {} + stdout.should.include('API_CONFIG name=internal-api-tsx-cjs-test') + stdout.should.include('API_CONFIG marker=config-marker-123') + // container: the live helpers map populated by the runner + stdout.should.include('API_CONTAINER helper=object') + // store: initialized by the runner + stdout.should.include('API_STORE hasDir=true') + // recorder: started by the runner for this test + stdout.should.include('API_RECORDER running=true') + // event: the live dispatcher the framework subscribes to + stdout.should.include('API_EVENT live=true') + // helper (ESM realm) reads the same live config + stdout.should.include('API_HELPER marker=config-marker-123') + chai.expect(runErr).to.be.null + }) + + it('runs within/tryTo/hopeThat/retryTo imported through the CJS loader', () => { + // 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 (and did not hang) + stdout.should.include('EFFECTS_FLAKY try=2') + stdout.should.include('EFFECTS_RETRY done') + }) +})