Skip to content

Commit 3cd9092

Browse files
DavertMikclaude
andcommitted
fix(core): load Mocha test files as ES Modules so internal-API imports resolve to the live instance
Test files were loaded with synchronous require(), which under tsx/cjs created a second CommonJS copy of lib/* modules. A test's `import { config } from "codeceptjs"` then resolved to a disconnected copy whose module-level singletons were never populated (#5636). Load test files through `await import()` instead — the same way the container already loads helpers — so the whole run shares one ES module graph and plain imports are honest. New lib/mocha/loadTests.js mirrors Mocha's loadFiles (lazyLoadFiles + per-file pre-require/require/post-require) to preserve teardown hooks, the gherkin .feature path, and the dup/missing-Feature validation. All synchronous mocha.loadFiles() sites converted: codecept.run(), rerun, workers (parent grouping + worker threads), dry-run, and check. TypeScript: tsx/cjs is a require hook and can no longer transpile test files loaded via import(). requireModules() auto-maps tsx/cjs -> tsx/esm with a deprecation notice; tsx/esm needs "type": "module" in package.json (init now writes it, and an ERR_REQUIRE_CYCLE_MODULE guard points users to it). BREAKING CHANGE: the programmatic Workers grouping API (createGroupsOfTests, createGroupsOfSuites, addTestFiles, splitTestsByGroups) is now async. TypeScript projects must set "type": "module" in package.json. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7cb5366 commit 3cd9092

19 files changed

Lines changed: 237 additions & 194 deletions

File tree

docs/configuration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,18 @@ For TypeScript test files in CodeceptJS 4.x, use the [`tsx`](https://tsx.is) loa
7171
// codecept.conf.ts
7272
export const config = {
7373
tests: './**/*_test.ts',
74-
require: ['tsx/cjs'],
74+
require: ['tsx/esm'],
7575
helpers: {},
7676
include: {},
7777
}
7878
```
7979

80+
This requires `"type": "module"` in `package.json` so `.ts` test files are compiled as ES Modules.
81+
8082
Combine several modules:
8183

8284
```ts
83-
require: ['tsx/cjs', 'should', './lib/testSetup']
85+
require: ['tsx/esm', 'should', './lib/testSetup']
8486
```
8587

8688
The config file itself (`codecept.conf.ts`) and helpers are transpiled automatically — only test files need the loader. See [TypeScript](/typescript) for the full setup.

docs/installation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ npm i tsx --save-dev
111111
// codecept.conf.ts
112112
export const config = {
113113
tests: './**/*_test.ts',
114-
require: ['tsx/cjs'], // loads the *_test.ts files
114+
require: ['tsx/esm'], // loads the *_test.ts files as ES Modules (needs "type": "module")
115115
helpers: {
116116
Playwright: { url: 'http://localhost', browser: 'chromium' },
117117
},

docs/parallel.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ import { Workers, event } from 'codeceptjs'
116116
const workers = new Workers(null, { testConfig: './codecept.conf.js' })
117117
118118
// split the suite into 2 groups, run each group on two browsers
119-
const groups = workers.createGroupsOfSuites(2)
119+
const groups = await workers.createGroupsOfSuites(2)
120120
for (const browser of ['chromium', 'firefox']) {
121121
for (const group of groups) {
122122
const worker = workers.spawn()
@@ -139,7 +139,7 @@ try {
139139
Building blocks:
140140

141141
- `new Workers(N, { testConfig, options })` — `N` workers; pass `null` to spawn them yourself with `spawn()`.
142-
- `createGroupsOfTests(n)` / `createGroupsOfSuites(n)` — split the suite into `n` groups.
142+
- `await createGroupsOfTests(n)` / `await createGroupsOfSuites(n)` — split the suite into `n` groups (async: test files are loaded as ES Modules).
143143
- `worker.addTests(group)` / `worker.addConfig(partialConfig)` — assign tests and config overrides to a spawned worker.
144144
- `bootstrapAll()` → `run()` → `teardownAll()` — lifecycle (wrap `run()` in `try/finally` so teardown always runs).
145145
- Events on the `workers` object: `event.test.passed`, `event.test.failed`, `event.all.result`, plus `'message'` for anything a child worker sends. `printResults()` prints the standard summary; `result.hasFailed()` and `result.stats` give the totals.

docs/typescript.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ CodeceptJS ships [type declarations](https://github.com/codeceptjs/CodeceptJS/tr
1515
? Do you plan to write tests in TypeScript? Yes
1616
```
1717

18-
It writes `codecept.conf.ts` and `*_test.ts` files. The **config file** and helpers are transpiled automatically. **Test files** need a loader — CodeceptJS 4.x is ESM, and Mocha loads test files through CommonJS hooks, so use [`tsx`](https://tsx.is) (fast, esbuild-based, no `tsconfig.json` required):
18+
It writes `codecept.conf.ts` and `*_test.ts` files. The **config file** and helpers are transpiled automatically. **Test files** need a loader — CodeceptJS 4.x is ESM and loads test files as ES Modules, so use [`tsx`](https://tsx.is) (fast, esbuild-based, no `tsconfig.json` required):
1919

2020
```sh
2121
npm i tsx --save-dev
@@ -25,16 +25,16 @@ npm i tsx --save-dev
2525
// codecept.conf.ts
2626
export const config = {
2727
tests: './**/*_test.ts',
28-
require: ['tsx/cjs'], // loads the *_test.ts files
28+
require: ['tsx/esm'], // loads the *_test.ts files as ES Modules
2929
helpers: {
3030
Playwright: { url: 'http://localhost', browser: 'chromium' },
3131
},
3232
}
3333
```
3434

35-
Run the tests with `npx codeceptjs run`.
35+
Set `"type": "module"` in `package.json` so `tsx` compiles your `.ts` test files as ES Modules. Then run the tests with `npx codeceptjs run`.
3636

37-
> Adding TypeScript to an existing project: set `"type": "module"` in `package.json`, rename the config to `codecept.conf.ts` with `export const config = {}`, install `tsx`, and add `require: ['tsx/cjs']`.
37+
> Adding TypeScript to an existing project: set `"type": "module"` in `package.json`, rename the config to `codecept.conf.ts` with `export const config = {}`, install `tsx`, and add `require: ['tsx/esm']`.
3838
3939
## Writing tests
4040

@@ -59,7 +59,9 @@ Scenario('admin signs in', ({ I }) => {
5959
})
6060
```
6161

62-
> **Cannot find module** or **Unexpected token** while running tests means the loader isn't wired up — check that `tsx` is installed and `require: ['tsx/cjs']` is in the config.
62+
> **Cannot find module** or **Unexpected token** while running tests means the loader isn't wired up — check that `tsx` is installed and `require: ['tsx/esm']` is in the config.
63+
>
64+
> **`ERR_REQUIRE_CYCLE_MODULE`** means `tsx` is compiling your `.ts` tests as CommonJS — add `"type": "module"` to the nearest `package.json`.
6365
6466
## Promise-based typings
6567

lib/codecept.js

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import ActorFactory from './actor.js'
1919
import output from './output.js'
2020
import { emptyFolder, resolveImportModulePath } from './utils.js'
2121
import { initCodeceptGlobals } from './globals.js'
22+
import loadTests from './mocha/loadTests.js'
2223
import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
2324
import recorder from './recorder.js'
2425
import store from './store.js'
@@ -58,7 +59,11 @@ class Codecept {
5859
*/
5960
async requireModules(requiringModules) {
6061
if (requiringModules) {
61-
for (const requiredModule of requiringModules) {
62+
for (let requiredModule of requiringModules) {
63+
if (requiredModule === 'tsx/cjs') {
64+
output.print(output.styles.debug('`tsx/cjs` is deprecated for test files. Using `tsx/esm` instead. Update your config `require` to `tsx/esm` and add `"type": "module"` to package.json.'))
65+
requiredModule = 'tsx/esm'
66+
}
6267
let modulePath = requiredModule
6368
const isLocalFile = existsSync(modulePath) || existsSync(`${modulePath}.js`)
6469
if (isLocalFile) {
@@ -295,21 +300,23 @@ class Codecept {
295300
this.testFiles.sort()
296301
}
297302

298-
return new Promise((resolve, reject) => {
299-
const mocha = container.mocha()
300-
mocha.files = this.testFiles
303+
const mocha = container.mocha()
304+
mocha.files = this.testFiles
301305

302-
if (test) {
303-
if (!fsPath.isAbsolute(test)) {
304-
test = fsPath.join(store.codeceptDir, test)
305-
}
306-
const testBasename = fsPath.basename(test, '.js')
307-
const testFeatureBasename = fsPath.basename(test, '.feature')
308-
mocha.files = mocha.files.filter(t => {
309-
return fsPath.basename(t, '.js') === testBasename || fsPath.basename(t, '.feature') === testFeatureBasename || t === test
310-
})
306+
if (test) {
307+
if (!fsPath.isAbsolute(test)) {
308+
test = fsPath.join(store.codeceptDir, test)
311309
}
310+
const testBasename = fsPath.basename(test, '.js')
311+
const testFeatureBasename = fsPath.basename(test, '.feature')
312+
mocha.files = mocha.files.filter(t => {
313+
return fsPath.basename(t, '.js') === testBasename || fsPath.basename(t, '.feature') === testFeatureBasename || t === test
314+
})
315+
}
316+
317+
await loadTests(mocha)
312318

319+
return new Promise((resolve, reject) => {
313320
const done = async (failures) => {
314321
event.emit(event.all.result, container.result())
315322
event.emit(event.all.after, this)

lib/command/check.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Container from '../container.js'
66
import figures from 'figures'
77
import chalk from 'chalk'
88
import { createTest } from '../mocha/test.js'
9+
import loadTests from '../mocha/loadTests.js'
910
import { getMachineInfo } from './info.js'
1011
import definitions from './definitions.js'
1112

@@ -73,7 +74,7 @@ export default async function (options) {
7374
const files = codecept.testFiles
7475
const mocha = Container.mocha()
7576
mocha.files = files
76-
mocha.loadFiles()
77+
await loadTests(mocha)
7778

7879
for (const suite of mocha.suite.suites) {
7980
if (suite && suite.tests) {

lib/command/dryRun.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import output from '../output.js'
66
import event from '../event.js'
77
import store from '../store.js'
88
import Container from '../container.js'
9+
import loadTests from '../mocha/loadTests.js'
910

1011
export default async function (test, options) {
1112
if (options.grep) process.env.grep = options.grep
@@ -74,7 +75,7 @@ async function printTests(files) {
7475

7576
const mocha = Container.mocha()
7677
mocha.files = files
77-
mocha.loadFiles()
78+
await loadTests(mocha)
7879

7980
let numOfTests = 0
8081
let numOfSuites = 0

lib/command/init.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export default async function (initPath, options = {}) {
165165
config.tests = result.tests
166166
if (isTypeScript) {
167167
config.tests = `${config.tests.replace(/\.js$/, `.${extension}`)}`
168-
config.require = ['tsx/cjs']
168+
config.require = ['tsx/esm']
169169
}
170170

171171
const matchResults = config.tests.match(/[^*.]+/)
@@ -260,6 +260,18 @@ export default async function (initPath, options = {}) {
260260
}
261261

262262
if (isTypeScript) {
263+
try {
264+
const pkgPath = path.join(process.cwd(), 'package.json')
265+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
266+
if (pkg.type !== 'module') {
267+
pkg.type = 'module'
268+
fs.writeFileSync(pkgPath, beautify(JSON.stringify(pkg)))
269+
print('Set "type": "module" in package.json so TypeScript tests load as ES Modules')
270+
}
271+
} catch (err) {
272+
print(colors.bold.yellow('Could not set "type": "module" in package.json. Add it manually so TypeScript tests load as ES Modules.'))
273+
}
274+
263275
const tsconfigJson = beautify(JSON.stringify(tsconfig))
264276
const tsconfigFile = path.join(testsPath, 'tsconfig.json')
265277
if (fileExists(tsconfigFile)) {

lib/command/workers/runTests.js

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { parentPort, workerData } from 'worker_threads'
1111

1212
// Delay imports to avoid ES Module loader race conditions in Node 22.x worker threads
1313
// These will be imported dynamically when needed
14-
let event, container, Codecept, getConfig, tryOrDefault, deepMerge, fixErrorStack
14+
let event, container, Codecept, getConfig, tryOrDefault, deepMerge, fixErrorStack, loadTests
1515

1616
let stdout = ''
1717

@@ -143,6 +143,7 @@ initPromise = (async function () {
143143
const coreUtilsModule = await import('../../utils.js')
144144
const CodeceptModule = await import('../../codecept.js')
145145
const typescriptModule = await import('../../utils/typescript.js')
146+
const loadTestsModule = await import('../../mocha/loadTests.js')
146147

147148
event = eventModule.default
148149
container = containerModule.default
@@ -151,6 +152,7 @@ initPromise = (async function () {
151152
deepMerge = coreUtilsModule.deepMerge
152153
Codecept = CodeceptModule.default
153154
fixErrorStack = typescriptModule.fixErrorStack
155+
loadTests = loadTestsModule.default
154156

155157
const overrideConfigs = tryOrDefault(() => JSON.parse(options.override), {})
156158

@@ -200,7 +202,7 @@ initPromise = (async function () {
200202
// We'll reload test files fresh for each test request
201203
} else {
202204
// Legacy mode - filter tests upfront
203-
filterTests()
205+
await filterTests()
204206
}
205207

206208
// run tests
@@ -290,20 +292,13 @@ async function runPoolTests() {
290292

291293
// Load only the assigned test file
292294
mocha.files = [testIdentifier]
293-
mocha.loadFiles()
294-
295-
try {
296-
require('fs').appendFileSync('/tmp/config_listener_debug.log', `${new Date().toISOString()} [POOL] Loaded ${testIdentifier}, tests: ${mocha.suite.total()}\n`)
297-
} catch (e) { /* ignore */ }
295+
await loadTests(mocha)
298296

299297
if (mocha.suite.total() > 0) {
300298
// Run only the tests in the current mocha suite
301299
// Don't use codecept.run() as it overwrites mocha.files with ALL test files
302300
await new Promise((resolve, reject) => {
303301
mocha.run(() => {
304-
try {
305-
require('fs').appendFileSync('/tmp/config_listener_debug.log', `${new Date().toISOString()} [POOL] Finished ${testIdentifier}\n`)
306-
} catch (e) { /* ignore */ }
307302
resolve()
308303
})
309304
})
@@ -429,10 +424,10 @@ function filterTestById(testUid) {
429424
}
430425
}
431426

432-
function filterTests() {
427+
async function filterTests() {
433428
const files = codecept.testFiles
434429
mocha.files = files
435-
mocha.loadFiles()
430+
await loadTests(mocha)
436431

437432
// Recursively filter tests in all suites (including nested ones)
438433
const filterSuiteTests = (suite) => {

lib/mocha/factory.js

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import Mocha from 'mocha'
22
import fsPath from 'path'
3-
import fs from 'fs'
43
import { fileURLToPath } from 'url'
54
import reporter from './cli.js'
6-
import gherkinParser, { loadTranslations } from './gherkin.js'
75
import output from '../output.js'
86
import scenarioUiFunction from './ui.js'
97
import { initMochaGlobals } from '../globals.js'
@@ -52,64 +50,6 @@ class MochaFactory {
5250
process.exit(1)
5351
}
5452

55-
// Override loadFiles to handle feature files
56-
const originalLoadFiles = Mocha.prototype.loadFiles
57-
mocha.loadFiles = function (fn) {
58-
// load features
59-
const featureFiles = this.files.filter(file => file.match(/\.feature$/))
60-
if (featureFiles.length > 0) {
61-
// Load translations for Gherkin features
62-
loadTranslations().catch(() => {
63-
// Ignore if translations can't be loaded
64-
})
65-
66-
for (const file of featureFiles) {
67-
const suite = gherkinParser(fs.readFileSync(file, 'utf8'), file)
68-
this.suite.addSuite(suite)
69-
}
70-
71-
// remove feature files
72-
const jsFiles = this.files.filter(file => !file.match(/\.feature$/))
73-
this.files = this.files.filter(file => !file.match(/\.feature$/))
74-
75-
// Load JavaScript test files using original loadFiles
76-
if (jsFiles.length > 0) {
77-
originalLoadFiles.call(this, fn)
78-
}
79-
80-
// add ids for each test and check uniqueness
81-
const dupes = []
82-
let missingFeatureInFile = []
83-
const seenTests = []
84-
this.suite.eachTest(test => {
85-
if (!test) {
86-
return // Skip undefined tests
87-
}
88-
const name = test.fullTitle()
89-
if (seenTests.includes(test.uid)) {
90-
dupes.push(name)
91-
}
92-
seenTests.push(test.uid)
93-
94-
if (name.slice(0, name.indexOf(':')) === '') {
95-
missingFeatureInFile.push(test.file)
96-
}
97-
})
98-
if (dupes.length) {
99-
// ideally this should be no-op and throw (breaking change)...
100-
output.error(`Duplicate test names detected - Feature + Scenario name should be unique:\n${dupes.join('\n')}`)
101-
}
102-
103-
if (missingFeatureInFile.length) {
104-
missingFeatureInFile = [...new Set(missingFeatureInFile)]
105-
output.error(`Missing Feature section in:\n${missingFeatureInFile.join('\n')}`)
106-
}
107-
} else {
108-
// Use original for non-feature files
109-
originalLoadFiles.call(this, fn)
110-
}
111-
}
112-
11353
const presetReporter = opts.reporter || config.reporter
11454
// use standard reporter
11555
if (!presetReporter) {

0 commit comments

Comments
 (0)