Skip to content

Commit 3ca79f3

Browse files
CGamesPlaymeta-codesync[bot]
authored andcommitted
add a fail-fast flag (#42)
Summary: This resolves #41. Full disclosure: this change was coded by an AI agent. Original prompt: > Please build a feature for fast failures. It should be a test case configuration option (similar to "detached") named "fail_fast". When set, if the test case fails for any reason (exit status, snapshot, etc), the entire test document immediately stops. I reviewed the code and tests, and tested the result in my actual scrut deployment. Pull Request resolved: #42 Reviewed By: AndreasBackx Differential Revision: D87975693 Pulled By: ukautz fbshipit-source-id: 45446d6c09ac9e9f44ff5f42a964db3c0bc4533c
1 parent 1b63fe4 commit 3ca79f3

File tree

8 files changed

+250
-43
lines changed

8 files changed

+250
-43
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Validate per-testcase fail_fast configuration
2+
3+
Tests in this file validate that the `fail_fast` option stops execution of the entire test document when a test case fails.
4+
5+
```scrut
6+
$ alias scrut_test='$SCRUT_BIN test --match-markdown="*.mdtest"'
7+
```
8+
9+
## fail_fast stops on first failure
10+
11+
```scrut
12+
$ scrut_test "$TESTDIR"/test-testcase-fail-fast.mdtest 2>&1
13+
// =============================================================================
14+
// @ *test-testcase-fail-fast.mdtest:* (glob)
15+
// -----------------------------------------------------------------------------
16+
// # Second test fails with fail_fast
17+
// -----------------------------------------------------------------------------
18+
// $ echo "Test 2"
19+
// =============================================================================
20+
21+
1 | - Wrong output
22+
1 | + Test 2
23+
24+
25+
Result: 1 document(s) with 3 testcase(s): 1 succeeded, 1 failed and 1 skipped
26+
[50]
27+
```
28+
29+
## without fail_fast all tests run
30+
31+
```scrut
32+
$ scrut_test "$TESTDIR"/test-testcase-no-fail-fast.mdtest 2>&1
33+
// =============================================================================
34+
// @ *test-testcase-no-fail-fast.mdtest:* (glob)
35+
// -----------------------------------------------------------------------------
36+
// # Second test fails without fail_fast
37+
// -----------------------------------------------------------------------------
38+
// $ echo "Test 2"
39+
// =============================================================================
40+
41+
1 | - Wrong output
42+
1 | + Test 2
43+
44+
45+
Result: 1 document(s) with 3 testcase(s): 2 succeeded, 1 failed and 0 skipped
46+
[50]
47+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# This test validates fail_fast stops execution on first failure
2+
3+
This file has 3 tests: first passes, second fails with fail_fast enabled, third should be skipped.
4+
5+
## First test passes
6+
7+
```scrut
8+
$ echo "Test 1"
9+
Test 1
10+
```
11+
12+
## Second test fails with fail_fast
13+
14+
```scrut {fail_fast: true}
15+
$ echo "Test 2"
16+
Wrong output
17+
```
18+
19+
## Third test should be skipped
20+
21+
```scrut
22+
$ echo "Test 3"
23+
Test 3
24+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# This test validates normal behavior without fail_fast
2+
3+
This file has 3 tests: first passes, second fails WITHOUT fail_fast, third should still run.
4+
5+
## First test passes
6+
7+
```scrut
8+
$ echo "Test 1"
9+
Test 1
10+
```
11+
12+
## Second test fails without fail_fast
13+
14+
```scrut
15+
$ echo "Test 2"
16+
Wrong output
17+
```
18+
19+
## Third test should still run
20+
21+
```scrut
22+
$ echo "Test 3"
23+
Test 3
24+
```

src/bin/commands/test.rs

Lines changed: 96 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -294,51 +294,24 @@ impl Args {
294294

295295
// ... because test timed out
296296
ExecutionError::Timeout(timeout, outputs) => {
297-
// append outcomes for each testcase that was executed (i.e. all testcase
298-
// until and including the one that timed out)
299-
outcomes.extend(outputs.iter().zip(testcases.iter()).map(
300-
|(output, testcase)| {
301-
let result = if matches!(output.exit_code, ExitStatus::Timeout(_)) {
302-
count_failed += 1;
297+
handle_early_termination(
298+
&outputs,
299+
&testcases,
300+
&mut outcomes,
301+
test.path.display().to_string(),
302+
escaping.clone(),
303+
test.parser_type,
304+
&mut count_success,
305+
&mut count_failed,
306+
&mut count_skipped,
307+
|output, testcase| {
308+
if matches!(output.exit_code, ExitStatus::Timeout(_)) {
303309
Err(TestCaseError::Timeout)
304310
} else {
305-
let result = testcase.validate(output);
306-
if result.is_err() {
307-
count_failed += 1;
308-
result
309-
} else {
310-
count_success += 1;
311-
Ok(())
312-
}
313-
};
314-
Outcome {
315-
location: Some(test.path.display().to_string()),
316-
testcase: (*testcase).clone(),
317-
output: output.clone(),
318-
escaping: escaping.clone(),
319-
format: test.parser_type,
320-
result,
311+
testcase.validate(output)
321312
}
322313
},
323-
));
324-
325-
// append outcomes for each testcase that was not executed (i.e. all
326-
// testcases after the one that timed out)
327-
let missing = testcases.len() - outputs.len();
328-
if missing > 0 {
329-
outcomes.extend(testcases.iter().skip(outputs.len()).map(|testcase| {
330-
Outcome {
331-
location: Some(test.path.display().to_string()),
332-
testcase: (*testcase).clone(),
333-
output: ("", "", None).into(),
334-
escaping: escaping.clone(),
335-
format: test.parser_type,
336-
result: Err(TestCaseError::Skipped),
337-
}
338-
}));
339-
340-
count_skipped += missing;
341-
}
314+
);
342315

343316
let (location, timeout) = match timeout {
344317
ExecutionTimeout::Index(idx) => (
@@ -361,6 +334,29 @@ impl Args {
361334
continue;
362335
}
363336

337+
// ... because test failed with fail_fast enabled
338+
ExecutionError::Failed(idx, outputs) => {
339+
handle_early_termination(
340+
&outputs,
341+
&testcases,
342+
&mut outcomes,
343+
test.path.display().to_string(),
344+
escaping.clone(),
345+
test.parser_type,
346+
&mut count_success,
347+
&mut count_failed,
348+
&mut count_skipped,
349+
|output, testcase| testcase.validate(output),
350+
);
351+
352+
pw.println(format!(
353+
"⚡ {}: stopped at testcase #{} due to fail_fast",
354+
style(test.path.to_string_lossy()).red(),
355+
idx + 1,
356+
));
357+
continue;
358+
}
359+
364360
// ... because of a final error
365361
_ => bail!("failing in {:?}: {}", test.path, err),
366362
},
@@ -480,6 +476,65 @@ impl Args {
480476
}
481477
}
482478

479+
/// Helper function to handle early termination cases (timeout, fail_fast).
480+
/// Validates outputs that were collected, marks remaining tests as skipped.
481+
fn handle_early_termination<F>(
482+
outputs: &[scrut::output::Output],
483+
testcases: &[&TestCase],
484+
outcomes: &mut Vec<Outcome>,
485+
location: String,
486+
escaping: scrut::escaping::Escaper,
487+
format: ParserType,
488+
count_success: &mut usize,
489+
count_failed: &mut usize,
490+
count_skipped: &mut usize,
491+
mut validate_output: F,
492+
) where
493+
F: FnMut(&scrut::output::Output, &TestCase) -> Result<(), TestCaseError>,
494+
{
495+
// append outcomes for each testcase that was executed
496+
outcomes.extend(
497+
outputs
498+
.iter()
499+
.zip(testcases.iter())
500+
.map(|(output, testcase)| {
501+
let result = validate_output(output, testcase);
502+
if result.is_err() {
503+
*count_failed += 1;
504+
} else {
505+
*count_success += 1;
506+
}
507+
Outcome {
508+
location: Some(location.clone()),
509+
testcase: (*testcase).clone(),
510+
output: output.clone(),
511+
escaping: escaping.clone(),
512+
format,
513+
result,
514+
}
515+
}),
516+
);
517+
518+
// append outcomes for testcases not executed
519+
let missing = testcases.len() - outputs.len();
520+
if missing > 0 {
521+
outcomes.extend(
522+
testcases
523+
.iter()
524+
.skip(outputs.len())
525+
.map(|testcase| Outcome {
526+
location: Some(location.clone()),
527+
testcase: (*testcase).clone(),
528+
output: ("", "", None).into(),
529+
escaping: escaping.clone(),
530+
format,
531+
result: Err(TestCaseError::Skipped),
532+
}),
533+
);
534+
*count_skipped += missing;
535+
}
536+
}
537+
483538
fn prefix_with_directory(prefix: &Path, paths: &[PathBuf]) -> Vec<PathBuf> {
484539
paths
485540
.iter()

src/config.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ pub struct TestCaseConfig {
242242
#[serde(skip_serializing_if = "Option::is_none")]
243243
pub detached_kill_signal: Option<KillSignal>,
244244

245+
/// If true, stops execution of the entire test document immediately if this
246+
/// test case fails for any reason (exit status, snapshot validation, etc).
247+
#[serde(skip_serializing_if = "Option::is_none")]
248+
pub fail_fast: Option<bool>,
249+
245250
/// A set of environment variable names and values that will be explicitly set
246251
/// for the test.
247252
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
@@ -332,6 +337,7 @@ impl TestCaseConfig {
332337
&& self.keep_crlf.is_none()
333338
&& self.timeout.is_none()
334339
&& self.detached.is_none()
340+
&& self.fail_fast.is_none()
335341
&& self.wait.is_none()
336342
&& self.skip_document_code.is_none()
337343
&& self.strip_ansi_escaping.is_none()
@@ -358,6 +364,7 @@ impl TestCaseConfig {
358364
.detached_kill_signal
359365
.clone()
360366
.or_else(|| defaults.detached_kill_signal.clone()),
367+
fail_fast: self.fail_fast.or(defaults.fail_fast),
361368
wait: self.wait.clone().or_else(|| defaults.wait.clone()),
362369
skip_document_code: self.skip_document_code.or(defaults.skip_document_code),
363370
strip_ansi_escaping: self.strip_ansi_escaping.or(defaults.strip_ansi_escaping),
@@ -406,6 +413,9 @@ impl TestCaseConfig {
406413
if self.detached != other.detached {
407414
diff.detached = self.detached;
408415
}
416+
if self.fail_fast != other.fail_fast {
417+
diff.fail_fast = self.fail_fast;
418+
}
409419
if self.skip_document_code != other.skip_document_code {
410420
diff.skip_document_code = self.skip_document_code;
411421
}
@@ -451,6 +461,9 @@ impl TestCaseConfig {
451461
if let Some(value) = self.detached {
452462
output.push(format!("detached: {}", value))
453463
}
464+
if let Some(value) = self.fail_fast {
465+
output.push(format!("fail_fast: {}", value))
466+
}
454467
if let Some(value) = self.skip_document_code {
455468
output.push(format!("skip_document_code: {}", value))
456469
}
@@ -484,6 +497,10 @@ impl TestCaseConfig {
484497
self.skip_document_code
485498
.unwrap_or(DEFAULT_SKIP_DOCUMENT_CODE)
486499
}
500+
501+
pub fn get_fail_fast(&self) -> bool {
502+
self.fail_fast.unwrap_or(false)
503+
}
487504
}
488505

489506
impl Display for TestCaseConfig {
@@ -553,6 +570,7 @@ append:
553570
defaults:
554571
detached: true
555572
detached_kill_signal: quit
573+
fail_fast: true
556574
environment:
557575
BAZ: zoing
558576
FOO: bar
@@ -594,6 +612,7 @@ total_timeout: 5m 3s
594612
},
595613
detached: Some(true),
596614
detached_kill_signal: Some(KillSignal::test_default()),
615+
fail_fast: Some(true),
597616
wait: Some(TestCaseWait {
598617
timeout: Duration::from_secs(2 * 60 + 1),
599618
path: Some(PathBuf::from("the-wait-path")),
@@ -624,6 +643,7 @@ total_timeout: 5m 3s
624643
},
625644
detached: Some(true),
626645
detached_kill_signal: Some(KillSignal::test_default()),
646+
fail_fast: Some(true),
627647
wait: Some(TestCaseWait {
628648
timeout: Duration::from_secs(2 * 60 + 1),
629649
path: Some(PathBuf::from("the-wait-path")),
@@ -641,6 +661,7 @@ total_timeout: 5m 3s
641661
const FULL_TESTCASE_CONFIG: &str = "
642662
detached: true
643663
detached_kill_signal: quit
664+
fail_fast: true
644665
environment:
645666
BAZ: zoing
646667
FOO: bar
@@ -672,6 +693,7 @@ wait:
672693
},
673694
detached: Some(true),
674695
detached_kill_signal: Some(KillSignal::test_default()),
696+
fail_fast: Some(true),
675697
wait: Some(TestCaseWait {
676698
timeout: Duration::from_secs(2 * 60 + 1),
677699
path: Some(PathBuf::from("the-wait-path")),
@@ -696,6 +718,7 @@ wait:
696718
},
697719
detached: Some(true),
698720
detached_kill_signal: Some(KillSignal::test_default()),
721+
fail_fast: Some(true),
699722
wait: Some(TestCaseWait {
700723
timeout: Duration::from_secs(2 * 60 + 1),
701724
path: Some(PathBuf::from("the-wait-path")),
@@ -711,7 +734,7 @@ wait:
711734

712735
#[test]
713736
fn test_testcase_config_yaml_one_liner() {
714-
let tests = vec![
737+
let tests = [
715738
(TestCaseConfig::empty(), "{}"),
716739
(
717740
TestCaseConfig {
@@ -736,6 +759,7 @@ wait:
736759
keep_crlf: Some(true),
737760
detached: Some(false),
738761
detached_kill_signal: None,
762+
fail_fast: Some(false),
739763
environment: BTreeMap::from([("foo".to_string(), "bar".to_string())]),
740764
skip_document_code: Some(123),
741765
strip_ansi_escaping: Some(true),
@@ -745,7 +769,7 @@ wait:
745769
path: Some(PathBuf::from("/tmp/wait")),
746770
}),
747771
},
748-
"{output_stream: stderr, keep_crlf: true, timeout: 3m 54s, detached: false, skip_document_code: 123, strip_ansi_escaping: true, wait: {timeout: 2m 3s, path: /tmp/wait}, environment: {foo: \"bar\"}}",
772+
"{output_stream: stderr, keep_crlf: true, timeout: 3m 54s, detached: false, fail_fast: false, skip_document_code: 123, strip_ansi_escaping: true, wait: {timeout: 2m 3s, path: /tmp/wait}, environment: {foo: \"bar\"}}",
749773
),
750774
];
751775
for (idx, (config, expected)) in tests.iter().enumerate() {

0 commit comments

Comments
 (0)