codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
346 lines (307 loc) • 10.5 kB
JavaScript
import { existsSync, readFileSync } from 'fs'
import { globSync } from 'glob'
import shuffle from 'lodash.shuffle'
import fsPath from 'path'
import { resolve } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { dirname } from 'path'
import { createRequire } from 'module'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
import Helper from '@codeceptjs/helper'
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, resolveImportModulePath } 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'
import stepsListener from './listener/steps.js'
import configListener from './listener/config.js'
import resultListener from './listener/result.js'
import helpersListener from './listener/helpers.js'
import globalTimeoutListener from './listener/globalTimeout.js'
import globalRetryListener from './listener/globalRetry.js'
import exitListener from './listener/exit.js'
import emptyRunListener from './listener/emptyRun.js'
/**
* CodeceptJS runner
*/
class Codecept {
/**
* Create CodeceptJS runner.
* Config and options should be passed
*
* @param {*} config
* @param {*} opts
*/
constructor(config, opts) {
this.config = Config.create(config)
this.opts = opts
this.testFiles = new Array(0)
this.requiringModules = config.require
}
/**
* Require modules before codeceptjs running
*
* @param {string[]} requiringModules
*/
async requireModules(requiringModules) {
if (requiringModules) {
for (const requiredModule of requiringModules) {
let modulePath = requiredModule
const isLocalFile = existsSync(modulePath) || existsSync(`${modulePath}.js`)
if (isLocalFile) {
modulePath = resolve(modulePath)
// For ESM, ensure .js extension for local files
if (!modulePath.endsWith('.js') && !modulePath.endsWith('.mjs') && !modulePath.endsWith('.cjs')) {
if (existsSync(`${modulePath}.js`)) {
modulePath = `${modulePath}.js`
}
}
} else {
// For npm packages, resolve from the user's directory
// This ensures packages like tsx are found in user's node_modules
const userDir = store.codeceptDir || process.cwd()
try {
// Use createRequire to resolve from user's directory
const userRequire = createRequire(pathToFileURL(resolve(userDir, 'package.json')).href)
const resolvedPath = userRequire.resolve(requiredModule)
modulePath = pathToFileURL(resolvedPath).href
} catch (resolveError) {
// If resolution fails, try direct import (will check from CodeceptJS node_modules)
// This is the fallback for globally installed packages
modulePath = requiredModule
}
}
// Use dynamic import for ESM
const resolvedPath = resolveImportModulePath(modulePath)
await import(resolvedPath)
}
}
}
/**
* Initialize CodeceptJS at specific dir.
* Loads config, requires factory methods
*
* @param {string} dir
*/
async init(dir) {
await this.initGlobals(dir)
// Require modules before initializing
await this.requireModules(this.requiringModules)
// initializing listeners
await container.create(this.config, this.opts)
await this.runHooks()
}
/**
* Creates global variables
*
* @param {string} dir
*/
async initGlobals(dir) {
await initCodeceptGlobals(dir, this.config, container)
}
/**
* Executes hooks.
*/
async runHooks() {
// For workers parent process we only need plugins/hooks.
// Core listeners are executed inside worker threads.
if (!this.opts?.skipDefaultListeners) {
const listenerModules = [
'./listener/store.js',
'./listener/steps.js',
'./listener/config.js',
'./listener/result.js',
'./listener/helpers.js',
'./listener/pageobjects.js',
'./listener/globalTimeout.js',
'./listener/globalRetry.js',
'./listener/retryEnhancer.js',
'./listener/exit.js',
'./listener/emptyRun.js',
]
for (const modulePath of listenerModules) {
const resolvedPath = resolveImportModulePath(modulePath)
const module = await import(resolvedPath)
runHook(module.default || module)
}
}
// custom hooks (previous iteration of plugins)
this.config.hooks.forEach(hook => runHook(hook))
}
/**
* Executes bootstrap.
*
* @returns {Promise<void>}
*/
async bootstrap() {
return runHook(this.config.bootstrap, 'bootstrap')
}
/**
* Executes teardown.
*
* @returns {Promise<void>}
*/
async teardown() {
return runHook(this.config.teardown, 'teardown')
}
/**
* Loads tests by pattern or by config.tests
*
* @param {string} [pattern]
*/
loadTests(pattern) {
const options = {
cwd: store.codeceptDir,
}
let patterns = [pattern]
if (!pattern) {
patterns = []
// If the user wants to test a specific set of test files as an array or string.
if (this.config.tests && !this.opts.features) {
if (Array.isArray(this.config.tests)) {
patterns.push(...this.config.tests)
} else {
patterns.push(this.config.tests)
}
}
if (this.config.gherkin && this.config.gherkin.features && !this.opts.tests) {
if (Array.isArray(this.config.gherkin.features)) {
this.config.gherkin.features.forEach(feature => {
patterns.push(feature)
})
} else {
patterns.push(this.config.gherkin.features)
}
}
}
for (pattern of patterns) {
if (pattern) {
globSync(pattern, options).forEach(file => {
if (file.includes('node_modules')) return
if (!fsPath.isAbsolute(file)) {
file = fsPath.join(store.codeceptDir, file)
}
if (!this.testFiles.includes(fsPath.resolve(file))) {
this.testFiles.push(fsPath.resolve(file))
}
})
}
}
if (this.opts.shuffle) {
this.testFiles = shuffle(this.testFiles)
}
if (this.opts.shard) {
this.testFiles = this._applySharding(this.testFiles, this.opts.shard)
}
}
/**
* Apply sharding to test files based on shard configuration
*
* @param {Array<string>} testFiles - Array of test file paths
* @param {string} shardConfig - Shard configuration in format "index/total" (e.g., "1/4")
* @returns {Array<string>} - Filtered array of test files for this shard
*/
_applySharding(testFiles, shardConfig) {
const shardMatch = shardConfig.match(/^(\d+)\/(\d+)$/)
if (!shardMatch) {
throw new Error('Invalid shard format. Expected format: "index/total" (e.g., "1/4")')
}
const shardIndex = parseInt(shardMatch[1], 10)
const shardTotal = parseInt(shardMatch[2], 10)
if (shardTotal < 1) {
throw new Error('Shard total must be at least 1')
}
if (shardIndex < 1 || shardIndex > shardTotal) {
throw new Error(`Shard index ${shardIndex} must be between 1 and ${shardTotal}`)
}
if (testFiles.length === 0) {
return testFiles
}
// Calculate which tests belong to this shard
const shardSize = Math.ceil(testFiles.length / shardTotal)
const startIndex = (shardIndex - 1) * shardSize
const endIndex = Math.min(startIndex + shardSize, testFiles.length)
return testFiles.slice(startIndex, endIndex)
}
/**
* Run a specific test or all loaded tests.
*
* @param {string} [test]
* @returns {Promise<void>}
*/
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
}
// Sort test files alphabetically for consistent execution order,
// but skip sorting when --shuffle is active so the randomised order is preserved.
if (!this.opts.shuffle) {
this.testFiles.sort()
}
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.runner = mocha.run(async (failures) => await done(failures))
} catch (e) {
output.error(e.stack)
reject(e)
}
})
}
/**
* Returns the version string of CodeceptJS.
*
* @returns {string} The version string.
*/
static version() {
return JSON.parse(readFileSync(`${__dirname}/../package.json`, 'utf8')).version
}
}
export default Codecept