Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/codecept.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ program
.option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
.option('--shuffle', 'Shuffle the order in which test files run')
.option('--shard <index/total>', 'run only a fraction of tests (e.g., --shard 1/4)')
.option('--save-failed-tests [path]', 'save failed tests to JSON file (default: failed-tests.json)')
.option('--failed-tests <path>', 'run only tests from failed tests JSON file')

// mocha options
.option('--colors', 'force enabling of colors')
Expand Down Expand Up @@ -207,6 +209,8 @@ program
.option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
.option('-O, --reporter-options <k=v,k2=v2,...>', 'reporter-specific options')
.option('-R, --reporter <name>', 'specify the reporter to use')
.option('--save-failed-tests [path]', 'save failed tests to JSON file (default: failed-tests.json)')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad defaults, this should be enabled for all runs if a plugin is turned on

.option('--failed-tests <path>', 'run only tests from failed tests JSON file')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need a plugin for that, not a new option

.action(errorHandler(require('../lib/command/run-workers')))

program
Expand Down
37 changes: 32 additions & 5 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,14 @@ I.see('new title', 'h1');

* `config` &#x20;

## consolidateWorkerJsonResults
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad naming


Consolidates JSON reports from multiple workers into a single HTML report

### Parameters

* `config` &#x20;

## coverage

Dumps code coverage from Playwright/Puppeteer after every test.
Expand Down Expand Up @@ -651,6 +659,17 @@ const eachElement = codeceptjs.container.plugins('eachElement');

Returns **([Promise][9]\<any> | [undefined][10])**&#x20;

## enhancedRetryFailedStep

Enhanced retryFailedStep plugin that coordinates with other retry mechanisms

This plugin provides step-level retries and coordinates with global retry settings
to avoid conflicts and provide predictable behavior.

### Parameters

* `config` &#x20;

## fakerTransform

Use the `@faker-js/faker` package to generate fake data inside examples on your gherkin tests
Expand Down Expand Up @@ -719,10 +738,10 @@ HTML Reporter Plugin for CodeceptJS

Generates comprehensive HTML reports showing:

- Test statistics
- Feature/Scenario details
- Individual step results
- Test artifacts (screenshots, etc.)
* Test statistics
* Feature/Scenario details
* Individual step results
* Test artifacts (screenshots, etc.)

## Configuration

Expand All @@ -749,7 +768,7 @@ Generates comprehensive HTML reports showing:

### Parameters

- `config` &#x20;
* `config` &#x20;

## pageInfo

Expand Down Expand Up @@ -862,6 +881,14 @@ Scenario('scenario tite', { disableRetryFailedStep: true }, () => {

* `config` &#x20;

## safeJsonStringify

Safely serialize data to JSON, handling circular references

### Parameters

* `data` &#x20;

## screenshotOnFail

Creates screenshot on failure. Screenshot is saved into `output` directory.
Expand Down
100 changes: 100 additions & 0 deletions lib/codecept.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,33 @@ class Codecept {
}
}

/**
* Filter tests to only include failed tests from a failed tests data
*
* @param {Array<object>} failedTests - Array of failed test objects with uid, title, file, etc.
*/
filterByFailedTests(failedTests) {
if (!failedTests || failedTests.length === 0) {
this.testFiles = []
return
}

// Extract unique file paths from failed tests
const failedTestFiles = [...new Set(failedTests.map(test => test.file).filter(Boolean))]

// Filter testFiles to only include files that contain failed tests
this.testFiles = this.testFiles.filter(file => {
const normalizedFile = fsPath.resolve(file)
return failedTestFiles.some(failedFile => {
const normalizedFailedFile = fsPath.resolve(failedFile)
return normalizedFile === normalizedFailedFile
})
})

// Store failed test info for filtering during test execution
this.failedTestsFilter = failedTests
}

/**
* Apply sharding to test files based on shard configuration
*
Expand Down Expand Up @@ -246,6 +273,18 @@ class Codecept {
}
mocha.files = mocha.files.filter(t => fsPath.basename(t, '.js') === test || t === test)
}

// Apply failed test filtering if specified
if (this.failedTestsFilter && this.failedTestsFilter.length > 0) {
// Use Mocha's grep functionality to filter tests by full title
const failedTestFullTitles = this.failedTestsFilter.map(t => t.fullTitle).filter(Boolean)
if (failedTestFullTitles.length > 0) {
// Create a regex pattern that matches any of the failed test full titles
const pattern = `^(${failedTestFullTitles.map(title => title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`
mocha.grep(new RegExp(pattern))
}
}

const done = () => {
event.emit(event.all.result, container.result())
event.emit(event.all.after, this)
Expand All @@ -262,6 +301,67 @@ class Codecept {
})
}

/**
* Filter Mocha tests to only include failed tests
*
* @private
* @param {Mocha} mocha - Mocha instance
* @param {Array<object>} failedTests - Array of failed test objects
*/
_filterMochaTestsByFailedTests(mocha, failedTests) {
const failedTestUids = new Set(failedTests.map(t => t.uid).filter(Boolean))
const failedTestTitles = new Set(failedTests.map(t => t.title).filter(Boolean))
const failedTestFullTitles = new Set(failedTests.map(t => t.fullTitle).filter(Boolean))

// Remove tests that are not in the failed tests list
for (const suite of mocha.suite.suites) {
suite.tests = suite.tests.filter(test => {
return failedTestUids.has(test.uid) || failedTestTitles.has(test.title) || failedTestFullTitles.has(test.fullTitle())
})

// Remove nested suites' tests as well
this._filterNestedSuites(suite, failedTestUids, failedTestTitles, failedTestFullTitles)
}

// Clean up empty suites
mocha.suite.suites = mocha.suite.suites.filter(suite => this._hasTests(suite))
}

/**
* Recursively filter nested suites
*
* @private
* @param {Suite} suite - Mocha suite
* @param {Set<string>} failedTestUids - Set of failed test UIDs
* @param {Set<string>} failedTestTitles - Set of failed test titles
* @param {Set<string>} failedTestFullTitles - Set of failed test full titles
*/
_filterNestedSuites(suite, failedTestUids, failedTestTitles, failedTestFullTitles) {
for (const childSuite of suite.suites || []) {
childSuite.tests = childSuite.tests.filter(test => {
return failedTestUids.has(test.uid) || failedTestTitles.has(test.title) || failedTestFullTitles.has(test.fullTitle())
})

this._filterNestedSuites(childSuite, failedTestUids, failedTestTitles, failedTestFullTitles)
}

// Remove empty child suites
suite.suites = suite.suites.filter(childSuite => this._hasTests(childSuite))
}

/**
* Check if suite or any nested suite has tests
*
* @private
* @param {Suite} suite - Mocha suite
* @returns {boolean}
*/
_hasTests(suite) {
if (suite.tests && suite.tests.length > 0) return true
if (suite.suites && suite.suites.some(childSuite => this._hasTests(childSuite))) return true
return false
}

static version() {
return JSON.parse(readFileSync(`${__dirname}/../package.json`, 'utf8')).version
}
Expand Down
30 changes: 30 additions & 0 deletions lib/command/run-workers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ const output = require('../output')
const store = require('../store')
const event = require('../event')
const Workers = require('../workers')
const fs = require('fs')
const path = require('path')
const container = require('../container')

module.exports = async function (workerCount, selectedRuns, options) {
process.env.profile = options.profile
Expand All @@ -27,11 +30,28 @@ module.exports = async function (workerCount, selectedRuns, options) {
throw new Error(`Invalid --by strategy: ${by}. Valid options are: ${validStrategies.join(', ')}`)
}
delete options.parent

// Handle failed tests loading
let failedTestsData = null
if (options.failedTests) {
const failedTestsFile = path.isAbsolute(options.failedTests) ? options.failedTests : path.resolve(options.failedTests)

if (!fs.existsSync(failedTestsFile)) {
throw new Error(`Failed tests file not found: ${failedTestsFile}`)
}

failedTestsData = JSON.parse(fs.readFileSync(failedTestsFile, 'utf8'))
if (!failedTestsData.tests || !Array.isArray(failedTestsData.tests)) {
throw new Error(`Invalid failed tests file format: ${failedTestsFile}`)
}
}

const config = {
by,
testConfig,
options,
selectedRuns,
failedTestsData, // Pass failed tests data to workers
}

const numberOfWorkers = parseInt(workerCount, 10)
Expand Down Expand Up @@ -69,6 +89,16 @@ module.exports = async function (workerCount, selectedRuns, options) {
}
await workers.bootstrapAll()
await workers.run()

// Save failed tests if requested
if (options.saveFailedTests !== undefined) {
const result = container.result()
if (result.failedTests.length > 0) {
const fileName = typeof options.saveFailedTests === 'string' ? options.saveFailedTests : 'failed-tests.json'
result.saveFailedTests(fileName)
console.log(`Failed tests saved to: ${fileName}`)
}
}
} catch (err) {
output.error(err)
process.exit(1)
Expand Down
34 changes: 33 additions & 1 deletion lib/command/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const { getConfig, printError, getTestRoot, createOutputDir } = require('./utils
const Config = require('../config')
const store = require('../store')
const Codecept = require('../codecept')
const fs = require('fs')
const path = require('path')
const container = require('../container')

module.exports = async function (test, options) {
// registering options globally to use in config
Expand All @@ -28,7 +31,26 @@ module.exports = async function (test, options) {
try {
codecept.init(testRoot)
await codecept.bootstrap()
codecept.loadTests(test)

// Handle failed tests file loading
if (options.failedTests) {
const failedTestsFile = path.isAbsolute(options.failedTests) ? options.failedTests : path.join(testRoot, options.failedTests)

if (!fs.existsSync(failedTestsFile)) {
throw new Error(`Failed tests file not found: ${failedTestsFile}`)
}

const failedTestsData = JSON.parse(fs.readFileSync(failedTestsFile, 'utf8'))
if (!failedTestsData.tests || !Array.isArray(failedTestsData.tests)) {
throw new Error(`Invalid failed tests file format: ${failedTestsFile}`)
}

// Load all tests first, then filter to only failed ones
codecept.loadTests(test)
codecept.filterByFailedTests(failedTestsData.tests)
} else {
codecept.loadTests(test)
}

if (options.verbose) {
global.debugMode = true
Expand All @@ -37,6 +59,16 @@ module.exports = async function (test, options) {
}

await codecept.run()

// Save failed tests if requested
if (options.saveFailedTests !== undefined) {
const result = container.result()
if (result.failedTests.length > 0) {
const fileName = typeof options.saveFailedTests === 'string' ? options.saveFailedTests : 'failed-tests.json'
result.saveFailedTests(fileName)
console.log(`Failed tests saved to: ${fileName}`)
}
}
} catch (err) {
printError(err)
process.exitCode = 1
Expand Down
8 changes: 7 additions & 1 deletion lib/command/workers/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const stderr = ''
// Requiring of Codecept need to be after tty.getWindowSize is available.
const Codecept = require(process.env.CODECEPT_CLASS_PATH || '../../codecept')

const { options, tests, testRoot, workerIndex, poolMode } = workerData
const { options, tests, testRoot, workerIndex, poolMode, failedTestsData } = workerData

// hide worker output
if (!options.debug && !options.verbose)
Expand All @@ -38,6 +38,12 @@ const config = deepMerge(getConfig(options.config || testRoot), overrideConfigs)
const codecept = new Codecept(config, options)
codecept.init(testRoot)
codecept.loadTests()

// Apply failed tests filtering if provided
if (failedTestsData && failedTestsData.tests) {
codecept.filterByFailedTests(failedTestsData.tests)
}

const mocha = container.mocha()

if (poolMode) {
Expand Down
22 changes: 22 additions & 0 deletions lib/result.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ class Result {
fs.writeFileSync(path.join(global.output_dir, fileName), JSON.stringify(this.simplify(), null, 2))
}

/**
* Save failed tests to JSON file
*
* @param {string} fileName
*/
saveFailedTests(fileName) {
if (!fileName) fileName = 'failed-tests.json'
const failedTests = this.failedTests.map(test => ({
uid: test.uid,
title: test.title,
fullTitle: test.fullTitle ? test.fullTitle() : test.title,
file: test.file,
parent: test.parent ? { title: test.parent.title } : null,
}))
const failedTestsData = {
timestamp: new Date().toISOString(),
count: failedTests.length,
tests: failedTests,
}
fs.writeFileSync(path.join(global.output_dir, fileName), JSON.stringify(failedTestsData, null, 2))
}

/**
* Add stats to result
*
Expand Down
Loading