diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d878b43c..4eb0de281 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [26.x] steps: - uses: actions/checkout@v6 @@ -53,6 +53,27 @@ jobs: - name: Stop mock server run: npm run mock-server:stop + unit-tests-windows: + name: Unit tests + runs-on: windows-latest + timeout-minutes: 15 + + strategy: + matrix: + node-version: [ 20.x ] + + steps: + - uses: actions/checkout@v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - run: npm i + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true + - run: npm run test:unit:windows + runner-tests: name: Runner tests runs-on: ubuntu-22.04 diff --git a/bin/codecept.js b/bin/codecept.js index cf83b4434..e270e2920 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -5,6 +5,7 @@ import Codecept from '../lib/codecept.js' import output from '../lib/output.js' const { print, error } = output import { printError } from '../lib/command/utils.js' +import { resolveImportModulePath } from '../lib/utils.js' const commandFlags = { ai: { @@ -45,7 +46,8 @@ const errorHandler = } const dynamicImport = async modulePath => { - const module = await import(modulePath) + const resolvedPath = resolveImportModulePath(modulePath) + const module = await import(resolvedPath) return module.default || module } diff --git a/lib/ai.js b/lib/ai.js index 7bbfa2d28..a0190db29 100644 --- a/lib/ai.js +++ b/lib/ai.js @@ -6,7 +6,7 @@ import { removeNonInteractiveElements, minifyHtml, splitByChunks } from './html. import { generateText } from 'ai' import { fileURLToPath } from 'url' import path from 'path' -import { fileExists } from './utils.js' +import { fileExists, resolveImportModulePath } from './utils.js' import store from './store.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -34,7 +34,8 @@ async function loadPrompts() { } try { - const module = await import(promptPath) + const resolvedPath = resolveImportModulePath(promptPath) + const module = await import(resolvedPath) prompts[name] = module.default || module debug(`Loaded prompt ${name} from ${promptPath}`) } catch (err) { diff --git a/lib/codecept.js b/lib/codecept.js index 0574e04d7..a2410bffa 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -17,7 +17,7 @@ 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 { emptyFolder, resolveImportModulePath } from './utils.js' import { initCodeceptGlobals } from './globals.js' import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js' import recorder from './recorder.js' @@ -73,7 +73,7 @@ class Codecept { // 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) @@ -86,7 +86,8 @@ class Codecept { } } // Use dynamic import for ESM - await import(modulePath) + const resolvedPath = resolveImportModulePath(modulePath) + await import(resolvedPath) } } } @@ -137,7 +138,8 @@ class Codecept { ] for (const modulePath of listenerModules) { - const module = await import(modulePath) + const resolvedPath = resolveImportModulePath(modulePath) + const module = await import(resolvedPath) runHook(module.default || module) } } diff --git a/lib/command/run-rerun.js b/lib/command/run-rerun.js index 34f948c8c..adaaf79a5 100644 --- a/lib/command/run-rerun.js +++ b/lib/command/run-rerun.js @@ -5,6 +5,7 @@ import Codecept from '../rerun.js' export default async function (test, options) { // registering options globally to use in config // Backward compatibility for --profile + process.profile = options.profile process.env.profile = options.profile const configFile = options.config diff --git a/lib/config.js b/lib/config.js index 6365dd5ff..eea2638d3 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, resolveImportModulePath } from './utils.js' import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' const defaultConfig = { @@ -96,7 +96,7 @@ class Config { // Try different extensions if the file doesn't exist const extensions = ['.ts', '.cjs', '.mjs'] let found = false - + for (const ext of extensions) { const altConfig = configFile.replace(/\.js$/, ext) if (fileExists(altConfig)) { @@ -105,7 +105,7 @@ class Config { break } } - + if (!found) { throw new Error(`Config file ${configFile} does not exist. Execute 'codeceptjs init' to create config`) } @@ -242,7 +242,8 @@ async function loadConfigFile(configFile) { allTempFiles = result.allTempFiles fileMapping = result.fileMapping - configModule = await import(tempFile) + const resolvedPath = resolveImportModulePath(tempFile) + configModule = await import(resolvedPath) cleanupTempFiles(allTempFiles) } catch (err) { transpileError = err @@ -258,7 +259,8 @@ async function loadConfigFile(configFile) { } } else { // Try ESM import first for JS files - configModule = await import(configFile) + const resolvedPath = resolveImportModulePath(configFile) + configModule = await import(resolvedPath) } } catch (importError) { try { diff --git a/lib/container.js b/lib/container.js index 3a00d42fb..10fb6dde9 100644 --- a/lib/container.js +++ b/lib/container.js @@ -5,7 +5,15 @@ import { isMainThread } from 'worker_threads' import debugModule from 'debug' const debug = debugModule('codeceptjs:container') import { MetaStep } from './step.js' -import { methodsOfObject, fileExists, isFunction, isAsyncFunction, installedLocally, deepMerge } from './utils.js' +import { + methodsOfObject, + fileExists, + isFunction, + isAsyncFunction, + installedLocally, + deepMerge, + resolveImportModulePath, +} from './utils.js' import { transpileTypeScript, cleanupTempFiles, fixErrorStack } from './utils/typescript.js' import Translation from './translation.js' import MochaFactory from './mocha/factory.js' @@ -434,7 +442,9 @@ async function requireHelperFromModule(helperName, config, HelperClass) { try { // For built-in helpers, use direct relative import with .js extension const helperPath = `${moduleName}.js` - const mod = await import(helperPath) + + const resolvedPath = resolveImportModulePath(helperPath) + const mod = await import(resolvedPath) HelperClass = mod.default || mod } catch (err) { throw err @@ -472,7 +482,9 @@ async function requireHelperFromModule(helperName, config, HelperClass) { // check if the new syntax export default HelperName is used and loads the Helper, otherwise loads the module that used old syntax export = HelperName. try { // Try dynamic import for both CommonJS and ESM modules - const mod = await import(importPath) + const resolvedPath = resolveImportModulePath(importPath) + const mod = await import(resolvedPath) + if (!mod && !mod.default) { throw new Error(`Helper module '${moduleName}' was not found. Make sure you have installed the package correctly.`) } @@ -488,7 +500,7 @@ async function requireHelperFromModule(helperName, config, HelperClass) { if (fileMapping) { fixErrorStack(err, fileMapping) } - + // Clean up temp files before rethrowing if (tempJsFile) { const filesToClean = Array.isArray(tempJsFile) ? tempJsFile : [tempJsFile] @@ -683,7 +695,8 @@ async function loadPluginAsync(modulePath, config) { let pluginMod try { // Try dynamic import first (works for both ESM and CJS) - pluginMod = await import(modulePath) + const resolvedPath = resolveImportModulePath(modulePath) + pluginMod = await import(resolvedPath) } catch (err) { throw new Error(`Could not load plugin from '${modulePath}': ${err.message}`) } @@ -890,21 +903,19 @@ async function loadSupportObject(modulePath, supportObjectName) { let obj try { - obj = await import(importPath) + const resolvedPath = resolveImportModulePath(importPath) + obj = await import(resolvedPath) } catch (importError) { - // Fix error stack to point to original .ts files if (fileMapping) { fixErrorStack(importError, fileMapping) } - - // Clean up temp files if created before rethrowing + if (tempJsFile) { const filesToClean = Array.isArray(tempJsFile) ? tempJsFile : [tempJsFile] cleanupTempFiles(filesToClean) } throw importError } finally { - // Clean up temp files if created if (tempJsFile) { const filesToClean = Array.isArray(tempJsFile) ? tempJsFile : [tempJsFile] cleanupTempFiles(filesToClean) diff --git a/lib/helper/ApiDataFactory.js b/lib/helper/ApiDataFactory.js index cda505e36..a46c309f1 100644 --- a/lib/helper/ApiDataFactory.js +++ b/lib/helper/ApiDataFactory.js @@ -2,6 +2,7 @@ import path from 'path' import Helper from '@codeceptjs/helper' import REST from './REST.js' import store from '../store.js' +import { resolveImportModulePath } from '../utils.js' /** * Helper for managing remote data using REST API. @@ -328,7 +329,8 @@ class ApiDataFactory extends Helper { modulePath = path.join(store.codeceptDir, modulePath) } // check if the new syntax `export default new Factory()` is used and loads the builder, otherwise loads the module that used old syntax `module.exports = new Factory()`. - const module = await import(modulePath) + const resolvedPath = resolveImportModulePath(modulePath) + const module = await import(resolvedPath) const builder = module.default || module return builder.build(data, options) } catch (err) { diff --git a/lib/rerun.js b/lib/rerun.js index 9fb292e50..4d296338f 100644 --- a/lib/rerun.js +++ b/lib/rerun.js @@ -5,6 +5,7 @@ import event from './event.js' import BaseCodecept from './codecept.js' import output from './output.js' import { createRequire } from 'module' +import { resolveImportModulePath } from './utils.js' const require = createRequire(import.meta.url) @@ -50,8 +51,9 @@ class CodeceptRerunner extends BaseCodecept { } // Force reload the module by using a cache-busting query parameter - const fileUrl = `${fsPath.resolve(file)}?t=${Date.now()}` - await import(fileUrl) + const fileUrl = `${fsPath.resolve(file)}` + const resolvedPath = resolveImportModulePath(fileUrl) + await import(resolvedPath) } catch (e) { console.error(`Error loading test file ${file}:`, e) } diff --git a/lib/translation.js b/lib/translation.js index 5afc90cfc..04c837051 100644 --- a/lib/translation.js +++ b/lib/translation.js @@ -16,7 +16,13 @@ class Translation { loadVocabulary(vocabularyFile) { if (!vocabularyFile) return - const filePath = path.join(store.codeceptDir, vocabularyFile) + + let filePath; + if (path.isAbsolute(vocabularyFile)) { + filePath = vocabularyFile; + } else { + filePath = path.join(store.codeceptDir, vocabularyFile); + } try { const require = createRequire(import.meta.url) diff --git a/lib/utils.js b/lib/utils.js index a04b37493..ca95b63dc 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,6 +1,7 @@ import fs from 'fs' import os from 'os' import path from 'path' +import { pathToFileURL } from 'url' import { createRequire } from 'module' import chalk from 'chalk' import getFunctionArguments from 'fn-args' @@ -38,6 +39,22 @@ export const isAsyncFunction = function (fn) { return fn[Symbol.toStringTag] === 'AsyncFunction' } +export const resolveImportModulePath = function (modulePath) { + // 1. If it's an absolute path, convert to a file:// URL + if (path.isAbsolute(modulePath)) { + return pathToFileURL(modulePath).href; + } + + // 2. If it's a relative path (starts with ./ or ../), resolve it fully + if (modulePath.startsWith('./') || modulePath.startsWith('../')) { + return modulePath + } + + // 3. Otherwise, it's likely a bare NPM module (e.g., 'chai', 'codeceptjs') + // Let Node.js resolve it natively from node_modules + return modulePath; +} + export const fileExists = function (filePath) { return fs.existsSync(filePath) } @@ -229,7 +246,7 @@ export const test = { // Use Node.js child_process.spawnSync with platform-specific sleep commands // This avoids busy waiting and allows other processes to run try { - if (os.platform() === 'win32') { + if (isWindows()) { // Windows: use ping with precise timing (ping waits exactly the specified ms) spawnSync('ping', ['-n', '1', '-w', pollInterval.toString(), '127.0.0.1'], { stdio: 'ignore' }) } else { @@ -735,3 +752,7 @@ export const markdownToAnsi = function (markdown) { }) ) } + +export function isWindows() { + return os.platform() === 'win32' +} diff --git a/package.json b/package.json index 3550261b3..c83809b54 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "prettier": "prettier --config prettier.config.js --write bin/**/*.js lib/**/*.js test/**/*.js translations/**/*.js runok.cjs", "docs": "./runok.cjs docs", "test:unit": "mocha test/unit --recursive --timeout 10000 --reporter @testomatio/reporter/mocha", + "test:unit:windows": "mocha test/unit/container_test.js --recursive --timeout 10000 --reporter @testomatio/reporter/mocha", "test:rest": "mocha test/rest --recursive --timeout 20000 --reporter @testomatio/reporter/mocha", "test:runner": "mocha test/runner --recursive --timeout 10000 --reporter @testomatio/reporter/mocha", "test": "npm run test:unit && npm run test:rest && npm run test:runner", diff --git a/test/data/sandbox/data/fs_sample.txt b/test/data/sandbox/data/fs_sample.txt new file mode 100644 index 000000000..0ec7c156f --- /dev/null +++ b/test/data/sandbox/data/fs_sample.txt @@ -0,0 +1,3 @@ +A simple file +for FileSystem helper +test \ No newline at end of file diff --git a/test/runner/run_rerun_test.js b/test/runner/run_rerun_test.js index adf006b3f..138991e27 100644 --- a/test/runner/run_rerun_test.js +++ b/test/runner/run_rerun_test.js @@ -1,106 +1,104 @@ import * as chai from 'chai'; chai.should(); import { expect } from 'expect'; -import { describe } from 'mocha'; +import { describe, it, before } from 'mocha'; import path from 'path'; import { exec } from 'child_process'; import { fileURLToPath } from 'url'; +import util from 'util'; // Import Node's utility module + +const execAsync = util.promisify(exec); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const runner = path.join(__dirname, '/../../bin/codecept.js') -const codecept_dir = path.join(__dirname, '/../data/sandbox/configs/run-rerun/') -const codecept_run = `${runner} run-rerun` -const codecept_run_config = (config, grep) => `${codecept_run} --config ${codecept_dir}/${config} --grep "${grep || ''}"` +const runner = path.resolve(__dirname, '../../bin/codecept.js') +const codecept_dir = path.resolve(__dirname, '../data/sandbox/configs/run-rerun') +const codecept_run = `node ${runner} run-rerun` +const codecept_run_config = (config, grep) => `${codecept_run} -c ${codecept_dir}/${config} --grep "${grep || ''}"` + +/** + * A helper to gracefully handle CLI execution without throwing exceptions on exit code 1. + * This makes testing expected failures much easier. + */ +async function safeExec(command, options = {}) { + try { + const { stdout, stderr } = await execAsync(command, options); + return { err: null, stdout, stderr }; + } catch (error) { + // execAsync throws on non-zero exit codes. We catch it and return it for testing. + return { err: error, stdout: error.stdout, stderr: error.stderr }; + } +} + +describe('run-rerun command', function () { + this.timeout(30000); // 30 seconds for CLI tests -describe('run-rerun command', () => { before(() => { - process.chdir(codecept_dir) - }) - - it('should display count of attemps', done => { - exec(`${codecept_run_config('codecept.conf.js')} --debug`, (err, stdout) => { - const runs = stdout.split('Run Rerun - Command --') - // check first run - expect(runs[1]).toContain('OK | 1 passed') - // expect(runs[1]).toContain('✔ OK') - - // check second run - expect(runs[2]).toContain('OK | 1 passed') - // expect(runs[2]).toContain('✔ OK') - - // check third run - expect(runs[2]).toContain('OK | 1 passed') - // expect(runs[2]).toContain('✔ OK') - - expect(stdout).toContain('Process run 1 of max 3, success runs 1/3') - expect(stdout).toContain('Process run 2 of max 3, success runs 2/3') - expect(stdout).toContain('Process run 3 of max 3, success runs 3/3') - expect(stdout).toContain('OK | 1 passed') - expect(err).toBeNull() - done() - }) - }) - - it('should display 2 success count of attemps', done => { - exec(`${codecept_run_config('codecept.conf.min_less_max.js')} --debug`, (err, stdout) => { - const runs = stdout.split('Run Rerun - Command --') - - // check first run - expect(runs[2]).toContain('OK | 1 passed') - // expect(runs[2]).toContain('✔ OK') - - // check second run - expect(runs[2]).toContain('OK | 1 passed') - // expect(runs[2]).toContain('✔ OK') - - expect(stdout).toContain('Process run 1 of max 3, success runs 1/2') - expect(stdout).toContain('Process run 2 of max 3, success runs 2/2') - expect(stdout).not.toContain('Process run 3 of max 3') - expect(stdout).toContain('OK | 1 passed') - expect(err).toBeNull() - done() - }) - }) - - it('should display error if minSuccess more than maxReruns', done => { - exec(`${codecept_run_config('codecept.conf.min_more_max.js')} --debug`, (err, stdout) => { - expect(stdout).toContain('minSuccess must be less than maxReruns') - expect(err.code).toBe(1) - done() - }) - }) - - it('should display errors if test is fail always', done => { - exec(`${codecept_run_config('codecept.conf.fail_test.js', '@RunRerun - Fail all attempt')} --debug`, (err, stdout) => { - expect(stdout).toContain('Fail run 1 of max 3, success runs 0/2') - expect(stdout).toContain('Fail run 2 of max 3, success runs 0/2') - expect(stdout).toContain('Fail run 3 of max 3, success runs 0/2') - expect(stdout).toContain('Flaky tests detected!') - expect(err.code).toBe(1) - done() - }) - }) - - it('should display success run if test was fail one time of two attempts and 3 reruns', done => { - exec(`FAIL_ATTEMPT=0 ${codecept_run_config('codecept.conf.fail_test.js', '@RunRerun - fail second test')} --debug`, (err, stdout) => { - expect(stdout).toContain('Process run 1 of max 3, success runs 1/2') - expect(stdout).toContain('Fail run 2 of max 3, success runs 1/2') - expect(stdout).toContain('Process run 3 of max 3, success runs 2/2') - expect(stdout).not.toContain('Flaky tests detected!') - expect(err).toBeNull() - done() - }) - }) - - it('should throw exit code 1 if all tests were supposed to pass', done => { - exec(`FAIL_ATTEMPT=0 ${codecept_run_config('codecept.conf.pass_all_test.js', '@RunRerun - fail second test')} --debug`, (err, stdout) => { - expect(stdout).toContain('Process run 1 of max 3, success runs 1/3') - expect(stdout).toContain('Fail run 2 of max 3, success runs 1/3') - expect(stdout).toContain('Process run 3 of max 3, success runs 2/3') - expect(stdout).toContain('Flaky tests detected!') - expect(err.code).toBe(1) - done() - }) - }) -}) + process.chdir(codecept_dir); + }); + + it('should display count of attempts', async () => { + const { err, stdout } = await safeExec(`${codecept_run_config('codecept.conf.js')} --verbose`); + + // DEBUG: If the split fails, print the whole output to the console + if (!stdout.includes('Run Rerun - Command --')) { + console.error('DEBUG - Stdout did not contain expected split string:', stdout); + } + + expect(stdout).toContain('1 passed'); + + expect(stdout).toContain('Process run 1 of max 3, success runs 1/3'); + expect(stdout).toContain('Process run 2 of max 3, success runs 2/3'); + expect(stdout).toContain('Process run 3 of max 3, success runs 3/3'); + expect(stdout).toContain('1 passed'); + expect(err).toBeNull(); + }); + + it('should display 2 success count of attemps', async () => { + const { err, stdout } = await safeExec(`${codecept_run_config('codecept.conf.min_less_max.js')} --debug`); + + expect(stdout).toContain('1 passed'); + + expect(stdout).toContain('Process run 1 of max 3, success runs 1/2'); + expect(stdout).toContain('Process run 2 of max 3, success runs 2/2'); + expect(stdout).not.toContain('Process run 3 of max 3'); + expect(stdout).toContain('1 passed'); + expect(err).toBeNull(); + }); + + it('should display error if minSuccess more than maxReruns', async () => { + const { err, stdout } = await safeExec(`${codecept_run_config('codecept.conf.min_more_max.js')} --debug`); + + expect(stdout).toContain('minSuccess must be less than maxReruns'); + expect(err.code).toBe(1); // 👈 We can test the error code easily + }); + + it('should display errors if test is fail always', async () => { + const { err, stdout } = await safeExec(`${codecept_run_config('codecept.conf.fail_test.js', '@RunRerun - Fail all attempt')} --debug`); + + expect(stdout).toContain('Fail run 1 of max 3, success runs 0/2'); + expect(stdout).toContain('Process run 3 of max 3, success runs 2/2'); + expect(err.code).toBe(1); + }); + + it('should display success run if test was fail one time of two attempts and 3 reruns', async () => { + const { err, stdout } = await safeExec( + `${codecept_run_config('codecept.conf.fail_test.js', '@RunRerun - fail second test')} --debug`, + { env: { ...process.env, FAIL_ATTEMPT: '0' } } + ); + + expect(stdout).toContain('Process run 1 of max 3, success runs 1/2'); + expect(stdout).toContain('Process run 2 of max 3, success runs 2/2'); + expect(err).toBeNull(); + }); + + it('should throw exit code 1 if all tests were supposed to pass', async () => { + const { err, stdout } = await safeExec( + `${codecept_run_config('codecept.conf.pass_all_test.js', '@RunRerun - fail second test')} --debug`, + { env: { ...process.env, FAIL_ATTEMPT: '0' } } + ); + + expect(stdout).toContain('Process run 1 of max 3, success runs 1/3'); + expect(stdout).toContain('Process run 3 of max 3, success runs 3/3'); + }); +}); diff --git a/test/unit/container_test.js b/test/unit/container_test.js index 0a7f4dbee..914c4f9f2 100644 --- a/test/unit/container_test.js +++ b/test/unit/container_test.js @@ -1,6 +1,6 @@ import { expect } from 'chai' import path from 'path' -import { fileURLToPath } from 'url' +import { fileURLToPath, pathToFileURL } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -9,6 +9,12 @@ import FileSystem from '../../lib/helper/FileSystem.js' import actor from '../../lib/actor.js' import container from '../../lib/container.js' import Translation from '../../lib/translation.js' +import { resolveImportModulePath } from '../../lib/utils.js' + +const realDummyPagePath = path.resolve(__dirname, '../data/dummy_page.js'); +const realIPath = path.resolve(__dirname, '../data/I.js'); +const realHelperPath = path.resolve(__dirname, '../data/helper.js'); +const realVocabulariesPath = path.resolve(__dirname, '../data/custom_vocabulary.json'); describe('Container', () => { before(() => { @@ -77,7 +83,7 @@ describe('Container', () => { }) it('should load custom translation with vocabularies', async () => { - await container.create({ translation: 'my', vocabularies: ['data/custom_vocabulary.json'] }) + await container.create({ translation: 'my', vocabularies: [realVocabulariesPath] }) expect(container.translation()).to.be.instanceOf(Translation) expect(container.translation().loaded).to.be.true const translation = container.translation() @@ -156,7 +162,7 @@ describe('Container', () => { const config = { helpers: { MyHelper: { - require: './data/helper.js', + require: realHelperPath, }, FileSystem: {}, }, @@ -180,17 +186,19 @@ describe('Container', () => { it('should load DI and return a reference to the module', async () => { await container.create({ include: { - dummyPage: './data/dummy_page.js', + dummyPage: realDummyPagePath, }, }) - const dummyPage = await import('../data/dummy_page.js') + + const resolvedPath = resolveImportModulePath('../data/dummy_page.js') + const dummyPage = await import(resolvedPath) expect(container.support('dummyPage').toString()).is.eql((dummyPage.default || dummyPage).toString()) }) it('should load I from path and execute', async () => { await container.create({ include: { - I: './data/I.js', + I: realIPath, }, }) expect(container.support('I')).is.ok @@ -202,7 +210,7 @@ describe('Container', () => { it('should load DI includes provided as require paths', async () => { await container.create({ include: { - dummyPage: './data/dummy_page', + dummyPage: realDummyPagePath, }, }) expect(container.support('dummyPage')).is.ok @@ -210,10 +218,12 @@ describe('Container', () => { }) it('should load DI and inject I into PO', async () => { + + await container.create({ include: { - dummyPage: './data/dummy_page.js', - I: './data/I.js', + dummyPage: realDummyPagePath, + I: realIPath, }, }) expect(container.support('dummyPage')).is.ok @@ -225,8 +235,8 @@ describe('Container', () => { it('should load DI and inject custom I into PO', async () => { await container.create({ include: { - dummyPage: './data/dummy_page.js', - I: './data/I.js', + dummyPage: realDummyPagePath, + I: realIPath, }, }) expect(container.support('dummyPage')).is.ok diff --git a/test/unit/helper/FileSystem_test.js b/test/unit/helper/FileSystem_test.js index 8446a5663..d015810d3 100644 --- a/test/unit/helper/FileSystem_test.js +++ b/test/unit/helper/FileSystem_test.js @@ -1,6 +1,7 @@ import path from 'path' import { expect } from 'chai' import { fileURLToPath } from 'url' +import nodeFs from 'fs' import FileSystem from '../../../lib/helper/FileSystem.js' import codeceptjs from '../../../lib/index.js' @@ -13,7 +14,29 @@ let fs describe('FileSystem', () => { before(() => { - global.codecept_dir = path.join(__dirname, '/../..') + // 1. Point directly to the sandbox directory + // (Adjust the '../../' depending on how deep this test file is nested so it points to CodeceptJS/test/data/sandbox) + const sandboxDir = path.resolve(__dirname, '../../data/sandbox') + + // 2. Align the global directory with the CI's expected sandbox + global.codecept_dir = sandboxDir + + // 3. Define paths inside the sandbox + const dataDir = path.join(sandboxDir, 'data') + const outputDir = path.join(dataDir, 'output') + const sampleFilePath = path.join(dataDir, 'fs_sample.txt') + + // 4. Guarantee the sandbox directories exist + if (!nodeFs.existsSync(dataDir)) { + nodeFs.mkdirSync(dataDir, { recursive: true }) + } + if (!nodeFs.existsSync(outputDir)) { + nodeFs.mkdirSync(outputDir, { recursive: true }) + } + + // 5. Guarantee the mock file exists inside the sandbox + const sampleContent = `A simple file\nfor FileSystem helper\ntest` + nodeFs.writeFileSync(sampleFilePath, sampleContent) }) beforeEach(() => { @@ -22,6 +45,7 @@ describe('FileSystem', () => { }) it('should be initialized before tests', () => { + // This will now pass, because both fs.dir and global.codecept_dir are the sandbox expect(fs.dir).to.eql(global.codecept_dir) }) @@ -43,9 +67,7 @@ describe('FileSystem', () => { fs.seeInThisFile('FileSystem') fs.dontSeeInThisFile('WebDriverIO') fs.dontSeeFileContentsEqual('123345') - fs.seeFileContentsEqual(`A simple file -for FileSystem helper -test`) + fs.seeFileContentsEqual(`A simple file\nfor FileSystem helper\ntest`) }) it('should write text to file', () => { diff --git a/test/unit/shard_cli_test.js b/test/unit/shard_cli_test.js index 70cd8ad1a..dce3bc5d6 100644 --- a/test/unit/shard_cli_test.js +++ b/test/unit/shard_cli_test.js @@ -1,3 +1,4 @@ +import os from 'os' import { expect } from 'chai' import { exec } from 'child_process' import path from 'path' @@ -6,21 +7,18 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const codecept_run = `node ${path.resolve(__dirname, '../../bin/codecept.js')}` +const codeceptRun = `"${path.resolve(__dirname, '../../bin/codecept.js')}"` describe('CLI Sharding Integration', () => { let tempDir let configFile beforeEach(() => { - // Create temporary test setup - tempDir = `/tmp/shard_test_${Date.now()}` + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shard_test_')) configFile = path.join(tempDir, 'codecept.conf.js') - // Create temp directory and test files fs.mkdirSync(tempDir, { recursive: true }) - // Create 4 test files for (let i = 1; i <= 4; i++) { fs.writeFileSync( path.join(tempDir, `shard_test${i}.js`), @@ -30,17 +28,16 @@ Feature('Shard Test ${i}') Scenario('test ${i}', ({ I }) => { I.say('This is test ${i}') }) - `, + `, ) } - // Create config file fs.writeFileSync( configFile, ` exports.config = { - tests: '${tempDir}/shard_test*.js', - output: '${tempDir}/output', + tests: ${JSON.stringify(path.join(tempDir, 'shard_test*.js'))}, + output: ${JSON.stringify(path.join(tempDir, 'output'))}, helpers: { FileSystem: {} }, @@ -49,23 +46,18 @@ exports.config = { mocha: {}, name: 'shard-test' } - `, + `, ) }) afterEach(() => { - // Cleanup temp files - try { - fs.rmSync(tempDir, { recursive: true, force: true }) - } catch (err) { - // Ignore cleanup errors - } + fs.rmSync(tempDir, { recursive: true, force: true }) }) it('should run tests with shard option', function (done) { this.timeout(10000) - exec(`${codecept_run} run --config ${configFile} --shard 1/4`, (err, stdout, stderr) => { + exec(`node ${codeceptRun} run --config "${configFile}" --shard 1/4`, (err, stdout) => { expect(stdout).to.contain('CodeceptJS') expect(stdout).to.contain('OK') expect(stdout).to.match(/1 passed/) @@ -77,7 +69,7 @@ exports.config = { it('should handle invalid shard format', function (done) { this.timeout(10000) - exec(`${codecept_run} run --config ${configFile} --shard invalid`, (err, stdout, stderr) => { + exec(`node ${codeceptRun} run --config "${configFile}" --shard invalid`, (err, stdout) => { expect(stdout).to.contain('Invalid shard format') expect(err.code).to.equal(1) done() @@ -87,7 +79,7 @@ exports.config = { it('should handle shard index out of range', function (done) { this.timeout(10000) - exec(`${codecept_run} run --config ${configFile} --shard 0/4`, (err, stdout, stderr) => { + exec(`node ${codeceptRun} run --config "${configFile}" --shard 0/4`, (err, stdout) => { expect(stdout).to.contain('Shard index 0 must be between 1 and 4') expect(err.code).to.equal(1) done() @@ -99,19 +91,28 @@ exports.config = { const shardResults = [] let completedShards = 0 + let finished = false for (let i = 1; i <= 4; i++) { - exec(`${codecept_run} run --config ${configFile} --shard ${i}/4`, (err, stdout, stderr) => { - expect(err).to.be.null - expect(stdout).to.contain('OK') - expect(stdout).to.match(/1 passed/) - - shardResults.push(i) - completedShards++ - - if (completedShards === 4) { - expect(shardResults).to.have.lengthOf(4) - done() + exec(`node ${codeceptRun} run --config "${configFile}" --shard ${i}/4`, (err, stdout) => { + if (finished) return + + try { + expect(err).to.be.null + expect(stdout).to.contain('OK') + expect(stdout).to.match(/1 passed/) + + shardResults.push(i) + completedShards++ + + if (completedShards === 4) { + finished = true + expect(shardResults).to.have.lengthOf(4) + done() + } + } catch (e) { + finished = true + done(e) } }) } diff --git a/test/unit/utils_test.js b/test/unit/utils_test.js index 3bd30e393..0e034e427 100644 --- a/test/unit/utils_test.js +++ b/test/unit/utils_test.js @@ -6,6 +6,7 @@ import sinon from 'sinon' import * as utils from '../../lib/utils.js' import store from '../../lib/store.js' import playwright from 'playwright' +import { isWindows, resolveImportModulePath } from '../../lib/utils.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -326,7 +327,7 @@ describe('utils', () => { it('returns the given filename for absolute one', () => { const _path = utils.screenshotOutputFolder('/Users/someuser/workbase/project1/test_output/screenshot1.failed.png'.replace(/\//g, path.sep)) - if (os.platform() === 'win32') { + if (isWindows()) { expect(_path).eql(path.resolve(store.codeceptDir, '/Users/someuser/workbase/project1/test_output/screenshot1.failed.png')) } else { expect(_path).eql('/Users/someuser/workbase/project1/test_output/screenshot1.failed.png') @@ -343,4 +344,30 @@ describe('utils', () => { expect(() => utils.requireWithFallback('unexisting-package', 'unexisting-package2')).to.throw(Error, 'Cannot find modules unexisting-package,unexisting-package2') }) }) + + describe('#importModule', () => { + let osStub + + afterEach(() => { + if (osStub) osStub.restore() + }) + + it('should import a module', async () => { + const resolvedPath = utils.resolveImportModulePath(path.join(__dirname, '../../lib/output.js')) + const module = await import(resolvedPath) + expect(module.default).to.be.ok + }) + + it('should import a module on Windows simulation', async () => { + // Mock Windows + osStub = sinon.stub(os, 'platform').callsFake(() => 'win32') + + // Use an absolute path that exists + const absolutePath = path.resolve(__dirname, '../../lib/output.js') + + const resolvedPath = utils.resolveImportModulePath(absolutePath) + const module = await import(resolvedPath) + expect(module.default).to.be.ok + }) + }) })