diff --git a/docs/internal-api.md b/docs/internal-api.md index aa0846036..833cc0b5b 100644 --- a/docs/internal-api.md +++ b/docs/internal-api.md @@ -225,42 +225,167 @@ Step events provide step objects with following fields: Whenever you execute tests with `--verbose` option you will see registered events and promises executed by a recorder. -## Custom Runner +## Programmatic API -You can run CodeceptJS tests from your script. +CodeceptJS can be imported and used programmatically from your scripts. The main entry point is the `Codecept` class, which provides methods to list and execute tests. + +### Setup ```js -const { codecept: Codecept } = require('codeceptjs'); - -// define main config -const config = { - helpers: { - WebDriver: { - browser: 'chrome', - url: 'http://localhost' - } - } +import { Codecept, container } from 'codeceptjs'; + +const config = { + helpers: { + Playwright: { browser: 'chromium', url: 'http://localhost' } + }, + tests: './*_test.js', }; -const opts = { steps: true }; - -// run CodeceptJS inside async function -(async () => { - const codecept = new Codecept(config, options); - codecept.init(__dirname); - - try { - await codecept.bootstrap(); - codecept.loadTests('**_test.js'); - // run tests - await codecept.run(test); - } catch (err) { - printError(err); - process.exitCode = 1; - } finally { - await codecept.teardown(); - } -})(); +const codecept = new Codecept(config, { steps: true }); +await codecept.init(__dirname); +``` + +### Listing Tests + +Use `getSuites()` to get all parsed suites with their tests without executing them: + +```js +const suites = codecept.getSuites(); + +for (const suite of suites) { + console.log(suite.title, suite.tags); + for (const test of suite.tests) { + console.log(' -', test.title, test.tags); + } +} +``` + +`getSuites()` accepts an optional glob pattern. If `loadTests()` hasn't been called yet, it will be called internally. + +Each suite contains: + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Feature/suite title | +| `file` | `string` | Absolute path to the test file | +| `tags` | `string[]` | Tags (e.g. `@smoke`) | +| `tests` | `Array` | Tests in this suite | + +Each test contains: + +| Property | Type | Description | +|----------|------|-------------| +| `title` | `string` | Scenario title | +| `uid` | `string` | Unique test identifier | +| `tags` | `string[]` | Tags from scenario and suite | +| `fullTitle` | `string` | `"Suite: Test"` format | + +### Executing Suites + +Use `runSuite()` to run all tests within a suite: + +```js +await codecept.bootstrap(); + +const suites = codecept.getSuites(); +for (const suite of suites) { + await codecept.runSuite(suite); +} + +const result = container.result(); +console.log(result.stats); +console.log(`Passed: ${result.passedTests.length}`); +console.log(`Failed: ${result.failedTests.length}`); + +await codecept.teardown(); ``` -> Also, you can run tests inside workers in a custom scripts. Please refer to the [parallel execution](/parallel) guide for more details. +### Executing Individual Tests + +Use `runTest()` to run a single test: + +```js +await codecept.bootstrap(); + +const suites = codecept.getSuites(); +for (const test of suites[0].tests) { + await codecept.runTest(test); +} + +const result = container.result(); +await codecept.teardown(); +``` + +### Result Object + +The `Result` object returned by `container.result()` provides: + +| Property | Type | Description | +|----------|------|-------------| +| `stats` | `object` | `{ passes, failures, tests, pending, failedHooks, duration }` | +| `tests` | `Test[]` | All collected tests | +| `passedTests` | `Test[]` | Tests that passed | +| `failedTests` | `Test[]` | Tests that failed | +| `skippedTests` | `Test[]` | Tests that were skipped | +| `hasFailed` | `boolean` | Whether any test failed | +| `duration` | `number` | Total duration in milliseconds | + +### Full Lifecycle (Low-Level) + +For full control, you can orchestrate the lifecycle manually: + +```js +const codecept = new Codecept(config, opts); +await codecept.init(__dirname); + +try { + await codecept.bootstrap(); + codecept.loadTests('**_test.js'); + await codecept.run(); +} catch (err) { + console.error(err); + process.exitCode = 1; +} finally { + await codecept.teardown(); +} +``` + +### Runner + +All Mocha interactions are handled by the `Runner` class (`lib/runner.js`). After calling `init()`, it's available as `codecept.runner`: + +```js +const runner = codecept.runner; + +// Same as codecept.getSuites() / runSuite() / runTest() +const suites = runner.getSuites(); +await runner.runSuite(suites[0]); +await runner.runTest(suites[0].tests[0]); +``` + +The `Codecept` methods (`getSuites`, `runSuite`, `runTest`, `run`) delegate to the Runner. + +### Codecept Methods Reference + +| Method | Description | +|--------|-------------| +| `new Codecept(config, opts)` | Create codecept instance | +| `await init(dir)` | Initialize globals, container, helpers, plugins, runner | +| `loadTests(pattern?)` | Find test files by glob pattern | +| `getSuites(pattern?)` | Load and return parsed suites with tests | +| `await bootstrap()` | Execute bootstrap hook | +| `await run(test?)` | Run all loaded tests (or filter by file path) | +| `await runSuite(suite)` | Run a specific suite from `getSuites()` | +| `await runTest(test)` | Run a specific test from `getSuites()` | +| `await teardown()` | Execute teardown hook | + +### Runner Methods Reference + +| Method | Description | +|--------|-------------| +| `getSuites(pattern?)` | Parse suites using a temporary Mocha instance | +| `run(test?)` | Run tests, optionally filtering by file path | +| `runSuite(suite)` | Run all tests in a suite | +| `runTest(test)` | Run a single test by fullTitle | + +> Also, you can run tests inside workers in a custom script. Please refer to the [parallel execution](/parallel) guide for more details. diff --git a/lib/codecept.js b/lib/codecept.js index e01fe8345..dfff4cee4 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -11,16 +11,13 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) import Helper from '@codeceptjs/helper' +import Runner from './runner.js' import container from './container.js' import Config from './config.js' -import event from './event.js' import runHook from './hooks.js' import ActorFactory from './actor.js' -import output from './output.js' import { emptyFolder } from './utils.js' import { initCodeceptGlobals } from './globals.js' -import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js' -import recorder from './recorder.js' import store from './store.js' import storeListener from './listener/store.js' @@ -103,6 +100,7 @@ class Codecept { await this.requireModules(this.requiringModules) // initializing listeners await container.create(this.config, this.opts) + this.runner = new Runner(this) await this.runHooks() } @@ -256,6 +254,40 @@ class Codecept { return testFiles.slice(startIndex, endIndex) } + /** + * Returns parsed suites with their tests. + * Creates a temporary Mocha instance to avoid polluting container state. + * Must be called after init(). Calls loadTests() internally if testFiles is empty. + * + * @param {string} [pattern] - glob pattern for test files + * @returns {Array<{title: string, file: string, tags: string[], tests: Array<{title: string, uid: string, tags: string[], fullTitle: string}>}>} + */ + getSuites(pattern) { + return this.runner.getSuites(pattern) + } + + /** + * Run all tests in a suite. + * Must be called after init() and bootstrap(). + * + * @param {{file: string}} suite - suite object returned by getSuites() + * @returns {Promise} + */ + async runSuite(suite) { + return this.runner.runSuite(suite) + } + + /** + * Run a single test by its fullTitle. + * Must be called after init() and bootstrap(). + * + * @param {{fullTitle: string}} test - test object returned by getSuites() + * @returns {Promise} + */ + async runTest(test) { + return this.runner.runTest(test) + } + /** * Run a specific test or all loaded tests. * @@ -263,64 +295,7 @@ class Codecept { * @returns {Promise} */ async run(test) { - await container.started() - - // Check TypeScript loader configuration before running tests - const tsValidation = validateTypeScriptSetup(this.testFiles, this.requiringModules || []) - if (tsValidation.hasError) { - output.error(tsValidation.message) - process.exit(1) - } - - // Show warning if ts-node/esm is being used - const tsWarning = getTSNodeESMWarning(this.requiringModules || []) - if (tsWarning) { - output.print(output.colors.yellow(tsWarning)) - } - - // Ensure translations are loaded for Gherkin features - try { - const { loadTranslations } = await import('./mocha/gherkin.js') - await loadTranslations() - } catch (e) { - // Ignore if gherkin module not available - } - - return new Promise((resolve, reject) => { - const mocha = container.mocha() - mocha.files = this.testFiles - - if (test) { - if (!fsPath.isAbsolute(test)) { - test = fsPath.join(store.codeceptDir, test) - } - const testBasename = fsPath.basename(test, '.js') - const testFeatureBasename = fsPath.basename(test, '.feature') - mocha.files = mocha.files.filter(t => { - return fsPath.basename(t, '.js') === testBasename || fsPath.basename(t, '.feature') === testFeatureBasename || t === test - }) - } - - const done = async (failures) => { - event.emit(event.all.result, container.result()) - event.emit(event.all.after, this) - // Wait for any recorder tasks added by event.all.after handlers - await recorder.promise() - // Set exit code based on test failures - if (failures) { - process.exitCode = 1 - } - resolve() - } - - try { - event.emit(event.all.before, this) - mocha.run(async (failures) => await done(failures)) - } catch (e) { - output.error(e.stack) - reject(e) - } - }) + return this.runner.run(test) } /** diff --git a/lib/command/check.js b/lib/command/check.js index c07a1f5f3..d4ceb63a8 100644 --- a/lib/command/check.js +++ b/lib/command/check.js @@ -70,15 +70,10 @@ export default async function (options) { if (codecept) { try { codecept.loadTests() - const files = codecept.testFiles - const mocha = Container.mocha() - mocha.files = files - mocha.loadFiles() - - for (const suite of mocha.suite.suites) { - if (suite && suite.tests) { - numTests += suite.tests.length - } + const suites = codecept.getSuites() + + for (const suite of suites) { + numTests += suite.tests.length } if (numTests > 0) { diff --git a/lib/command/dryRun.js b/lib/command/dryRun.js index 1d46caf85..af1ed1976 100644 --- a/lib/command/dryRun.js +++ b/lib/command/dryRun.js @@ -4,7 +4,6 @@ import Codecept from '../codecept.js' import output from '../output.js' import event from '../event.js' import store from '../store.js' -import Container from '../container.js' export default async function (test, options) { if (options.grep) process.env.grep = options.grep @@ -35,7 +34,7 @@ export default async function (test, options) { store.dryRun = true if (!options.steps && !options.verbose && !options.debug) { - await printTests(codecept.testFiles) + await printTests(codecept) return } event.dispatcher.on(event.all.result, printFooter) @@ -46,16 +45,14 @@ export default async function (test, options) { } } -async function printTests(files) { +async function printTests(codecept) { const { default: figures } = await import('figures') const { default: colors } = await import('chalk') output.print(output.styles.debug(`Tests from ${store.codeceptDir}:`)) output.print() - const mocha = Container.mocha() - mocha.files = files - mocha.loadFiles() + const suites = codecept.getSuites() let numOfTests = 0 let numOfSuites = 0 @@ -65,19 +62,19 @@ async function printTests(files) { let filterRegex if (filterBy) { try { - filterRegex = new RegExp(filterBy, 'i') // Case-insensitive matching + filterRegex = new RegExp(filterBy, 'i') } catch (err) { console.error(`Invalid grep pattern: ${filterBy}`) process.exit(1) } } - for (const suite of mocha.suite.suites) { + for (const suite of suites) { const suiteMatches = filterRegex ? filterRegex.test(suite.title) : true let suiteHasMatchingTests = false if (suiteMatches) { - outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file || '')}\n` + outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file)}\n` suiteHasMatchingTests = true numOfSuites++ } @@ -87,7 +84,7 @@ async function printTests(files) { if (testMatches) { if (!suiteMatches && !suiteHasMatchingTests) { - outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file || '')}\n` + outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file)}\n` suiteHasMatchingTests = true numOfSuites++ } diff --git a/lib/command/run-multiple.js b/lib/command/run-multiple.js index 6f28af9bc..504d2832e 100644 --- a/lib/command/run-multiple.js +++ b/lib/command/run-multiple.js @@ -9,6 +9,7 @@ import { createRuns } from './run-multiple/collection.js' import { clearString, replaceValueDeep } from '../utils.js' import { getConfig, getTestRoot, fail } from './utils.js' import store from '../store.js' +import Config from '../config.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -115,17 +116,11 @@ export default async function (selectedRuns, options) { } function executeRun(runName, runConfig) { - // clone config - let overriddenConfig = { ...config } + let overriddenConfig = Config.applyRunConfig(config, runConfig) - // get configuration const browserConfig = runConfig.browser const browserName = browserConfig.browser - for (const key in browserConfig) { - overriddenConfig.helpers = replaceValueDeep(overriddenConfig.helpers, key, browserConfig[key]) - } - let outputDir = `${runName}_` if (browserConfig.outputName) { outputDir += typeof browserConfig.outputName === 'function' ? browserConfig.outputName() : browserConfig.outputName @@ -138,20 +133,10 @@ function executeRun(runName, runConfig) { outputDir = clearString(outputDir) - // tweaking default output directories and for mochawesome overriddenConfig = replaceValueDeep(overriddenConfig, 'output', path.join(config.output, outputDir)) overriddenConfig = replaceValueDeep(overriddenConfig, 'reportDir', path.join(config.output, outputDir)) overriddenConfig = replaceValueDeep(overriddenConfig, 'mochaFile', path.join(config.output, outputDir, `${browserName}_report.xml`)) - // override tests configuration - if (overriddenConfig.tests) { - overriddenConfig.tests = runConfig.tests - } - - if (overriddenConfig.gherkin && runConfig.gherkin && runConfig.gherkin.features) { - overriddenConfig.gherkin.features = runConfig.gherkin.features - } - // override grep param and collect all params const params = ['run', '--child', `${runId++}.${runName}:${browserName}`, '--override', JSON.stringify(overriddenConfig)] diff --git a/lib/config.js b/lib/config.js index 0b3372e32..9c1114b35 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' import { createRequire } from 'module' -import { fileExists, isFile, deepMerge, deepClone } from './utils.js' +import { fileExists, isFile, deepMerge, deepClone, replaceValueDeep } from './utils.js' import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' const defaultConfig = { @@ -134,6 +134,33 @@ class Config { return (config = deepMerge(config, additionalConfig)) } + /** + * Apply run configuration (browser overrides, tests, gherkin) to a base config. + * Used by workers and run-multiple to create per-run configurations. + * + * @param {Object} baseConfig + * @param {Object} runConfig - must have .browser object, optionally .tests and .gherkin + * @return {Object} + */ + static applyRunConfig(baseConfig, runConfig) { + const overriddenConfig = deepClone(baseConfig) + const browserConfig = runConfig.browser + + for (const key in browserConfig) { + overriddenConfig.helpers = replaceValueDeep(overriddenConfig.helpers, key, browserConfig[key]) + } + + if (overriddenConfig.tests && runConfig.tests) { + overriddenConfig.tests = runConfig.tests + } + + if (overriddenConfig.gherkin && runConfig.gherkin?.features) { + overriddenConfig.gherkin.features = runConfig.gherkin.features + } + + return overriddenConfig + } + /** * Resets config to default * @return {Object} diff --git a/lib/index.js b/lib/index.js index 6d427892a..6dc390e0b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -23,6 +23,8 @@ import heal from './heal.js' import ai from './ai.js' import Workers from './workers.js' import Secret, { secret } from './secret.js' +import Result from './result.js' +import Runner from './runner.js' export default { /** @type {typeof CodeceptJS.Codecept} */ @@ -67,7 +69,12 @@ export default { Secret, /** @type {typeof CodeceptJS.secret} */ secret, + + /** @type {typeof Result} */ + Result, + + Runner, } // Named exports for ESM compatibility -export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret } +export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret, Result, Runner } diff --git a/lib/runner.js b/lib/runner.js new file mode 100644 index 000000000..ed2880edc --- /dev/null +++ b/lib/runner.js @@ -0,0 +1,116 @@ +import fsPath from 'path' +import container from './container.js' +import MochaFactory from './mocha/factory.js' +import event from './event.js' +import recorder from './recorder.js' +import output from './output.js' +import store from './store.js' +import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js' + +class Runner { + constructor(codecept) { + this.codecept = codecept + } + + getSuites(pattern) { + if (this.codecept.testFiles.length === 0) { + this.codecept.loadTests(pattern) + } + + const tempMocha = MochaFactory.create(this.codecept.config.mocha || {}, this.codecept.opts || {}) + tempMocha.files = this.codecept.testFiles + tempMocha.loadFiles() + + const suites = [] + for (const suite of tempMocha.suite.suites) { + suites.push({ + ...suite.simplify(), + file: suite.file || '', + tests: suite.tests.map(test => ({ + ...test.simplify(), + fullTitle: test.fullTitle(), + })), + }) + } + + tempMocha.unloadFiles() + return suites + } + + async run(test) { + let files = this.codecept.testFiles + let grep + + if (test) { + if (!fsPath.isAbsolute(test)) { + test = fsPath.join(store.codeceptDir, test) + } + const testBasename = fsPath.basename(test, '.js') + const testFeatureBasename = fsPath.basename(test, '.feature') + files = files.filter(t => { + return fsPath.basename(t, '.js') === testBasename || fsPath.basename(t, '.feature') === testFeatureBasename || t === test + }) + } + + return this._execute({ files, grep }) + } + + async runSuite(suite) { + return this.run(suite.file) + } + + async runTest(test) { + return this._execute({ grep: test.fullTitle }) + } + + async _execute({ files, grep } = {}) { + await container.started() + + const tsValidation = validateTypeScriptSetup(this.codecept.testFiles, this.codecept.requiringModules || []) + if (tsValidation.hasError) { + output.error(tsValidation.message) + process.exit(1) + } + + const tsWarning = getTSNodeESMWarning(this.codecept.requiringModules || []) + if (tsWarning) { + output.print(output.colors.yellow(tsWarning)) + } + + try { + const { loadTranslations } = await import('./mocha/gherkin.js') + await loadTranslations() + } catch (e) { + // Ignore if gherkin module not available + } + + return new Promise((resolve, reject) => { + const mocha = container.mocha() + mocha.files = files || this.codecept.testFiles + + if (grep) { + mocha.grep(grep) + } + + const done = async (failures) => { + event.emit(event.all.result, container.result()) + event.emit(event.all.after, this.codecept) + await recorder.promise() + if (failures) { + process.exitCode = 1 + } + resolve() + } + + try { + event.emit(event.all.before, this.codecept) + mocha.run(async (failures) => await done(failures)) + } catch (e) { + output.error(e.stack) + reject(e) + } + }) + } +} + +export default Runner diff --git a/lib/workers.js b/lib/workers.js index d97304a69..c6e5d7f7e 100644 --- a/lib/workers.js +++ b/lib/workers.js @@ -13,7 +13,7 @@ import Codecept from './codecept.js' import MochaFactory from './mocha/factory.js' import Container from './container.js' import { getTestRoot } from './command/utils.js' -import { isFunction, fileExists, replaceValueDeep, deepClone } from './utils.js' +import { isFunction, fileExists } from './utils.js' import mainConfig from './config.js' import output from './output.js' import event from './event.js' @@ -117,34 +117,11 @@ const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns } const workersToExecute = [] - const currentOutputFolder = config.output - let currentMochawesomeReportDir - let currentMochaJunitReporterFile - - if (config.mocha && config.mocha.reporterOptions) { - currentMochawesomeReportDir = config.mocha.reporterOptions?.mochawesome.options.reportDir - currentMochaJunitReporterFile = config.mocha.reporterOptions['mocha-junit-reporter'].options.mochaFile - } - createRuns(selectedRuns, config).forEach(worker => { - const separator = path.sep - const _config = { ...config } - let workerName = worker.name.replace(':', '_') - _config.output = `${currentOutputFolder}${separator}${workerName}` - if (config.mocha && config.mocha.reporterOptions) { - _config.mocha.reporterOptions.mochawesome.options.reportDir = `${currentMochawesomeReportDir}${separator}${workerName}` - - const _tempArray = currentMochaJunitReporterFile.split(separator) - _tempArray.splice( - _tempArray.findIndex(item => item.includes('.xml')), - 0, - workerName, - ) - _config.mocha.reporterOptions['mocha-junit-reporter'].options.mochaFile = _tempArray.join(separator) - } - workerName = worker.getOriginalName() || worker.getName() - const workerConfig = worker.getConfig() - workersToExecute.push(getOverridenConfig(workerName, workerConfig, _config)) + const workerName = worker.name.replace(':', '_') + const _config = mainConfig.applyRunConfig(config, worker.getConfig()) + _config.output = path.join(config.output, workerName) + workersToExecute.push(_config) }) const workers = [] let index = 0 @@ -188,27 +165,6 @@ const convertToMochaTests = testGroup => { return group } -const getOverridenConfig = (workerName, workerConfig, config) => { - // clone config - const overriddenConfig = deepClone(config) - - // get configuration - const browserConfig = workerConfig.browser - - for (const key in browserConfig) { - overriddenConfig.helpers = replaceValueDeep(overriddenConfig.helpers, key, browserConfig[key]) - } - - // override tests configuration - if (overriddenConfig.tests) { - overriddenConfig.tests = workerConfig.tests - } - - if (overriddenConfig.gherkin && workerConfig.gherkin && workerConfig.gherkin.features) { - overriddenConfig.gherkin.features = workerConfig.gherkin.features - } - return overriddenConfig -} class WorkerObject { /** @@ -224,17 +180,11 @@ class WorkerObject { addConfig(config) { const oldConfig = JSON.parse(this.options.override || '{}') - // Remove customLocatorStrategies from both old and new config before JSON serialization - // since functions cannot be serialized and will be lost, causing workers to have empty strategies. - // Note: Only WebDriver helper supports customLocatorStrategies - const configWithoutFunctions = { ...config } - - // Clean both old and new config const cleanConfig = cfg => { if (cfg.helpers) { cfg.helpers = { ...cfg.helpers } Object.keys(cfg.helpers).forEach(helperName => { - if (cfg.helpers[helperName] && cfg.helpers[helperName].customLocatorStrategies !== undefined) { + if (cfg.helpers[helperName]?.customLocatorStrategies !== undefined) { cfg.helpers[helperName] = { ...cfg.helpers[helperName] } delete cfg.helpers[helperName].customLocatorStrategies } @@ -243,11 +193,7 @@ class WorkerObject { return cfg } - const cleanedOldConfig = cleanConfig(oldConfig) - const cleanedNewConfig = cleanConfig(configWithoutFunctions) - - // Deep merge configurations to preserve all helpers from base config - const newConfig = merge({}, cleanedOldConfig, cleanedNewConfig) + const newConfig = merge({}, cleanConfig(oldConfig), cleanConfig(config)) this.options.override = JSON.stringify(newConfig) } @@ -292,8 +238,6 @@ class Workers extends EventEmitter { this.testPool = [] this.testPoolInitialized = false this.isPoolMode = config.by === 'pool' - this.activeWorkers = new Map() - this.maxWorkers = numberOfWorkers // Track original worker count for pool mode createOutputDir(config.testConfig) // Defer worker initialization until codecept is ready @@ -369,30 +313,18 @@ class Workers extends EventEmitter { * @param {Number} numberOfWorkers */ createGroupsOfTests(numberOfWorkers) { - // If Codecept isn't initialized yet, return empty groups as a safe fallback if (!this.codecept) return populateGroups(numberOfWorkers) - const files = this.codecept.testFiles - - // Create a fresh mocha instance to avoid state pollution - Container.createMocha(this.codecept.config.mocha || {}, this.options) - const mocha = Container.mocha() - mocha.files = files - mocha.loadFiles() - + const suites = this.codecept.getSuites() const groups = populateGroups(numberOfWorkers) let groupCounter = 0 - mocha.suite.eachTest(test => { - const i = groupCounter % groups.length - if (test) { - groups[i].push(test.uid) + for (const suite of suites) { + for (const test of suite.tests) { + groups[groupCounter % groups.length].push(test.uid) groupCounter++ } - }) - - // Clean up after collecting test UIDs - mocha.unloadFiles() - + } + return groups } @@ -407,36 +339,11 @@ class Workers extends EventEmitter { this.testGroups = populateGroups(numberOfWorkers) } - /** - * Initialize the test pool if not already done - * This is called lazily to avoid state pollution issues during construction - */ _initializeTestPool() { - if (this.testPoolInitialized) { - return - } - - // Ensure codecept is initialized - if (!this.codecept) { - output.log('Warning: codecept not initialized when initializing test pool') - this.testPoolInitialized = true - return - } - - const files = this.codecept.testFiles - if (!files || files.length === 0) { - this.testPoolInitialized = true - return - } - - // In ESM, test UIDs are not stable across different mocha instances - // So instead of using UIDs, we distribute test FILES - // Each file may contain multiple tests - for (const file of files) { - this.testPool.push(file) - } - + if (this.testPoolInitialized) return this.testPoolInitialized = true + if (!this.codecept) return + this.testPool = [...this.codecept.testFiles] } /** @@ -456,29 +363,17 @@ class Workers extends EventEmitter { * @param {Number} numberOfWorkers */ createGroupsOfSuites(numberOfWorkers) { - // If Codecept isn't initialized yet, return empty groups as a safe fallback if (!this.codecept) return populateGroups(numberOfWorkers) - const files = this.codecept.testFiles + const suites = this.codecept.getSuites() const groups = populateGroups(numberOfWorkers) - // Create a fresh mocha instance to avoid state pollution - Container.createMocha(this.codecept.config.mocha || {}, this.options) - const mocha = Container.mocha() - mocha.files = files - mocha.loadFiles() - - mocha.suite.suites.forEach(suite => { + for (const suite of suites) { const i = indexOfSmallestElement(groups) - suite.tests.forEach(test => { - if (test) { - groups[i].push(test.uid) - } - }) - }) - - // Clean up after collecting test UIDs - mocha.unloadFiles() - + for (const test of suite.tests) { + groups[i].push(test.uid) + } + } + return groups } @@ -517,10 +412,6 @@ class Workers extends EventEmitter { workerThreads.push(workerThread) } - recorder.add('workers started', () => { - // Workers are already running, this is just a placeholder step - }) - // Add overall timeout to prevent infinite hanging const overallTimeout = setTimeout(() => { console.error('[Main] Overall timeout reached (10 minutes). Force terminating remaining workers...') @@ -557,11 +448,6 @@ class Workers extends EventEmitter { } _listenWorkerEvents(worker) { - // Track worker thread for pool mode - if (this.isPoolMode) { - this.activeWorkers.set(worker, { available: true, workerIndex: null }) - } - // Track last activity time to detect hanging workers let lastActivity = Date.now() let currentTest = null