Skip to content

Commit e02f5fa

Browse files
miraoclaude
andcommitted
fix(core): make the whole internal API realm-agnostic under tsx/cjs (#5635)
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 <noreply@anthropic.com>
1 parent f2d832c commit e02f5fa

15 files changed

Lines changed: 144 additions & 84 deletions

lib/config.js

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'path'
33
import { createRequire } from 'module'
44
import { fileExists, isFile, deepMerge, deepClone, resolveImportModulePath } from './utils.js'
55
import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js'
6+
import { realmSingleton } from './realm.js'
67

78
const defaultConfig = {
89
output: './_output',
@@ -35,20 +36,17 @@ const defaultConfig = {
3536

3637
// Array<{ fn: (cfg) => void, ran: boolean, error?: Error }>
3738
let hooks = []
38-
let config = {}
39-
40-
// Expose the live config object globally so a duplicate copy of the framework
41-
// (loaded as CommonJS when a test, page object or fragment does
42-
// `import { config } from 'codeceptjs'` through a CJS loader such as tsx/cjs)
43-
// reads the same config the runner actually loaded. Test files are loaded via
44-
// Mocha's synchronous require() (CJS realm) while the framework runs as ESM, so
45-
// the CJS copy would otherwise see its own empty module-scoped `config = {}`.
46-
// Helpers don't hit this because they're loaded via import() (the ESM realm).
47-
// Mirrors the globalThis bridges in recorder.js and container.js.
39+
40+
// Shared across realms (see realm.js). config is reassigned wholesale by create/append/
41+
// reset, so the live value lives in a stable holder object that every realm resolves to.
42+
// A test, page object or fragment that does `import { config } from "codeceptjs"` is loaded
43+
// as CommonJS (Mocha's require()) while the runner is ESM; without the shared holder the
44+
// CJS copy reads its own empty `{}`. Helpers don't hit this — they load via import() (ESM).
45+
const configHolder = realmSingleton('__codeceptjs_config', () => ({ value: {} }))
46+
4847
function setConfig(value) {
49-
config = value
50-
if (typeof globalThis !== 'undefined') globalThis.__codeceptjs_config = config
51-
return config
48+
configHolder.value = value
49+
return value
5250
}
5351

5452
// Apply a single hook against `cfg`, swallowing errors so one broken hook
@@ -86,8 +84,8 @@ class Config {
8684
// (e.g. from plugin boot) stay pending until runPendingHooks. Array
8785
// iterators re-check length on each step, so hooks pushed during a hook
8886
// execution are visited in this same pass.
89-
for (const hook of hooks) applyHook(hook, config)
90-
return config
87+
for (const hook of hooks) applyHook(hook, configHolder.value)
88+
return configHolder.value
9189
}
9290

9391
/**
@@ -151,7 +149,7 @@ class Config {
151149
* @return {*}
152150
*/
153151
static get(key, val) {
154-
const cfg = (typeof globalThis !== 'undefined' && globalThis.__codeceptjs_config) || config
152+
const cfg = configHolder.value
155153
if (key) {
156154
return cfg[key] || val
157155
}
@@ -175,7 +173,7 @@ class Config {
175173
* @param {Object<string, *>} [cfg] target config (defaults to the live singleton)
176174
* @return {boolean} true if any hook ran
177175
*/
178-
static runPendingHooks(cfg = config) {
176+
static runPendingHooks(cfg = configHolder.value) {
179177
let ran = false
180178
for (const hook of hooks) {
181179
if (hook.ran) continue
@@ -210,7 +208,7 @@ class Config {
210208
* @return {Object<string, *>}
211209
*/
212210
static append(additionalConfig) {
213-
return setConfig(deepMerge(config, additionalConfig))
211+
return setConfig(deepMerge(configHolder.value, additionalConfig))
214212
}
215213

216214
/**

lib/container.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import Result from './result.js'
2525
import ai from './ai.js'
2626
import actorFactory from './actor.js'
2727
import Config from './config.js'
28+
import { realmSingleton } from './realm.js'
2829

2930
let asyncHelperPromise
3031

@@ -33,7 +34,12 @@ let beforeCalledSet = new Set()
3334
export function getBeforeCalledSet() { return beforeCalledSet }
3435
export function resetBeforeCalledSet() { beforeCalledSet = new Set() }
3536

36-
let container = {
37+
// Shared across realms (see realm.js): the runner (ESM) populates this state in
38+
// Container.create(); a test/page object that does `import { container } from "codeceptjs"`
39+
// is loaded as CommonJS and would otherwise read an empty, never-populated copy. Pointing
40+
// the module variable at the shared object means the static accessors in every realm
41+
// operate on the live helpers/support/plugins.
42+
let container = realmSingleton('__codeceptjs_container_state', () => ({
3743
helpers: {},
3844
support: {},
3945
proxySupport: {},
@@ -50,7 +56,7 @@ let container = {
5056
result: null,
5157
sharedKeys: new Set(), // Track keys shared via share() function
5258
tsFileMapping: null, // TypeScript file mapping for error stack fixing
53-
}
59+
}))
5460

5561
/**
5662
* Dependency Injection Container

lib/event.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import debugModule from 'debug'
22
const debug = debugModule('codeceptjs:event')
33
import events from 'events'
44
import output from './output.js'
5+
import { realmSingleton } from './realm.js'
56

67
const MAX_LISTENERS = 200
78

8-
const dispatcher = new events.EventEmitter()
9-
10-
dispatcher.setMaxListeners(MAX_LISTENERS)
9+
// Shared across realms so listeners registered from a test (CJS realm, via
10+
// `import { event } from "codeceptjs"`) and events emitted by the runner (ESM realm)
11+
// reach the same EventEmitter. Without this the test subscribes to a dead dispatcher.
12+
const dispatcher = realmSingleton('__codeceptjs_dispatcher', () => {
13+
const d = new events.EventEmitter()
14+
d.setMaxListeners(MAX_LISTENERS)
15+
return d
16+
})
1117

1218
// Increase process max listeners to prevent warnings for beforeExit and other events
1319
if (typeof process.setMaxListeners === 'function') {

lib/output.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import colors from 'chalk'
22
import figures from 'figures'
33
import { maskData, shouldMaskData, getMaskConfig } from './utils/mask_data.js'
4+
import { realmSingleton } from './realm.js'
45

56
const styles = {
67
error: colors.bgRed.white.bold,
@@ -21,7 +22,7 @@ let newline = true
2122
* @alias output
2223
* @namespace
2324
*/
24-
const output = {
25+
const output = realmSingleton('__codeceptjs_output', () => ({
2526
colors,
2627
styles,
2728
print,
@@ -302,7 +303,7 @@ const output = {
302303
msg += ' '
303304
print(status + style(msg) + colors.grey(` // ${duration}`))
304305
},
305-
}
306+
}))
306307

307308
export default output
308309

lib/realm.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Cross-realm singleton sharing.
3+
*
4+
* CodeceptJS 4.x runs as native ESM, but test files, page objects and fragments are
5+
* loaded through Mocha's synchronous require() (the CommonJS realm) under a CJS loader
6+
* such as tsx/cjs (the setup the official Quickstart generates). So when user code does
7+
* `import { recorder } from "codeceptjs"`, Node materializes a SECOND, disconnected CJS
8+
* copy of the lib modules and their singletons — a copy the runner never populates, so
9+
* the internal API silently reads empty/never-started state. Helpers don't hit this
10+
* because they're loaded via import() (the ESM realm) and share the live singletons.
11+
*
12+
* Resolving the instance from globalThis (a single object shared across realms) makes
13+
* every realm use the very object the runner operates on. The runner (ESM) always loads
14+
* these modules first during bootstrap, so it wins the `key`; later CJS copies reuse it.
15+
* Under pure ESM the module loads once, so there is no behavior change.
16+
*
17+
* @template T
18+
* @param {string} key
19+
* @param {() => T} factory
20+
* @returns {T}
21+
*/
22+
export function realmSingleton(key, factory) {
23+
if (globalThis[key] === undefined) globalThis[key] = factory()
24+
return globalThis[key]
25+
}

lib/recorder.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import chalk from 'chalk'
55
import { printObjectProperties } from './utils.js'
66
import output from './output.js'
77
import { TimeoutError } from './timeout.js'
8+
import { realmSingleton } from './realm.js'
89
const MAX_TASKS = 100
910

1011
let promise
@@ -30,7 +31,7 @@ const defaultRetryOptions = {
3031
* @alias recorder
3132
* @interface
3233
*/
33-
export default {
34+
const recorder = {
3435
/**
3536
* @type {Array<Object<string, *>>}
3637
* @inner
@@ -425,6 +426,8 @@ export default {
425426
},
426427
}
427428

429+
export default realmSingleton('__codeceptjs_recorder', () => recorder)
430+
428431
function getTimeoutPromise(timeoutMs, taskName) {
429432
let timer
430433
if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`)

lib/store.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { realmSingleton } from './realm.js'
2+
13
/**
24
* Global store for current session
35
* @namespace
46
*/
5-
const store = {
7+
const store = realmSingleton('__codeceptjs_store', () => ({
68
// --- Required (set once via initialize(), immutable after) ---
79

810
/** @type {string | null} */
@@ -110,6 +112,6 @@ const store = {
110112
this._codeceptDir = opts.codeceptDir
111113
this._outputDir = opts.outputDir
112114
},
113-
}
115+
}))
114116

115117
export default store

test/data/config-tsx-cjs/config_test.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

test/data/config-tsx-cjs/codecept.conf.ts renamed to test/data/internal-api-tsx-cjs/codecept.conf.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ export const config: CodeceptJS.MainConfig = {
33
output: "./output",
44
helpers: {
55
ConfigHelper: {
6-
require: "./config_helper.js",
6+
require: "./internal_api_helper.js",
77
marker: "config-marker-123"
88
}
99
},
10-
name: "config-tsx-cjs-test",
10+
name: "internal-api-tsx-cjs-test",
1111
require: ["tsx/cjs"]
1212
};

test/data/config-tsx-cjs/config_helper.js renamed to test/data/internal-api-tsx-cjs/internal_api_helper.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ const Config = ConfigModule.default || ConfigModule
66

77
class ConfigHelper extends Helper {
88
reportConfig() {
9-
// Helper is loaded via import() (ESM realm), so it has always shared the live config.
10-
console.log(`CONFIG_FROM_HELPER marker=${Config.get().helpers.ConfigHelper.marker}`)
9+
// Helper is loaded via import() (the ESM realm), so it has always shared the live config.
10+
console.log(`API_HELPER marker=${Config.get().helpers.ConfigHelper.marker}`)
1111
}
1212
}
1313

0 commit comments

Comments
 (0)