Skip to content

Commit 0692d80

Browse files
DavertMikclaude
andcommitted
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>
1 parent ff47c22 commit 0692d80

13 files changed

Lines changed: 281 additions & 2 deletions

File tree

‎bin/codecept.js‎

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

182183
// mocha options
183184
.option('--colors', 'force enabling of colors')
@@ -207,6 +208,7 @@ program
207208
.option(commandFlags.config.flag, commandFlags.config.description)
208209
.option('-g, --grep <pattern>', 'only run tests matching <pattern>')
209210
.option('-i, --invert', 'inverts --grep matches')
211+
.option('--last-failed', 'run only tests that failed in the previous run')
210212
.option('-o, --override [value]', 'override current config options')
211213
.option('--suites', 'parallel execution of suites not single tests')
212214
.option('--by <strategy>', 'test distribution strategy: "test" (pre-assign individual tests), "suite" (pre-assign test suites), or "pool" (dynamic distribution for optimal load balancing, recommended)')

‎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
@@ -157,6 +165,9 @@ npx codeceptjs run-workers 3 --by suite
157165

158166
# Pool mode with filtering
159167
npx codeceptjs run-workers 4 --by pool --grep "@smoke"
168+
169+
# Rerun only tests that failed in the previous run
170+
npx codeceptjs run-workers 3 --last-failed
160171
```
161172

162173
**Test Distribution Strategies:**
@@ -169,6 +180,25 @@ The pool mode provides the best load balancing by maintaining tests in a shared
169180

170181
See [Parallel Execution](/parallel) documentation for more details.
171182

183+
## Last Failed
184+
185+
Rerun only the tests that failed in the previous run. Works with `run` and `run-workers`, including all `--by` strategies:
186+
187+
```sh
188+
npx codeceptjs run --last-failed
189+
npx codeceptjs run-workers 3 --last-failed
190+
```
191+
192+
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`:
193+
194+
- Skipped and pending tests are not considered failed.
195+
- Tests are matched by their title, including the Feature title. A renamed test or Feature is not matched.
196+
- Combined with `--grep`, only failed tests that also match the pattern are run.
197+
- 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.
198+
- 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.
199+
200+
`--last-failed` is different from [run-rerun](#run-rerun), which repeats whole runs to detect flaky tests.
201+
172202
## Run Rerun <Badge text="Since 3.3.6" type="warning"/>
173203

174204
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
@@ -23,6 +23,7 @@ import loadTests from './mocha/loadTests.js'
2323
import { validateTypeScriptSetup, getTSNodeESMWarning } from './utils/loaderCheck.js'
2424
import recorder from './recorder.js'
2525
import store from './store.js'
26+
import Result from './result.js'
2627

2728
import storeListener from './listener/store.js'
2829
import stepsListener from './listener/steps.js'
@@ -264,6 +265,21 @@ class Codecept {
264265
return testFiles.slice(startIndex, endIndex)
265266
}
266267

268+
/**
269+
* Keeps only tests that failed in the previous run when `--last-failed` is set.
270+
*
271+
* @param {Mocha} mocha
272+
*/
273+
filterLastFailedTests(mocha) {
274+
if (!this.opts.lastFailed) return
275+
const failedTestUids = new Set(Result.loadFailedTests().map(test => test.uid))
276+
const filterSuite = suite => {
277+
suite.tests = suite.tests.filter(test => failedTestUids.has(test.uid))
278+
suite.suites.forEach(filterSuite)
279+
}
280+
filterSuite(mocha.suite)
281+
}
282+
267283
/**
268284
* Run a specific test or all loaded tests.
269285
*
@@ -317,6 +333,7 @@ class Codecept {
317333
// Worker threads pre-load and filter tests before run(); reloading duplicates Gherkin suites.
318334
if (!mocha.suite.total()) {
319335
await loadTests(mocha)
336+
this.filterLastFailedTests(mocha)
320337
} else {
321338
mocha.lazyLoadFiles(true)
322339
}
@@ -327,6 +344,7 @@ class Codecept {
327344
event.emit(event.all.after, this)
328345
// Wait for any recorder tasks added by event.all.after handlers
329346
await recorder.promise()
347+
if (!this.opts.child && !store.dryRun) container.result().save()
330348
// Set exit code based on test failures
331349
if (failures) {
332350
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

291292
if (mocha.suite.total() > 0) {
292293
// Run only the tests in the current mocha suite

‎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
*

‎lib/workers.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ class Workers extends EventEmitter {
375375
const mocha = Container.mocha()
376376
mocha.files = files
377377
await loadTests(mocha)
378+
this.codecept.filterLastFailedTests(mocha)
378379

379380
const groups = populateGroups(numberOfWorkers)
380381
let groupCounter = 0
@@ -463,6 +464,7 @@ class Workers extends EventEmitter {
463464
const mocha = Container.mocha()
464465
mocha.files = files
465466
await loadTests(mocha)
467+
this.codecept.filterLastFailedTests(mocha)
466468

467469
mocha.suite.suites.forEach(suite => {
468470
const i = indexOfSmallestElement(groups)
@@ -802,6 +804,7 @@ class Workers extends EventEmitter {
802804
// For tests with retries configured, emit all failures + final success
803805
// For tests without retries, emit only final state
804806
const lastState = states[states.length - 1]
807+
Container.result().addTest(deserializeTest(lastState.data))
805808

806809
// Check if this test had retries by looking for failure followed by success
807810
const hasRetryPattern = states.length > 1 && states.some((s, i) => s.isFailed && i < states.length - 1 && !states[i + 1].isFailed)
@@ -829,6 +832,7 @@ class Workers extends EventEmitter {
829832

830833
this.emit(event.all.result, Container.result())
831834
event.dispatcher.emit(event.workers.result, Container.result())
835+
Container.result().save()
832836
this.emit('end') // internal event
833837
}
834838

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const config = {
2+
tests: './*_test.js',
3+
output: './output',
4+
helpers: {},
5+
include: {},
6+
bootstrap: null,
7+
mocha: {},
8+
name: 'last-failed',
9+
}

0 commit comments

Comments
 (0)