Skip to content

Commit e363396

Browse files
DavertMikDavertMikclaudeDenysKuchma
authored
feat: --last-failed option for run and run-workers (#5733)
* feat: add --last-failed option to run and run-workers Every run and run-workers now saves result.json to the output dir via Result.save(). --last-failed reads it and runs only tests whose state is failed, matched by test uid, filtering before tests are split into workers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: use Object type in filterLastFailedTests JSDoc for typings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * keep result.json on empty runs, record tests of suites with failed BeforeSuite * ifx * fix --------- Co-authored-by: DavertMik <davert@testomat.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Denys Kuchma <den.kuchma@ukr.net>
1 parent 8f9296a commit e363396

17 files changed

Lines changed: 378 additions & 3 deletions

File tree

‎bin/codecept.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ program
186186
.option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
187187
.option('--shuffle', 'Shuffle the order in which test files run')
188188
.option('--shard <index/total>', 'run only a fraction of tests (e.g., --shard 1/4)')
189+
.option('--last-failed', 'run only tests that failed in the previous run')
189190

190191
// mocha options
191192
.option('--colors', 'force enabling of colors')
@@ -215,6 +216,7 @@ program
215216
.option(commandFlags.config.flag, commandFlags.config.description)
216217
.option('-g, --grep <pattern>', 'only run tests matching <pattern>')
217218
.option('-i, --invert', 'inverts --grep matches')
219+
.option('--last-failed', 'run only tests that failed in the previous run')
218220
.option('-o, --override [value]', 'override current config options')
219221
.option('--suites', 'parallel execution of suites not single tests')
220222
.option('--serial <pattern>', 'run tests matching <pattern> one by one after parallel workers finish')

‎docs/commands.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ Run test files in shuffled order
5353
npx codeceptjs run --shuffle
5454
```
5555

56+
Rerun only tests that failed in the previous run
57+
58+
```sh
59+
npx codeceptjs run --last-failed
60+
```
61+
62+
See [Last Failed](#last-failed) for details.
63+
5664
Run single test in debug mode (see more in [debugging](#Debugging) section)
5765

5866
```sh
@@ -158,6 +166,9 @@ npx codeceptjs run-workers 3 --by suite
158166
# Pool mode with filtering
159167
npx codeceptjs run-workers 4 --by pool --grep "@smoke"
160168

169+
# Rerun only tests that failed in the previous run
170+
npx codeceptjs run-workers 3 --last-failed
171+
161172
# Run tests tagged @serial one by one after parallel workers finish
162173
npx codeceptjs run-workers 3 --serial "@serial"
163174
```
@@ -174,6 +185,25 @@ The pool mode provides the best load balancing by maintaining tests in a shared
174185

175186
See [Parallel Execution](/parallel) documentation for more details.
176187

188+
## Last Failed
189+
190+
Rerun only the tests that failed in the previous run. Works with `run` and `run-workers`, including all `--by` strategies:
191+
192+
```sh
193+
npx codeceptjs run --last-failed
194+
npx codeceptjs run-workers 3 --last-failed
195+
```
196+
197+
Every `run` and `run-workers` writes a report to `result.json` in the output directory (`output` in config). It lists each executed test with its state. `--last-failed` reads this report and runs only the tests whose state is `failed`:
198+
199+
- Skipped and pending tests are not considered failed.
200+
- Tests are matched by their title, including the Feature title. A renamed test or Feature is not matched.
201+
- Combined with `--grep`, only failed tests that also match the pattern are run.
202+
- The report always reflects the most recent run, including a `--last-failed` run. Tests that pass in a rerun drop out of the next `--last-failed` set; tests that did not run (filtered out by `--grep`, sharding, and so on) are not included.
203+
- If the report does not exist, the command fails with "No previous run found". If the last run had no failures, it prints "No failed tests in the last run" and exits with code 0.
204+
205+
`--last-failed` is different from [run-rerun](#run-rerun), which repeats whole runs to detect flaky tests.
206+
177207
## Run Rerun <Badge text="Since 3.3.6" type="warning"/>
178208

179209
Run tests multiple times to detect and fix flaky tests.

‎lib/codecept.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import loadTests from './mocha/loadTests.js'
2424
import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
2525
import recorder from './recorder.js'
2626
import store from './store.js'
27+
import Result from './result.js'
2728
import WebServer from './webServer.js'
2829

2930
import storeListener from './listener/store.js'
@@ -271,6 +272,21 @@ class Codecept {
271272
return testFiles.slice(startIndex, endIndex)
272273
}
273274

275+
/**
276+
* Keeps only tests that failed in the previous run when `--last-failed` is set.
277+
*
278+
* @param {Object} mocha Mocha instance with loaded tests
279+
*/
280+
filterLastFailedTests(mocha) {
281+
if (!this.opts.lastFailed) return
282+
const failedTestUids = new Set(Result.loadFailedTests().map(test => test.uid))
283+
const filterSuite = suite => {
284+
suite.tests = suite.tests.filter(test => failedTestUids.has(test.uid))
285+
suite.suites.forEach(filterSuite)
286+
}
287+
filterSuite(mocha.suite)
288+
}
289+
274290
/**
275291
* Run a specific test or all loaded tests.
276292
*
@@ -324,6 +340,7 @@ class Codecept {
324340
// Worker threads pre-load and filter tests before run(); reloading duplicates Gherkin suites.
325341
if (!mocha.suite.total()) {
326342
await loadTests(mocha)
343+
this.filterLastFailedTests(mocha)
327344
} else {
328345
mocha.lazyLoadFiles(true)
329346
}
@@ -334,6 +351,7 @@ class Codecept {
334351
event.emit(event.all.after, this)
335352
// Wait for any recorder tasks added by event.all.after handlers
336353
await recorder.promise()
354+
if (!this.opts.child && !store.dryRun && container.result().tests.length) container.result().save()
337355
// Set exit code based on test failures
338356
if (failures) {
339357
process.exitCode = 1

‎lib/command/run-workers.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import store from '../store.js'
55
import event from '../event.js'
66
import Workers from '../workers.js'
77
import Codecept from '../codecept.js'
8+
import Config from '../config.js'
89
import { getMachineInfo } from './info.js'
10+
import { getConfig, getTestRoot, createOutputDir, hasLastFailedTests } from './utils.js'
911

1012
export default async function (workerCount, selectedRuns, options) {
1113
process.env.profile = options.profile
@@ -36,6 +38,12 @@ export default async function (workerCount, selectedRuns, options) {
3638
selectedRuns,
3739
}
3840

41+
if (options.lastFailed) {
42+
await getConfig(testConfig)
43+
const outputDir = createOutputDir(Config.append(overrideConfigs), getTestRoot(testConfig))
44+
if (!(await hasLastFailedTests(outputDir))) return
45+
}
46+
3947
const numberOfWorkers = parseInt(workerCount, 10)
4048

4149
output.print(`CodeceptJS v${Codecept.version()} ${output.standWithUkraine()}`)

‎lib/command/run.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getConfig, printError, getTestRoot, createOutputDir, autoExit } from './utils.js'
1+
import { getConfig, printError, getTestRoot, createOutputDir, autoExit, hasLastFailedTests } from './utils.js'
22
import Config from '../config.js'
33
import store from '../store.js'
44
import Codecept from '../codecept.js'
@@ -21,7 +21,8 @@ export default async function (test, options) {
2121
config = Config.append(JSON.parse(options.override))
2222
}
2323
const testRoot = getTestRoot(configFile)
24-
createOutputDir(config, testRoot)
24+
const outputDir = createOutputDir(config, testRoot)
25+
if (options.lastFailed && !(await hasLastFailedTests(outputDir))) return
2526

2627
const codecept = new Codecept(config, options)
2728

‎lib/command/utils.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ export const createOutputDir = (config, testRoot) => {
105105
output.print(`creating output directory: ${outputDir}`)
106106
mkdirp.sync(outputDir)
107107
}
108+
return outputDir
109+
}
110+
111+
export async function hasLastFailedTests(outputDir) {
112+
const { default: Result } = await import('../result.js')
113+
let failedTests
114+
try {
115+
failedTests = Result.loadFailedTests(outputDir)
116+
} catch (err) {
117+
output.error(err.message)
118+
process.exitCode = 1
119+
return false
120+
}
121+
if (!failedTests.length) output.print('No failed tests in the last run')
122+
return failedTests.length > 0
108123
}
109124

110125
export async function autoExit() {

‎lib/command/workers/runTests.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ async function runPoolTests() {
287287
// Load only the assigned test file
288288
mocha.files = [testIdentifier]
289289
await loadTests(mocha)
290+
codecept.filterLastFailedTests(mocha)
290291
filterSuites(mocha.suite.suites, test => !skipTests.includes(test.uid))
291292

292293
if (mocha.suite.total() > 0) {

‎lib/listener/result.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ export default function () {
99
event.dispatcher.on(event.test.before, test => {
1010
container.result().addTest(test)
1111
})
12+
13+
event.dispatcher.on(event.test.failed, test => {
14+
container.result().addTest(test)
15+
})
1216
}

‎lib/plugin/junitReporter.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ export default function (config = {}) {
122122

123123
function buildXml(result, config, hookFailures = []) {
124124
const doc = new DOMImplementation().createDocument(null, null, null)
125-
const allTests = result.tests.concat(hookFailures)
125+
const failureKey = (suiteTitle, message) => `${suiteTitle || ''}::${String(message || '').replace(/\s+/g, ' ').trim()}`
126+
const hookFailureKeys = new Set(hookFailures.map(hf => failureKey(hf.parent && hf.parent.title, hf.err && hf.err.message)))
127+
const tests = result.tests.filter(test => !hookFailureKeys.has(failureKey(test.parent && test.parent.title, test.err && test.err.message)))
128+
const allTests = tests.concat(hookFailures)
126129
const suites = groupBySuite(allTests)
127130

128131
const root = doc.createElement('testsuites')

‎lib/result.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ class Result {
216216
fs.writeFileSync(path.join(store.outputDir, fileName), JSON.stringify(this.simplify(), null, 2))
217217
}
218218

219+
/**
220+
* Loads tests that failed in a result saved by a previous run.
221+
*
222+
* @param {string} [outputDir] Directory containing the saved result. Defaults to `output_dir`.
223+
* @param {string} [fileName] Path to the JSON file, relative to `outputDir`. Defaults to "result.json".
224+
* @returns {Array<{uid: string, title: string, state: string}>} Serialized failed tests.
225+
*/
226+
static loadFailedTests(outputDir = store.outputDir, fileName = 'result.json') {
227+
const resultFile = path.join(outputDir, fileName)
228+
if (!fs.existsSync(resultFile)) {
229+
throw new Error(`No previous run found (${resultFile} does not exist), run tests once first`)
230+
}
231+
const { tests = [] } = JSON.parse(fs.readFileSync(resultFile, 'utf8'))
232+
return tests.filter(test => test.state === 'failed')
233+
}
234+
219235
/**
220236
* Adds stats to this result.
221237
*

0 commit comments

Comments
 (0)