From f2d832cf000dbda9d52042f0dfe8095c89831d7c Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Sun, 14 Jun 2026 08:41:06 +0200 Subject: [PATCH 1/4] fix(config): make config.get() work from tests/page objects under tsx/cjs (#5635) The internal API `config` imported via `import { config } from "codeceptjs"` returned an empty object `{}` from tests, page objects and fragments, while it worked from helpers. Test files are loaded through Mocha's synchronous require() (the CommonJS realm) under a CJS loader such as tsx/cjs, while the framework runs as native ESM. So a test importing `config` gets a second, disconnected CJS copy of config.js whose module-scoped `config` is an empty `{}` that the runner never populates. Helpers don't hit this because they are loaded via import() (the ESM realm) and share the live singleton. Bridge the live config through globalThis, the same way recorder.js and container.js were fixed in #5634: setConfig() mirrors the config onto globalThis.__codeceptjs_config on every create/append/reset, and Config.get() prefers it, falling back to the local module copy. Under pure ESM both point at the same object, so there is no behavior change on the ESM path. Adds a runner regression test driving a real tsx/cjs project that asserts a test (CJS realm) and a helper (ESM realm) both read the live config. It fails without the fix and passes with it. Co-Authored-By: Claude Opus 4.8 --- lib/config.js | 25 +++++++++++++---- test/data/config-tsx-cjs/codecept.conf.ts | 12 +++++++++ test/data/config-tsx-cjs/config_helper.js | 14 ++++++++++ test/data/config-tsx-cjs/config_test.ts | 18 +++++++++++++ test/data/config-tsx-cjs/package.json | 8 ++++++ test/data/config-tsx-cjs/tsconfig.json | 14 ++++++++++ test/runner/config_tsx_test.js | 33 +++++++++++++++++++++++ 7 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 test/data/config-tsx-cjs/codecept.conf.ts create mode 100644 test/data/config-tsx-cjs/config_helper.js create mode 100644 test/data/config-tsx-cjs/config_test.ts create mode 100644 test/data/config-tsx-cjs/package.json create mode 100644 test/data/config-tsx-cjs/tsconfig.json create mode 100644 test/runner/config_tsx_test.js diff --git a/lib/config.js b/lib/config.js index eea2638d3..8b1db6ae1 100644 --- a/lib/config.js +++ b/lib/config.js @@ -37,6 +37,20 @@ const defaultConfig = { let hooks = [] let config = {} +// Expose the live config object globally so a duplicate copy of the framework +// (loaded as CommonJS when a test, page object or fragment does +// `import { config } from 'codeceptjs'` through a CJS loader such as tsx/cjs) +// reads the same config the runner actually loaded. Test files are loaded via +// Mocha's synchronous require() (CJS realm) while the framework runs as ESM, so +// the CJS copy would otherwise see its own empty module-scoped `config = {}`. +// Helpers don't hit this because they're loaded via import() (the ESM realm). +// Mirrors the globalThis bridges in recorder.js and container.js. +function setConfig(value) { + config = value + if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_config = config + return config +} + // 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 // framework's own output module (when available) so it shows up in test @@ -67,7 +81,7 @@ 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 @@ -137,10 +151,11 @@ class Config { * @return {*} */ static get(key, val) { + const cfg = (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_config) || config if (key) { - return config[key] || val + return cfg[key] || val } - return config + return cfg } static addHook(fn) { @@ -195,7 +210,7 @@ class Config { * @return {Object} */ static append(additionalConfig) { - return (config = deepMerge(config, additionalConfig)) + return setConfig(deepMerge(config, additionalConfig)) } /** @@ -204,7 +219,7 @@ class Config { */ static reset() { hooks = [] - return (config = { ...defaultConfig }) + return setConfig({ ...defaultConfig }) } } diff --git a/test/data/config-tsx-cjs/codecept.conf.ts b/test/data/config-tsx-cjs/codecept.conf.ts new file mode 100644 index 000000000..4685ebafc --- /dev/null +++ b/test/data/config-tsx-cjs/codecept.conf.ts @@ -0,0 +1,12 @@ +export const config: CodeceptJS.MainConfig = { + tests: "./*_test.ts", + output: "./output", + helpers: { + ConfigHelper: { + require: "./config_helper.js", + marker: "config-marker-123" + } + }, + name: "config-tsx-cjs-test", + require: ["tsx/cjs"] +}; diff --git a/test/data/config-tsx-cjs/config_helper.js b/test/data/config-tsx-cjs/config_helper.js new file mode 100644 index 000000000..e65db98b5 --- /dev/null +++ b/test/data/config-tsx-cjs/config_helper.js @@ -0,0 +1,14 @@ +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 { + reportConfig() { + // Helper is loaded via import() (ESM realm), so it has always shared the live config. + console.log(`CONFIG_FROM_HELPER marker=${Config.get().helpers.ConfigHelper.marker}`) + } +} + +export default ConfigHelper diff --git a/test/data/config-tsx-cjs/config_test.ts b/test/data/config-tsx-cjs/config_test.ts new file mode 100644 index 000000000..ca8f59233 --- /dev/null +++ b/test/data/config-tsx-cjs/config_test.ts @@ -0,0 +1,18 @@ +// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 +// Importing the internal API (config) through a CommonJS loader (tsx/cjs) used to load +// a second, disconnected copy of config.js whose module-scoped `config` was an empty +// object, so `config.get()` returned {} from tests/page objects/fragments while helpers +// (loaded via import(), the ESM realm) saw the real config. The relative path is loaded +// as CJS here while the runner loads the same module as ESM, reproducing the split that +// the globalThis bridge fixes. +import ConfigModule from "../../../lib/config.js"; + +const Config = (ConfigModule as any).default || ConfigModule; + +Feature("config under tsx/cjs"); + +Scenario("config.get() reads the live config from a test", ({ I }) => { + console.log(`CONFIG_FROM_TEST name=${Config.get("name")}`); + console.log(`CONFIG_FROM_TEST marker=${Config.get().helpers.ConfigHelper.marker}`); + I.reportConfig(); +}); diff --git a/test/data/config-tsx-cjs/package.json b/test/data/config-tsx-cjs/package.json new file mode 100644 index 000000000..00e4e9ae0 --- /dev/null +++ b/test/data/config-tsx-cjs/package.json @@ -0,0 +1,8 @@ +{ + "name": "config-tsx-cjs", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "tsx": "^4.20.6" + } +} diff --git a/test/data/config-tsx-cjs/tsconfig.json b/test/data/config-tsx-cjs/tsconfig.json new file mode 100644 index 000000000..ed768c564 --- /dev/null +++ b/test/data/config-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/config_tsx_test.js b/test/runner/config_tsx_test.js new file mode 100644 index 000000000..82b5bdb4d --- /dev/null +++ b/test/runner/config_tsx_test.js @@ -0,0 +1,33 @@ +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/config-tsx-cjs') +const codecept_run = `${runner} run --config ${codecept_dir}/codecept.conf.ts` + +// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 +// When the internal API (config) is imported through a CommonJS loader (tsx/cjs) a second, +// disconnected copy of config.js was loaded, so config.get() returned {} from tests/page +// objects while helpers (loaded via import()) saw the real config. +describe('CodeceptJS config under tsx/cjs', function () { + this.timeout(40000) + + it('config.get() reads the live config from a test imported via tsx/cjs', done => { + exec(`${codecept_run}`, { timeout: 30000 }, (err, stdout) => { + stdout.should.include('1 passed') + // the test (CJS realm) reads the real config the runner loaded, not an empty {} + stdout.should.include('CONFIG_FROM_TEST name=config-tsx-cjs-test') + stdout.should.include('CONFIG_FROM_TEST marker=config-marker-123') + // the helper (ESM realm) reads the same live config + stdout.should.include('CONFIG_FROM_HELPER marker=config-marker-123') + chai.expect(err).to.be.null + done() + }) + }) +}) From e02f5fac2b456a08f0e9f3a4c47ee466bef99578 Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Sun, 14 Jun 2026 09:20:06 +0200 Subject: [PATCH 2/4] fix(core): make the whole internal API realm-agnostic under tsx/cjs (#5635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the config fix to every stateful internal-API singleton. The internal API (config, container, recorder, event, store, output) is documented to work from tests, page objects and fragments — not only helpers — but test files are loaded through Mocha's synchronous require() (the CommonJS realm) under a CJS loader such as tsx/cjs, while the framework runs as native ESM. So importing any of these from a test loaded a second, disconnected CJS copy: config.get() returned {}, the container had no helpers, the recorder was never started, the event dispatcher had no listeners. Helpers were unaffected because they load via import() (the ESM realm). Add lib/realm.js with realmSingleton(key, factory), which stores each singleton on globalThis so every realm resolves to the one the runner operates on (the ESM runner loads these modules first during bootstrap, so it wins the key; later CJS copies reuse it). Apply it to recorder, container state, the event dispatcher, store and output, and refactor config.js to the same holder pattern. Under pure ESM the modules load once, so there is no behavior change. Broadens the regression fixture to assert config/container/recorder/event/store all resolve to the live singletons from a test imported via tsx/cjs. Co-Authored-By: Claude Opus 4.8 --- lib/config.js | 34 ++++++++------- lib/container.js | 10 ++++- lib/event.js | 12 ++++-- lib/output.js | 5 ++- lib/realm.js | 25 +++++++++++ lib/recorder.js | 5 ++- lib/store.js | 6 ++- test/data/config-tsx-cjs/config_test.ts | 18 -------- .../codecept.conf.ts | 4 +- .../internal_api_helper.js} | 4 +- .../internal-api-tsx-cjs/internal_api_test.ts | 29 +++++++++++++ .../package.json | 2 +- .../tsconfig.json | 0 test/runner/config_tsx_test.js | 33 --------------- test/runner/internal_api_tsx_test.js | 41 +++++++++++++++++++ 15 files changed, 144 insertions(+), 84 deletions(-) create mode 100644 lib/realm.js delete mode 100644 test/data/config-tsx-cjs/config_test.ts rename test/data/{config-tsx-cjs => internal-api-tsx-cjs}/codecept.conf.ts (71%) rename test/data/{config-tsx-cjs/config_helper.js => internal-api-tsx-cjs/internal_api_helper.js} (62%) create mode 100644 test/data/internal-api-tsx-cjs/internal_api_test.ts rename test/data/{config-tsx-cjs => internal-api-tsx-cjs}/package.json (73%) rename test/data/{config-tsx-cjs => internal-api-tsx-cjs}/tsconfig.json (100%) delete mode 100644 test/runner/config_tsx_test.js create mode 100644 test/runner/internal_api_tsx_test.js diff --git a/lib/config.js b/lib/config.js index 8b1db6ae1..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,20 +36,17 @@ const defaultConfig = { // Array<{ fn: (cfg) => void, ran: boolean, error?: Error }> let hooks = [] -let config = {} - -// Expose the live config object globally so a duplicate copy of the framework -// (loaded as CommonJS when a test, page object or fragment does -// `import { config } from 'codeceptjs'` through a CJS loader such as tsx/cjs) -// reads the same config the runner actually loaded. Test files are loaded via -// Mocha's synchronous require() (CJS realm) while the framework runs as ESM, so -// the CJS copy would otherwise see its own empty module-scoped `config = {}`. -// Helpers don't hit this because they're loaded via import() (the ESM realm). -// Mirrors the globalThis bridges in recorder.js and container.js. + +// 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) { - config = value - if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_config = config - return config + configHolder.value = value + return value } // Apply a single hook against `cfg`, swallowing errors so one broken hook @@ -86,8 +84,8 @@ class Config { // (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 } /** @@ -151,7 +149,7 @@ class Config { * @return {*} */ static get(key, val) { - const cfg = (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_config) || config + const cfg = configHolder.value if (key) { return cfg[key] || val } @@ -175,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 @@ -210,7 +208,7 @@ class Config { * @return {Object} */ static append(additionalConfig) { - return setConfig(deepMerge(config, additionalConfig)) + return setConfig(deepMerge(configHolder.value, additionalConfig)) } /** diff --git a/lib/container.js b/lib/container.js index 89e80f3d8..3000086ae 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 @@ -33,7 +34,12 @@ let beforeCalledSet = new Set() export function getBeforeCalledSet() { return beforeCalledSet } export function resetBeforeCalledSet() { beforeCalledSet = new Set() } -let container = { +// 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. +let container = realmSingleton('__codeceptjs_container_state', () => ({ helpers: {}, support: {}, proxySupport: {}, @@ -50,7 +56,7 @@ let container = { result: null, sharedKeys: new Set(), // Track keys shared via share() function tsFileMapping: null, // TypeScript file mapping for error stack fixing -} +})) /** * 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..0a982df51 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, @@ -21,7 +22,7 @@ let newline = true * @alias output * @namespace */ -const output = { +const output = realmSingleton('__codeceptjs_output', () => ({ colors, styles, print, @@ -302,7 +303,7 @@ const output = { msg += ' ' print(status + style(msg) + colors.grey(` // ${duration}`)) }, -} +})) export default output 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..f9e9cff5f 100644 --- a/lib/store.js +++ b/lib/store.js @@ -1,8 +1,10 @@ +import { realmSingleton } from './realm.js' + /** * Global store for current session * @namespace */ -const store = { +const store = realmSingleton('__codeceptjs_store', () => ({ // --- Required (set once via initialize(), immutable after) --- /** @type {string | null} */ @@ -110,6 +112,6 @@ const store = { this._codeceptDir = opts.codeceptDir this._outputDir = opts.outputDir }, -} +})) export default store diff --git a/test/data/config-tsx-cjs/config_test.ts b/test/data/config-tsx-cjs/config_test.ts deleted file mode 100644 index ca8f59233..000000000 --- a/test/data/config-tsx-cjs/config_test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 -// Importing the internal API (config) through a CommonJS loader (tsx/cjs) used to load -// a second, disconnected copy of config.js whose module-scoped `config` was an empty -// object, so `config.get()` returned {} from tests/page objects/fragments while helpers -// (loaded via import(), the ESM realm) saw the real config. The relative path is loaded -// as CJS here while the runner loads the same module as ESM, reproducing the split that -// the globalThis bridge fixes. -import ConfigModule from "../../../lib/config.js"; - -const Config = (ConfigModule as any).default || ConfigModule; - -Feature("config under tsx/cjs"); - -Scenario("config.get() reads the live config from a test", ({ I }) => { - console.log(`CONFIG_FROM_TEST name=${Config.get("name")}`); - console.log(`CONFIG_FROM_TEST marker=${Config.get().helpers.ConfigHelper.marker}`); - I.reportConfig(); -}); diff --git a/test/data/config-tsx-cjs/codecept.conf.ts b/test/data/internal-api-tsx-cjs/codecept.conf.ts similarity index 71% rename from test/data/config-tsx-cjs/codecept.conf.ts rename to test/data/internal-api-tsx-cjs/codecept.conf.ts index 4685ebafc..51e632ddd 100644 --- a/test/data/config-tsx-cjs/codecept.conf.ts +++ b/test/data/internal-api-tsx-cjs/codecept.conf.ts @@ -3,10 +3,10 @@ export const config: CodeceptJS.MainConfig = { output: "./output", helpers: { ConfigHelper: { - require: "./config_helper.js", + require: "./internal_api_helper.js", marker: "config-marker-123" } }, - name: "config-tsx-cjs-test", + name: "internal-api-tsx-cjs-test", require: ["tsx/cjs"] }; diff --git a/test/data/config-tsx-cjs/config_helper.js b/test/data/internal-api-tsx-cjs/internal_api_helper.js similarity index 62% rename from test/data/config-tsx-cjs/config_helper.js rename to test/data/internal-api-tsx-cjs/internal_api_helper.js index e65db98b5..d2ca76d91 100644 --- a/test/data/config-tsx-cjs/config_helper.js +++ b/test/data/internal-api-tsx-cjs/internal_api_helper.js @@ -6,8 +6,8 @@ const Config = ConfigModule.default || ConfigModule class ConfigHelper extends Helper { reportConfig() { - // Helper is loaded via import() (ESM realm), so it has always shared the live config. - console.log(`CONFIG_FROM_HELPER marker=${Config.get().helpers.ConfigHelper.marker}`) + // 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}`) } } 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/config-tsx-cjs/package.json b/test/data/internal-api-tsx-cjs/package.json similarity index 73% rename from test/data/config-tsx-cjs/package.json rename to test/data/internal-api-tsx-cjs/package.json index 00e4e9ae0..27330843b 100644 --- a/test/data/config-tsx-cjs/package.json +++ b/test/data/internal-api-tsx-cjs/package.json @@ -1,5 +1,5 @@ { - "name": "config-tsx-cjs", + "name": "internal-api-tsx-cjs", "version": "1.0.0", "type": "module", "devDependencies": { diff --git a/test/data/config-tsx-cjs/tsconfig.json b/test/data/internal-api-tsx-cjs/tsconfig.json similarity index 100% rename from test/data/config-tsx-cjs/tsconfig.json rename to test/data/internal-api-tsx-cjs/tsconfig.json diff --git a/test/runner/config_tsx_test.js b/test/runner/config_tsx_test.js deleted file mode 100644 index 82b5bdb4d..000000000 --- a/test/runner/config_tsx_test.js +++ /dev/null @@ -1,33 +0,0 @@ -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/config-tsx-cjs') -const codecept_run = `${runner} run --config ${codecept_dir}/codecept.conf.ts` - -// Regression test for https://github.com/codeceptjs/CodeceptJS/issues/5635 -// When the internal API (config) is imported through a CommonJS loader (tsx/cjs) a second, -// disconnected copy of config.js was loaded, so config.get() returned {} from tests/page -// objects while helpers (loaded via import()) saw the real config. -describe('CodeceptJS config under tsx/cjs', function () { - this.timeout(40000) - - it('config.get() reads the live config from a test imported via tsx/cjs', done => { - exec(`${codecept_run}`, { timeout: 30000 }, (err, stdout) => { - stdout.should.include('1 passed') - // the test (CJS realm) reads the real config the runner loaded, not an empty {} - stdout.should.include('CONFIG_FROM_TEST name=config-tsx-cjs-test') - stdout.should.include('CONFIG_FROM_TEST marker=config-marker-123') - // the helper (ESM realm) reads the same live config - stdout.should.include('CONFIG_FROM_HELPER marker=config-marker-123') - chai.expect(err).to.be.null - done() - }) - }) -}) diff --git a/test/runner/internal_api_tsx_test.js b/test/runner/internal_api_tsx_test.js new file mode 100644 index 000000000..3c44b32bc --- /dev/null +++ b/test/runner/internal_api_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/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) 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 and asserts each one is the live instance. +describe('CodeceptJS internal API under tsx/cjs', function () { + this.timeout(40000) + + it('resolves config/container/recorder/event/store to the live singletons from a test', done => { + exec(`${codecept_run}`, { timeout: 30000 }, (err, stdout) => { + stdout.should.include('1 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(err).to.be.null + done() + }) + }) +}) From 730e701124db2170be520339153d6b332ef06d0d Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Sun, 14 Jun 2026 09:37:39 +0200 Subject: [PATCH 3/4] fix(types): keep object-literal singletons visible to jsdoc for dtslint (#5635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit wrapped the output/store/container singletons by inlining their object literal into the realmSingleton() factory call. tsd-jsdoc (npm run def) couldn't associate the `@namespace`/`@type` JSDoc with a literal nested in an arrow argument, so the regenerated typings dropped the `output` and `store` namespaces and lost the Result reference — breaking dtslint in CI. Keep the plain object literal (so jsdoc still sees it) and share it at export time via realmSingleton(() => literal), the same shape recorder.js already uses. Runtime behavior is unchanged; `npm run def` now regenerates the committed typings unchanged and dtslint passes. Co-Authored-By: Claude Opus 4.8 --- lib/container.js | 16 +++++++++------- lib/output.js | 6 +++--- lib/store.js | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/container.js b/lib/container.js index 3000086ae..dcbc9d7ee 100644 --- a/lib/container.js +++ b/lib/container.js @@ -34,12 +34,7 @@ let beforeCalledSet = new Set() export function getBeforeCalledSet() { return beforeCalledSet } export function resetBeforeCalledSet() { beforeCalledSet = new Set() } -// 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. -let container = realmSingleton('__codeceptjs_container_state', () => ({ +let container = { helpers: {}, support: {}, proxySupport: {}, @@ -56,7 +51,14 @@ let container = realmSingleton('__codeceptjs_container_state', () => ({ result: null, sharedKeys: new Set(), // Track keys shared via share() function 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/output.js b/lib/output.js index 0a982df51..52739dba9 100644 --- a/lib/output.js +++ b/lib/output.js @@ -22,7 +22,7 @@ let newline = true * @alias output * @namespace */ -const output = realmSingleton('__codeceptjs_output', () => ({ +const output = { colors, styles, print, @@ -303,9 +303,9 @@ const output = realmSingleton('__codeceptjs_output', () => ({ msg += ' ' print(status + style(msg) + colors.grey(` // ${duration}`)) }, -})) +} -export default output +export default realmSingleton('__codeceptjs_output', () => output) function print(...msg) { if (outputProcess) { diff --git a/lib/store.js b/lib/store.js index f9e9cff5f..ed1fbcb0e 100644 --- a/lib/store.js +++ b/lib/store.js @@ -4,7 +4,7 @@ import { realmSingleton } from './realm.js' * Global store for current session * @namespace */ -const store = realmSingleton('__codeceptjs_store', () => ({ +const store = { // --- Required (set once via initialize(), immutable after) --- /** @type {string | null} */ @@ -112,6 +112,6 @@ const store = realmSingleton('__codeceptjs_store', () => ({ this._codeceptDir = opts.codeceptDir this._outputDir = opts.outputDir }, -})) +} -export default store +export default realmSingleton('__codeceptjs_store', () => store) From e0f5bb8f1454048a4674ccb07c1f92e4778b5f57 Mon Sep 17 00:00:00 2001 From: Jaromir Obr Date: Sun, 14 Jun 2026 09:50:28 +0200 Subject: [PATCH 4/4] test(effects): port effects-under-tsx/cjs coverage from #5634 (#5635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5635 fixes the dual-realm split at the source (shared recorder/container), which makes within/tryTo/hopeThat/retryTo work from a CJS-loaded test without the effects.js indirection PR #5634 added — i.e. #5635 supersedes #5634's mechanism. To avoid losing #5634's regression coverage, fold its effects assertions into this PR's fixture: extend the helper with the effects-exercising methods and add effects_test.ts, then assert the markers from the same single fixture run. retryTo is kept last and the child exec has a timeout guard so a future regression that re-hangs it fails cleanly instead of hanging. Co-Authored-By: Claude Opus 4.8 --- .../data/internal-api-tsx-cjs/effects_test.ts | 41 ++++++++++++ .../internal_api_helper.js | 33 ++++++++++ test/runner/internal_api_tsx_test.js | 65 +++++++++++++------ 3 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 test/data/internal-api-tsx-cjs/effects_test.ts 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 index d2ca76d91..949306e0b 100644 --- a/test/data/internal-api-tsx-cjs/internal_api_helper.js +++ b/test/data/internal-api-tsx-cjs/internal_api_helper.js @@ -5,10 +5,43 @@ 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/runner/internal_api_tsx_test.js b/test/runner/internal_api_tsx_test.js index 3c44b32bc..854004163 100644 --- a/test/runner/internal_api_tsx_test.js +++ b/test/runner/internal_api_tsx_test.js @@ -12,30 +12,55 @@ 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) 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 and asserts each one is the live instance. +// 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) - it('resolves config/container/recorder/event/store to the live singletons from a test', done => { - exec(`${codecept_run}`, { timeout: 30000 }, (err, stdout) => { - stdout.should.include('1 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(err).to.be.null + 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') + }) })