UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

843 lines (738 loc) 26.9 kB
import path from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' import { mkdirp } from 'mkdirp' import { Worker } from 'worker_threads' import { EventEmitter } from 'events' import ms from 'ms' import merge from 'lodash.merge' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) 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 mainConfig from './config.js' import output from './output.js' import event from './event.js' import { deserializeTest } from './mocha/test.js' import { deserializeSuite } from './mocha/suite.js' import recorder from './recorder.js' import store from './store.js' import runHook from './hooks.js' import WorkerStorage from './workerStorage.js' import { createRuns } from './command/run-multiple/collection.js' const pathToWorker = path.join(__dirname, 'command', 'workers', 'runTests.js') const initializeCodecept = async (configPath, options = {}) => { const config = await mainConfig.load(configPath || '.') const codecept = new Codecept(config, { ...options, skipDefaultListeners: true }) await codecept.init(getTestRoot(configPath)) codecept.loadTests() return codecept } const createOutputDir = async configPath => { const config = await mainConfig.load(configPath || '.') const testRoot = getTestRoot(configPath) const outputDir = path.isAbsolute(config.output) ? config.output : path.join(testRoot, config.output) if (!fileExists(outputDir)) { output.print(`creating output directory: ${outputDir}`) mkdirp.sync(outputDir) } } const populateGroups = numberOfWorkers => { const groups = [] for (let i = 0; i < numberOfWorkers; i++) { groups[i] = [] } return groups } const createWorker = (workerObject, isPoolMode = false) => { const worker = new Worker(pathToWorker, { workerData: { options: simplifyObject(workerObject.options), tests: workerObject.tests, testRoot: workerObject.testRoot, workerIndex: workerObject.workerIndex + 1, poolMode: isPoolMode, }, stdout: true, stderr: true, }) // Pipe worker stdout/stderr to main process if (worker.stdout) { worker.stdout.setEncoding('utf8') worker.stdout.on('data', data => { process.stdout.write(data) }) } if (worker.stderr) { worker.stderr.setEncoding('utf8') worker.stderr.on('data', data => { process.stderr.write(data) }) } worker.on('error', err => { console.error(`[Main] Worker Error:`, err) output.error(`Worker Error: ${err.stack}`) }) WorkerStorage.addWorker(worker) return worker } const simplifyObject = object => { return Object.keys(object) .filter(k => k.indexOf('_') !== 0) .filter(k => typeof object[k] !== 'function') .filter(k => typeof object[k] !== 'object') .reduce((obj, key) => { obj[key] = object[key] return obj }, {}) } const createWorkerObjects = (testGroups, config, testRoot, options, selectedRuns) => { selectedRuns = options && options.all && config.multiple ? Object.keys(config.multiple) : selectedRuns if (selectedRuns === undefined || !selectedRuns.length || config.multiple === undefined) { return testGroups.map((tests, index) => { const workerObj = new WorkerObject(index) workerObj.addConfig(config) workerObj.addTests(tests) workerObj.setTestRoot(testRoot) workerObj.addOptions(options) return workerObj }) } const workersToExecute = [] const currentOutputFolder = config.output let currentMochaJunitReporterFile if (config.mocha && config.mocha.reporterOptions) { 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) { 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 workers = [] let index = 0 testGroups.forEach(tests => { const testWorkerArray = [] workersToExecute.forEach(finalConfig => { const workerObj = new WorkerObject(index++) workerObj.addConfig(finalConfig) workerObj.addTests(tests) workerObj.setTestRoot(testRoot) workerObj.addOptions(options) testWorkerArray.push(workerObj) }) workers.push(...testWorkerArray) }) return workers } const indexOfSmallestElement = groups => { let i = 0 for (let j = 1; j < groups.length; j++) { if (groups[j - 1].length > groups[j].length) { i = j } } return i } const convertToMochaTests = testGroup => { const group = [] if (testGroup instanceof Array) { const mocha = MochaFactory.create({}, {}) mocha.files = testGroup mocha.loadFiles() mocha.suite.eachTest(test => { group.push(test.uid) }) mocha.unloadFiles() } 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 { /** * @param {Number} workerIndex - Unique ID for worker */ constructor(workerIndex) { this.workerIndex = workerIndex this.options = {} this.tests = [] this.testRoot = getTestRoot() } 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) { cfg.helpers[helperName] = { ...cfg.helpers[helperName] } delete cfg.helpers[helperName].customLocatorStrategies } }) } 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) this.options.override = JSON.stringify(newConfig) } addTestFiles(testGroup) { this.addTests(convertToMochaTests(testGroup)) } addTests(tests) { this.tests = this.tests.concat(tests) } setTestRoot(path) { this.testRoot = getTestRoot(path) } addOptions(opts) { this.options = { ...this.options, ...opts, } } } class Workers extends EventEmitter { /** * @param {Number} numberOfWorkers * @param {Object} config */ constructor(numberOfWorkers, config = { by: 'test' }) { super() this.setMaxListeners(50) this.codeceptPromise = initializeCodecept(config.testConfig, config.options) this.codecept = null this.config = config // Save config this.numberOfWorkersRequested = numberOfWorkers // Save requested worker count this.options = config.options || {} this.errors = [] this.numberOfWorkers = 0 this.closedWorkers = 0 this.workers = [] this.testGroups = [] 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 } async _ensureInitialized() { if (!this.codecept) { this.codecept = await this.codeceptPromise // Initialize workers in these cases: // 1. Positive number requested AND no manual workers pre-spawned // 2. Function-based grouping (indicated by negative number) AND no manual workers pre-spawned const shouldAutoInit = this.workers.length === 0 && ((Number.isInteger(this.numberOfWorkersRequested) && this.numberOfWorkersRequested > 0) || (this.numberOfWorkersRequested < 0 && isFunction(this.config.by))) if (shouldAutoInit) { this._initWorkers(this.numberOfWorkersRequested, this.config) } } } _initWorkers(numberOfWorkers, config) { this.splitTestsByGroups(numberOfWorkers, config) // For function-based grouping, use the actual number of test groups created const actualNumberOfWorkers = isFunction(config.by) ? this.testGroups.length : numberOfWorkers this.workers = createWorkerObjects(this.testGroups, this.codecept.config, getTestRoot(config.testConfig), config.options, config.selectedRuns) this.numberOfWorkers = this.workers.length } /** * This splits tests by groups. * Strategy for group split is taken from a constructor's config.by value: * * `config.by` can be: * * - `suite` * - `test` * - `pool` * - function(numberOfWorkers) * * This method can be overridden for a better split. */ splitTestsByGroups(numberOfWorkers, config) { if (isFunction(config.by)) { const createTests = config.by const testGroups = createTests(numberOfWorkers) if (!(testGroups instanceof Array)) { throw new Error('Test group should be an array') } for (const testGroup of testGroups) { this.testGroups.push(convertToMochaTests(testGroup)) } } else if (typeof numberOfWorkers === 'number' && numberOfWorkers > 0) { if (config.by === 'pool') { this.createTestPool(numberOfWorkers) } else { this.testGroups = config.by === 'suite' ? this.createGroupsOfSuites(numberOfWorkers) : this.createGroupsOfTests(numberOfWorkers) } } } /** * Creates a new worker * * @returns {WorkerObject} */ spawn() { const worker = new WorkerObject(this.numberOfWorkers) this.workers.push(worker) this.numberOfWorkers += 1 return worker } /** * @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 groups = populateGroups(numberOfWorkers) let groupCounter = 0 mocha.suite.eachTest(test => { const i = groupCounter % groups.length if (test) { groups[i].push(test.uid) groupCounter++ } }) // Clean up after collecting test UIDs mocha.unloadFiles() return groups } /** * @param {Number} numberOfWorkers */ createTestPool(numberOfWorkers) { // For pool mode, create empty groups for each worker and initialize empty pool // Test pool will be populated lazily when getNextTest() is first called this.testPool = [] this.testPoolInitialized = false 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) } this.testPoolInitialized = true } /** * Gets the next test from the pool * @returns {String|null} test file path or null if no tests available */ getNextTest() { // Lazy initialization of test pool on first call if (!this.testPoolInitialized) { this._initializeTestPool() } return this.testPool.shift() } /** * @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 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 => { const i = indexOfSmallestElement(groups) suite.tests.forEach(test => { if (test) { groups[i].push(test.uid) } }) }) // Clean up after collecting test UIDs mocha.unloadFiles() return groups } /** * @param {Object} config */ overrideConfig(config) { for (const worker of this.workers) { worker.addConfig(config) } } async bootstrapAll() { await this._ensureInitialized() return runHook(this.codecept.config.bootstrapAll, 'bootstrapAll') } async teardownAll() { await this._ensureInitialized() return runHook(this.codecept.config.teardownAll, 'teardownAll') } async run() { await this._ensureInitialized() recorder.startUnlessRunning() event.dispatcher.emit(event.workers.before) store.workerMode = true process.env.RUNS_WITH_WORKERS = 'true' // Create workers and set up message handlers immediately (not in recorder queue) // This prevents a race condition where workers start sending messages before handlers are attached const workerThreads = [] for (const worker of this.workers) { const workerThread = createWorker(worker, this.isPoolMode) this._listenWorkerEvents(workerThread) workerThreads.push(workerThread) } recorder.add('workers started', () => { // Workers are already running, this is just a placeholder step }) return new Promise(resolve => { this.on('end', () => { resolve() }) }) } /** * @returns {Array<WorkerObject>} */ getWorkers() { return this.workers } /** * @returns {Boolean} */ isFailed() { return (Container.result().failures.length || this.errors.length) > 0 } _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 let autoTerminated = false const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m') const timeoutChecker = setInterval(() => { const elapsed = Date.now() - lastActivity if (elapsed > workerTimeout) { console.error(`[Main] Worker appears to be hanging (no activity for ${Math.floor(elapsed/1000)}s). Terminating...`) if (currentTest) { console.error(`[Main] Last test: ${currentTest}`) } clearInterval(timeoutChecker) worker.terminate() } }, 30000) // Check every 30 seconds worker.on('message', message => { lastActivity = Date.now() // Update activity timestamp // Track current test if (message.event === event.test.started && message.data) { currentTest = message.data.title || message.data.fullTitle } output.process(message.workerIndex) // Handle test requests for pool mode if (message.type === 'REQUEST_TEST') { if (this.isPoolMode) { const nextTest = this.getNextTest() if (nextTest) { worker.postMessage({ type: 'TEST_ASSIGNED', test: nextTest }) } else { worker.postMessage({ type: 'NO_MORE_TESTS' }) } } return } // deal with events that are not test cycle related if (!message.event) { return this.emit('message', message) } switch (message.event) { case event.all.result: // we ensure consistency of result by adding tests in the very end // Check if message.data.stats is valid before adding if (message.data.stats) { Container.result().addStats(message.data.stats) } if (message.data.failures) { Container.result().addFailures(message.data.failures) } if (message.data.tests) { message.data.tests.forEach(test => { Container.result().addTest(deserializeTest(test)) }) } const exitTimeout = parseInt(process.env.CODECEPT_AUTO_EXIT_TIMEOUT, 10) if (exitTimeout === 0) break setTimeout(() => { autoTerminated = true worker.terminate() }, exitTimeout || 2000) break case event.suite.before: { const suite = deserializeSuite(message.data) this.emit(event.suite.before, suite) event.dispatcher.emit(event.suite.before, suite) } break case event.suite.after: { const suite = deserializeSuite(message.data) this.emit(event.suite.after, suite) event.dispatcher.emit(event.suite.after, suite) } break case event.test.before: { const test = deserializeTest(message.data) this.emit(event.test.before, test) event.dispatcher.emit(event.test.before, test) } break case event.test.started: { const test = deserializeTest(message.data) this.emit(event.test.started, test) event.dispatcher.emit(event.test.started, test) } break case event.test.failed: // For hook failures, emit immediately as there won't be a test.finished event // Regular test failures are handled via test.finished to support retries if (message.data?.hookName) { this.emit(event.test.failed, deserializeTest(message.data)) } // Otherwise skip - we'll emit based on finished state break case event.test.passed: // Skip individual passed events - we'll emit based on finished state break case event.test.skipped: { const test = deserializeTest(message.data) this.emit(event.test.skipped, test) event.dispatcher.emit(event.test.skipped, test) } break case event.test.finished: // Handle different types of test completion properly { const data = message.data const uid = data?.uid const isFailed = !!data?.err || data?.state === 'failed' if (uid) { // Track states for each test UID if (!this._testStates) this._testStates = new Map() if (!this._testStates.has(uid)) { this._testStates.set(uid, { states: [], lastData: data, workerIndex: message.workerIndex }) } const testState = this._testStates.get(uid) testState.states.push({ isFailed, data }) testState.lastData = data } else { // For tests without UID, emit immediately if (isFailed) { this.emit(event.test.failed, deserializeTest(data)) } else { this.emit(event.test.passed, deserializeTest(data)) } } const test = deserializeTest(data) this.emit(event.test.finished, test) event.dispatcher.emit(event.test.finished, test) } break case event.test.after: { const test = deserializeTest(message.data) this.emit(event.test.after, test) event.dispatcher.emit(event.test.after, test) } break case event.step.finished: this.emit(event.step.finished, message.data) event.dispatcher.emit(event.step.finished, message.data) break case event.step.started: this.emit(event.step.started, message.data) event.dispatcher.emit(event.step.started, message.data) break case event.step.passed: this.emit(event.step.passed, message.data) event.dispatcher.emit(event.step.passed, message.data) break case event.step.failed: this.emit(event.step.failed, message.data, message.data.error) event.dispatcher.emit(event.step.failed, message.data, message.data.error) break case event.hook.failed: // Hook failures are already reported as test failures by the worker // Just emit the hook.failed event for listeners this.emit(event.hook.failed, message.data) event.dispatcher.emit(event.hook.failed, message.data) break case event.hook.passed: this.emit(event.hook.passed, message.data) event.dispatcher.emit(event.hook.passed, message.data) break case event.hook.finished: this.emit(event.hook.finished, message.data) event.dispatcher.emit(event.hook.finished, message.data) break } }) worker.on('error', err => { console.error(`[Main] Worker error:`, err.message || err) if (currentTest) { console.error(`[Main] Failed during test: ${currentTest}`) } this.errors.push(err) }) worker.on('exit', (code) => { clearInterval(timeoutChecker) this.closedWorkers += 1 if (code !== 0 && !autoTerminated) { console.error(`[Main] Worker exited with code ${code}`) if (currentTest) { console.error(`[Main] Last test running: ${currentTest}`) } // Mark as failed process.exitCode = 1 } if (this.isPoolMode) { // Pool mode: finish when all workers have exited and no more tests if (this.closedWorkers === this.numberOfWorkers) { this._finishRun() } } else if (this.closedWorkers === this.numberOfWorkers) { // Regular mode: finish when all original workers have exited this._finishRun() } }) } _finishRun() { event.dispatcher.emit(event.workers.after, { tests: this.workers.map(worker => worker.tests) }) if (Container.result().hasFailed || this.errors.length > 0) { process.exitCode = 1 } else { process.exitCode = 0 } // Emit states for all tracked tests before emitting results if (this._testStates) { for (const [uid, { states, lastData, workerIndex }] of this._testStates) { // Set correct worker index for output output.process(workerIndex) // For tests with retries configured, emit all failures + final success // For tests without retries, emit only final state const lastState = states[states.length - 1] // Check if this test had retries by looking for failure followed by success const hasRetryPattern = states.length > 1 && states.some((s, i) => s.isFailed && i < states.length - 1 && !states[i + 1].isFailed) if (hasRetryPattern) { // Emit all intermediate failures and final success for retries for (const state of states) { if (state.isFailed) { this.emit(event.test.failed, deserializeTest(state.data)) } else { this.emit(event.test.passed, deserializeTest(state.data)) } } } else { // For non-retries (like step failures), emit only the final state if (lastState.isFailed) { this.emit(event.test.failed, deserializeTest(lastState.data)) } else { this.emit(event.test.passed, deserializeTest(lastState.data)) } } } this._testStates.clear() } this.emit(event.all.result, Container.result()) event.dispatcher.emit(event.workers.result, Container.result()) this.emit('end') // internal event } printResults() { const result = Container.result() result.finish() // Reset process for logs in main thread output.process(null) output.print() this.failuresLog = result.failures .filter(log => log.length && typeof log[1] === 'number') // mocha/lib/reporters/base.js .map(([format, num, title, message, stack], i) => [format, i + 1, title, message, stack]) if (this.failuresLog.length) { output.print() output.print('-- FAILURES:') this.failuresLog.forEach(log => output.print(...log)) } output.result(result.stats?.passes || 0, result.stats?.failures || 0, result.stats?.pending || 0, ms(result.duration), result.stats?.failedHooks || 0) process.env.RUNS_WITH_WORKERS = 'false' } } export default Workers