diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae84f5304..6ac6e3d7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,27 +9,42 @@ on: - '**' jobs: - build: + unit-tests: + name: Unit tests + runs-on: ubuntu-22.04 + + strategy: + matrix: + node-version: [20.x, 22.x] + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + 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 + runner-tests: + name: Runner tests runs-on: ubuntu-22.04 strategy: matrix: - node-version: [20.x] + node-version: [20.x, 22.x] steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: npm i --force - env: - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true - PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true - - uses: nick-fields/retry@v3 - with: - timeout_minutes: 6 - max_attempts: 3 - retry_on: error - command: npm test + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm i + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: true + - run: npm run test:runner diff --git a/lib/codecept.js b/lib/codecept.js index b924425ee..f0626cbcc 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -111,6 +111,7 @@ class Codecept { runHook(require('./listener/globalTimeout')) runHook(require('./listener/globalRetry')) runHook(require('./listener/exit')) + runHook(require('./listener/emptyRun')) // custom hooks (previous iteration of plugins) this.config.hooks.forEach(hook => runHook(hook)) diff --git a/lib/listener/emptyRun.js b/lib/listener/emptyRun.js new file mode 100644 index 000000000..2fa8af341 --- /dev/null +++ b/lib/listener/emptyRun.js @@ -0,0 +1,58 @@ +const figures = require('figures') +const Container = require('../container') +const event = require('../event') +const output = require('../output') + +module.exports = function () { + let isEmptyRun = true + + event.dispatcher.on(event.test.before, test => { + isEmptyRun = false + }) + + event.dispatcher.on(event.all.result, () => { + if (isEmptyRun) { + const mocha = Container.mocha() + + if (mocha.options.grep) { + const Fuse = require('fuse.js') + + output.print() + output.print('No tests found by pattern: ' + mocha.options.grep) + + const allTests = [] + mocha.suite.suites.forEach(suite => { + suite.tests.forEach(test => { + allTests.push(test.fullTitle()) + }) + }) + + const fuse = new Fuse(allTests, { + includeScore: true, + threshold: 0.6, + caseSensitive: false, + }) + + const results = fuse.search(mocha.options.grep.toString()) + + if (results.length > 0) { + output.print() + output.print('Maybe you wanted to run one of these tests?') + results.forEach(result => { + output.print(figures.checkboxOff, output.styles.log(result.item)) + }) + + output.print() + output.print(output.styles.debug('To run the first test use the following command:')) + output.print(output.styles.bold('npx codeceptjs run --debug --grep "' + results[0].item + '"')) + } + } + if (process.env.CI && !process.env.DONT_FAIL_ON_EMPTY_RUN) { + output.print() + output.error('No tests were executed. Failing on CI to avoid false positives') + output.error('To disable this check, set `DONT_FAIL_ON_EMPTY_RUN` environment variable to true in CI config') + process.exitCode = 1 + } + } + }) +} diff --git a/lib/mocha/factory.js b/lib/mocha/factory.js index d5ef1444e..3ae4b5613 100644 --- a/lib/mocha/factory.js +++ b/lib/mocha/factory.js @@ -4,7 +4,6 @@ const fs = require('fs') const reporter = require('./cli') const gherkinParser = require('./gherkin') const output = require('../output') -const { genTestId } = require('../utils') const ConnectionRefused = require('../helper/errors/ConnectionRefused') const scenarioUi = fsPath.join(__dirname, './ui.js') @@ -45,8 +44,6 @@ class MochaFactory { let missingFeatureInFile = [] const seenTests = [] mocha.suite.eachTest(test => { - test.uid = genTestId(test) - const name = test.fullTitle() if (seenTests.includes(test.uid)) { dupes.push(name) diff --git a/lib/mocha/test.js b/lib/mocha/test.js index 836f1f6cf..48908a308 100644 --- a/lib/mocha/test.js +++ b/lib/mocha/test.js @@ -1,6 +1,7 @@ const Test = require('mocha/lib/test') const { test: testWrapper } = require('./asyncWrapper') const { enhanceMochaSuite } = require('./suite') +const { genTestId } = require('../utils') /** * Factory function to create enhanced tests @@ -40,6 +41,7 @@ function enhanceMochaTest(test) { suite.addTest(testWrapper(this)) test.tags = [...(test.tags || []), ...(suite.tags || [])] test.fullTitle = () => `${suite.title}: ${test.title}` + test.uid = genTestId(test) } test.applyOptions = function (opts) { diff --git a/lib/mocha/types.d.ts b/lib/mocha/types.d.ts index 36e3e72ce..50c51e2ef 100644 --- a/lib/mocha/types.d.ts +++ b/lib/mocha/types.d.ts @@ -3,6 +3,7 @@ import { Test as MochaTest, Suite as MochaSuite } from 'mocha' declare global { namespace CodeceptJS { interface Test extends MochaTest { + uid: string title: string tags: string[] steps: string[] diff --git a/lib/pause.js b/lib/pause.js index 65733ecff..b4bbf670b 100644 --- a/lib/pause.js +++ b/lib/pause.js @@ -2,6 +2,7 @@ const colors = require('chalk') const readline = require('readline') const ora = require('ora-classic') const debug = require('debug')('codeceptjs:pause') +const Fuse = require('fuse.js') const container = require('./container') const history = require('./history') @@ -29,10 +30,20 @@ const pause = function (passedObject = {}) { // add listener to all next steps to provide next() functionality event.dispatcher.on(event.step.after, () => { recorder.add('Start next pause session', () => { + // test already finished, nothing to pause + if (!store.currentTest) return if (!next) return return pauseSession() }) }) + + event.dispatcher.on(event.test.finished, () => { + finish() + recorder.session.restore('pause') + rl.close() + history.save() + }) + recorder.add('Start new session', () => pauseSession(passedObject)) } @@ -72,10 +83,12 @@ function pauseSession(passedObject = {}) { }) return new Promise(resolve => { finish = resolve + // eslint-disable-next-line return askForStep() }) } +/* eslint-disable */ async function parseInput(cmd) { rl.pause() next = false @@ -83,7 +96,7 @@ async function parseInput(cmd) { if (cmd === '') next = true if (!cmd || cmd === 'resume' || cmd === 'exit') { finish() - recorder.session.restore() + recorder.session.restore('pause') rl.close() history.save() return nextStep() @@ -184,6 +197,7 @@ async function parseInput(cmd) { recorder.add('ask for next step', askForStep) nextStep() } +/* eslint-enable */ function askForStep() { return new Promise(resolve => { @@ -197,13 +211,23 @@ function askForStep() { function completer(line) { const I = container.support('I') const completions = methodsOfObject(I) - const hits = completions.filter(c => { - if (c.indexOf(line) === 0) { - return c - } - return null + // If no input, return all completions + if (!line) { + return [completions, line] + } + + // Initialize Fuse with completions + const fuse = new Fuse(completions, { + threshold: 0.3, + distance: 100, + minMatchCharLength: 1, }) - return [hits && hits.length ? hits : completions, line] + + // Search using Fuse.js + const searchResults = fuse.search(line) + const hits = searchResults.map(result => result.item) + + return [hits, line] } function registerVariable(name, value) { diff --git a/lib/recorder.js b/lib/recorder.js index e3fb80a4b..40db146c7 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -1,27 +1,27 @@ -const debug = require('debug')('codeceptjs:recorder'); -const promiseRetry = require('promise-retry'); -const chalk = require('chalk'); -const { printObjectProperties } = require('./utils'); -const { log } = require('./output'); +const debug = require('debug')('codeceptjs:recorder') +const promiseRetry = require('promise-retry') +const chalk = require('chalk') +const { printObjectProperties } = require('./utils') +const { log } = require('./output') -const MAX_TASKS = 100; +const MAX_TASKS = 100 -let promise; -let running = false; -let errFn; -let queueId = 0; -let sessionId = null; -let asyncErr = null; -let ignoredErrs = []; +let promise +let running = false +let errFn +let queueId = 0 +let sessionId = null +let asyncErr = null +let ignoredErrs = [] -let tasks = []; -let oldPromises = []; +let tasks = [] +let oldPromises = [] const defaultRetryOptions = { retries: 0, minTimeout: 150, maxTimeout: 10000, -}; +} /** * Singleton object to record all test steps as promises and run them in chain. @@ -29,7 +29,6 @@ const defaultRetryOptions = { * @interface */ module.exports = { - /** * @type {Array>} * @inner @@ -43,11 +42,11 @@ module.exports = { * @inner */ start() { - debug('Starting recording promises'); - running = true; - asyncErr = null; - errFn = null; - this.reset(); + debug('Starting recording promises') + running = true + asyncErr = null + errFn = null + this.reset() }, /** @@ -55,7 +54,7 @@ module.exports = { * @inner */ isRunning() { - return running; + return running }, /** @@ -64,7 +63,7 @@ module.exports = { */ startUnlessRunning() { if (!this.isRunning()) { - this.start(); + this.start() } }, @@ -76,7 +75,7 @@ module.exports = { * @inner */ errHandler(fn) { - errFn = fn; + errFn = fn }, /** @@ -87,16 +86,16 @@ module.exports = { * @inner */ reset() { - if (promise && running) this.catch(); - queueId++; - sessionId = null; - asyncErr = null; - log(`${currentQueue()} Starting recording promises`); - promise = Promise.resolve(); - oldPromises = []; - tasks = []; - ignoredErrs = []; - this.session.running = false; + if (promise && running) this.catch() + queueId++ + sessionId = null + asyncErr = null + log(`${currentQueue()} Starting recording promises`) + promise = Promise.resolve() + oldPromises = [] + tasks = [] + ignoredErrs = [] + this.session.running = false // reset this retries makes the retryFailedStep plugin won't work if there is Before/BeforeSuit block due to retries is undefined on Scenario // this.retries = []; }, @@ -123,12 +122,16 @@ module.exports = { * @inner */ start(name) { - debug(`${currentQueue()}Starting <${name}> session`); - tasks.push('--->'); - oldPromises.push(promise); - this.running = true; - sessionId = name; - promise = Promise.resolve(); + if (sessionId) { + debug(`${currentQueue()}Session already started as ${sessionId}`) + this.restore(sessionId) + } + debug(`${currentQueue()}Starting <${name}> session`) + tasks.push('--->') + oldPromises.push(promise) + this.running = true + sessionId = name + promise = Promise.resolve() }, /** @@ -136,12 +139,12 @@ module.exports = { * @inner */ restore(name) { - tasks.push('<---'); - debug(`${currentQueue()}Finalize <${name}> session`); - this.running = false; - sessionId = null; - this.catch(errFn); - promise = promise.then(() => oldPromises.pop()); + tasks.push('<---') + debug(`${currentQueue()}Finalize <${name}> session`) + this.running = false + sessionId = null + this.catch(errFn) + promise = promise.then(() => oldPromises.pop()) }, /** @@ -149,9 +152,8 @@ module.exports = { * @inner */ catch(fn) { - promise = promise.catch(fn); + promise = promise.catch(fn) }, - }, /** @@ -171,42 +173,47 @@ module.exports = { */ add(taskName, fn = undefined, force = false, retry = undefined, timeout = undefined) { if (typeof taskName === 'function') { - fn = taskName; - taskName = fn.toString(); - if (retry === undefined) retry = false; + fn = taskName + taskName = fn.toString() + if (retry === undefined) retry = false } - if (retry === undefined) retry = true; + if (retry === undefined) retry = true if (!running && !force) { - return; + return } - tasks.push(taskName); - debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`)); + tasks.push(taskName) + debug(chalk.gray(`${currentQueue()} Queued | ${taskName}`)) - return promise = Promise.resolve(promise).then((res) => { + return (promise = Promise.resolve(promise).then(res => { // prefer options for non-conditional retries - const retryOpts = this.retries.sort((r1, r2) => r1.when && !r2.when).slice(-1).pop(); + const retryOpts = this.retries + .sort((r1, r2) => r1.when && !r2.when) + .slice(-1) + .pop() // no retries or unnamed tasks if (!retryOpts || !taskName || !retry) { - const [promise, timer] = getTimeoutPromise(timeout, taskName); - return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer)); + const [promise, timer] = getTimeoutPromise(timeout, taskName) + return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer)) } - debug(`${currentQueue()} Running | ${taskName}`); + debug(`${currentQueue()} Running | ${taskName}`) - const retryRules = this.retries.slice().reverse(); + const retryRules = this.retries.slice().reverse() return promiseRetry(Object.assign(defaultRetryOptions, retryOpts), (retry, number) => { - if (number > 1) log(`${currentQueue()}Retrying... Attempt #${number}`); - const [promise, timer] = getTimeoutPromise(timeout, taskName); - return Promise.race([promise, Promise.resolve(res).then(fn)]).finally(() => clearTimeout(timer)).catch((err) => { - if (ignoredErrs.includes(err)) return; - for (const retryObj of retryRules) { - if (!retryObj.when) return retry(err); - if (retryObj.when && retryObj.when(err)) return retry(err); - } - throw err; - }); - }); - }); + if (number > 1) log(`${currentQueue()}Retrying... Attempt #${number}`) + const [promise, timer] = getTimeoutPromise(timeout, taskName) + return Promise.race([promise, Promise.resolve(res).then(fn)]) + .finally(() => clearTimeout(timer)) + .catch(err => { + if (ignoredErrs.includes(err)) return + for (const retryObj of retryRules) { + if (!retryObj.when) return retry(err) + if (retryObj.when && retryObj.when(err)) return retry(err) + } + throw err + }) + }) + })) }, /** @@ -215,15 +222,15 @@ module.exports = { * @inner */ retry(opts) { - if (!promise) return; + if (!promise) return if (opts === null) { - opts = {}; + opts = {} } if (Number.isInteger(opts)) { - opts = { retries: opts }; + opts = { retries: opts } } - return this.add(() => this.retries.push(opts)); + return this.add(() => this.retries.push(opts)) }, /** @@ -232,20 +239,25 @@ module.exports = { * @inner */ catch(customErrFn) { - const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50); - debug(chalk.gray(`${currentQueue()} Queued | catch with error handler ${fnDescription || ''}`)); - return promise = promise.catch((err) => { - log(`${currentQueue()}Error | ${err} ${fnDescription}...`); - if (!(err instanceof Error)) { // strange things may happen - err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`); // we should be prepared for them + const fnDescription = customErrFn + ?.toString() + ?.replace(/\s{2,}/g, ' ') + .replace(/\n/g, ' ') + ?.slice(0, 50) + debug(chalk.gray(`${currentQueue()} Queued | catch with error handler ${fnDescription || ''}`)) + return (promise = promise.catch(err => { + log(`${currentQueue()}Error | ${err} ${fnDescription}...`) + if (!(err instanceof Error)) { + // strange things may happen + err = new Error(`[Wrapped Error] ${printObjectProperties(err)}`) // we should be prepared for them } if (customErrFn) { - customErrFn(err); + customErrFn(err) } else if (errFn) { - errFn(err); + errFn(err) } - this.stop(); - }); + this.stop() + })) }, /** @@ -254,17 +266,22 @@ module.exports = { * @inner */ catchWithoutStop(customErrFn) { - const fnDescription = customErrFn?.toString()?.replace(/\s{2,}/g, ' ').replace(/\n/g, ' ')?.slice(0, 50); - return promise = promise.catch((err) => { - if (ignoredErrs.includes(err)) return; // already caught - log(`${currentQueue()} Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`); - if (!(err instanceof Error)) { // strange things may happen - err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`); // we should be prepared for them + const fnDescription = customErrFn + ?.toString() + ?.replace(/\s{2,}/g, ' ') + .replace(/\n/g, ' ') + ?.slice(0, 50) + return (promise = promise.catch(err => { + if (ignoredErrs.includes(err)) return // already caught + log(`${currentQueue()} Error (Non-Terminated) | ${err} | ${fnDescription || ''}...`) + if (!(err instanceof Error)) { + // strange things may happen + err = new Error(`[Wrapped Error] ${JSON.stringify(err)}`) // we should be prepared for them } if (customErrFn) { - return customErrFn(err); + return customErrFn(err) } - }); + })) }, /** @@ -276,15 +293,15 @@ module.exports = { */ throw(err) { - if (ignoredErrs.includes(err)) return promise; // already caught + if (ignoredErrs.includes(err)) return promise // already caught return this.add(`throw error: ${err.message}`, () => { - if (ignoredErrs.includes(err)) return; // already caught - throw err; - }); + if (ignoredErrs.includes(err)) return // already caught + throw err + }) }, ignoreErr(err) { - ignoredErrs.push(err); + ignoredErrs.push(err) }, /** @@ -293,7 +310,7 @@ module.exports = { */ saveFirstAsyncError(err) { if (asyncErr === null) { - asyncErr = err; + asyncErr = err } }, @@ -302,7 +319,7 @@ module.exports = { * @inner */ getAsyncErr() { - return asyncErr; + return asyncErr }, /** @@ -310,7 +327,7 @@ module.exports = { * @inner */ cleanAsyncErr() { - asyncErr = null; + asyncErr = null }, /** @@ -319,9 +336,9 @@ module.exports = { * @inner */ stop() { - debug(this.toString()); - log(`${currentQueue()} Stopping recording promises`); - running = false; + debug(this.toString()) + log(`${currentQueue()} Stopping recording promises`) + running = false }, /** @@ -332,7 +349,7 @@ module.exports = { * @inner */ promise() { - return promise; + return promise }, /** @@ -341,7 +358,7 @@ module.exports = { * @inner */ scheduled() { - return tasks.slice(-MAX_TASKS).join('\n'); + return tasks.slice(-MAX_TASKS).join('\n') }, /** @@ -350,7 +367,7 @@ module.exports = { * @inner */ getQueueId() { - return queueId; + return queueId }, /** @@ -359,21 +376,25 @@ module.exports = { * @inner */ toString() { - return `Queue: ${currentQueue()}\n\nTasks: ${this.scheduled()}`; + return `Queue: ${currentQueue()}\n\nTasks: ${this.scheduled()}` }, - -}; +} function getTimeoutPromise(timeoutMs, taskName) { - let timer; - if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`); - return [new Promise((done, reject) => { - timer = setTimeout(() => { reject(new Error(`Action ${taskName} was interrupted on step timeout ${timeoutMs}ms`)); }, timeoutMs || 2e9); - }), timer]; + let timer + if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`) + return [ + new Promise((done, reject) => { + timer = setTimeout(() => { + reject(new Error(`Action ${taskName} was interrupted on step timeout ${timeoutMs}ms`)) + }, timeoutMs || 2e9) + }), + timer, + ] } function currentQueue() { - let session = ''; - if (sessionId) session = `<${sessionId}> `; - return `[${queueId}] ${session}`; + let session = '' + if (sessionId) session = `<${sessionId}> ` + return `[${queueId}] ${session}` } diff --git a/package.json b/package.json index 8fff055b3..7310e4ffc 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "fn-args": "4.0.0", "fs-extra": "11.2.0", "glob": "^11.0.0", + "fuse.js": "^7.0.0", "html-minifier-terser": "7.2.0", "inquirer": "6.5.2", "invisi-data": "^1.0.0", diff --git a/test/data/sandbox/features/step_definitions/my_steps.de.js b/test/data/sandbox/features/step_definitions/my_steps.de.js index 98d1e549f..1d89c015f 100644 --- a/test/data/sandbox/features/step_definitions/my_steps.de.js +++ b/test/data/sandbox/features/step_definitions/my_steps.de.js @@ -4,7 +4,7 @@ Given('ich habe ein Produkt mit einem Preis von {int}$ in meinem Warenkorb', pri I.addItem(parseInt(price, 10)) }) -Given('der Rabatt für Bestellungen über $\{int} beträgt {int} %', (maxPrice, discount) => { +Given('der Rabatt für Bestellungen über ${int} beträgt {int} %', (maxPrice, discount) => { I.haveDiscountForPrice(maxPrice, discount) }) diff --git a/test/data/sandbox/i18n/features/step_definitions/my_steps.de.js b/test/data/sandbox/i18n/features/step_definitions/my_steps.de.js index 98d1e549f..1d89c015f 100644 --- a/test/data/sandbox/i18n/features/step_definitions/my_steps.de.js +++ b/test/data/sandbox/i18n/features/step_definitions/my_steps.de.js @@ -4,7 +4,7 @@ Given('ich habe ein Produkt mit einem Preis von {int}$ in meinem Warenkorb', pri I.addItem(parseInt(price, 10)) }) -Given('der Rabatt für Bestellungen über $\{int} beträgt {int} %', (maxPrice, discount) => { +Given('der Rabatt für Bestellungen über ${int} beträgt {int} %', (maxPrice, discount) => { I.haveDiscountForPrice(maxPrice, discount) }) diff --git a/test/runner/codecept_test.js b/test/runner/codecept_test.js index 1759a610c..8141179e2 100644 --- a/test/runner/codecept_test.js +++ b/test/runner/codecept_test.js @@ -170,10 +170,11 @@ describe('CodeceptJS Runner', () => { it('should filter by feature tags', done => { process.chdir(codecept_dir) exec(`${codecept_run_config('codecept.grep.2.js')} --grep @feature_grep --invert`, (err, stdout) => { - stdout.should.not.include('@feature_grep') // feature - stdout.should.not.include('grep message 1') - stdout.should.not.include('grep message 2') - assert(!err) + debug(stdout) + stdout.should.include('0 passed') + stdout.should.include('No tests found by pattern: /@feature_grep/') // feature + // fails on CI, but not on local + assert(process.env.CI ? err : !err) done() }) }) diff --git a/test/runner/interface_test.js b/test/runner/interface_test.js index b81f63727..daa40b342 100644 --- a/test/runner/interface_test.js +++ b/test/runner/interface_test.js @@ -156,7 +156,13 @@ describe('CodeceptJS Interface', () => { expect(output).toContain('OK') expect(output).toContain('0 passed') expect(output).toContain('2 skipped') - expect(err).toBeFalsy() + console.log(err) + if (process.env.CI) { + // we notify that no tests were executed, which is not expected on CI + expect(err).toBeTruthy() + } else { + expect(err).toBeFalsy() + } done() }) })