diff --git a/.gitattributes b/.gitattributes index 807d598..14f2174 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,8 @@ # Use bd merge for beads JSONL files .beads/issues.jsonl merge=beads + +# Keep test corpus files with LF endings (Unix-style) on all platforms +# This ensures the corpus parser works correctly on Windows +test/**/*.txt text eol=lf +*.sh text eol=lf diff --git a/.veta/.lock b/.veta/.lock new file mode 100644 index 0000000..e69de29 diff --git a/.veta/counter b/.veta/counter new file mode 100644 index 0000000..f11c82a --- /dev/null +++ b/.veta/counter @@ -0,0 +1 @@ +9 \ No newline at end of file diff --git a/.veta/db.sqlite b/.veta/db.sqlite deleted file mode 100644 index fcae766..0000000 Binary files a/.veta/db.sqlite and /dev/null differ diff --git a/.veta/notes/1.json b/.veta/notes/1.json new file mode 100644 index 0000000..385af88 --- /dev/null +++ b/.veta/notes/1.json @@ -0,0 +1,6 @@ +{ + "body": "When running cctr tests that call cctr recursively (e.g., tests that run 'cctr $CCTR_FIXTURE_DIR/tests'), the nested invocation uses the installed cctr binary (e.g., ~/.local/bin/cctr), NOT the debug build (./target/debug/cctr).\n\nThis causes confusing test failures when developing new features - the outer test uses the new code, but inner tests use the old installed version.\n\nFix: Run './script/install' to update the installed binary before running tests, or use PATH manipulation to ensure the debug build is found first.", + "modified": "2026-01-29 00:12:54", + "references": [], + "title": "cctr: nested tests use installed binary, not debug build" +} \ No newline at end of file diff --git a/.veta/notes/2.json b/.veta/notes/2.json new file mode 100644 index 0000000..a259e0c --- /dev/null +++ b/.veta/notes/2.json @@ -0,0 +1,8 @@ +{ + "body": "When bumping the version, update all 3 occurrences in the root Cargo.toml:\n\n1. [workspace.package] version = \"X.Y.Z\"\n2. cctr-expr = { version = \"X.Y.Z\", ... }\n3. cctr-corpus = { version = \"X.Y.Z\", ... }\n\nAll three must match or cargo build will fail.", + "modified": "2026-01-29 00:15:33", + "references": [ + "Cargo.toml" + ], + "title": "cctr: version bump requires 3 places in Cargo.toml" +} \ No newline at end of file diff --git a/.veta/notes/3.json b/.veta/notes/3.json new file mode 100644 index 0000000..5f37404 --- /dev/null +++ b/.veta/notes/3.json @@ -0,0 +1,6 @@ +{ + "body": "GitHub CI runs fmt and clippy checks that will fail if code isn't formatted or has warnings.\n\nBefore pushing, always run:\n1. cargo fmt --all\n2. cargo clippy --all-targets --all-features -- -D warnings\n\nOr just run ./script/test which does both.", + "modified": "2026-01-29 00:20:33", + "references": [], + "title": "cctr: always run cargo fmt and clippy before pushing" +} \ No newline at end of file diff --git a/.veta/notes/4.json b/.veta/notes/4.json new file mode 100644 index 0000000..0200c0d --- /dev/null +++ b/.veta/notes/4.json @@ -0,0 +1,8 @@ +{ + "body": "cmd.exe has several issues with multi-line commands:\n1. `cmd /C` only executes the first line of a multi-line command\n2. For loop variables need `%%i` in batch files but `%i` interactively\n3. `echo content >` includes trailing space before redirect - need `echo content>`\n4. Same trailing space issue with pipes: `echo hello |` vs `echo hello|`\n\nPowerShell is cleaner:\n- `powershell -Command` handles multi-line commands directly (like bash -c)\n- Consistent $variable syntax in scripts and interactive\n- No trailing space issues\n- Can use: `powershell -ExecutionPolicy Bypass -Command $command`\n\nDecision: Default to PowerShell on Windows, bash on Unix. Add %shell directive for override.", + "modified": "2026-01-29 07:11:39", + "references": [ + "crates/cctr/src/runner.rs" + ], + "title": "Windows shell execution: PowerShell vs cmd.exe" +} \ No newline at end of file diff --git a/.veta/notes/5.json b/.veta/notes/5.json new file mode 100644 index 0000000..19549ff --- /dev/null +++ b/.veta/notes/5.json @@ -0,0 +1,10 @@ +{ + "body": "Refactored directive design:\n\n## %skip\n- Works at: _setup.txt (skips suite), file-level, test-level\n- Syntax: %skip, %skip(message), %skip if: command, %skip(message) if: command \n- Conditional skip runs in the file's shell (or default)\n\n## %platform (NEW)\n- Works at: _setup.txt, file-level only\n- Syntax: %platform windows or %platform unix, mac, linux\n- Comma-separated list, no 'not' support\n- Skips all tests if current platform not in list\n\n## %shell\n- Works at: file-level only (no test-level!)\n- Co-validated with %platform before tests run\n- Error if incompatible (e.g. %shell cmd + %platform unix)\n\nDefaults: bash on Unix, PowerShell on Windows", + "modified": "2026-01-29 09:25:56", + "references": [ + "crates/cctr/src/runner.rs", + "crates/cctr-corpus/src/lib.rs", + "README.md" + ], + "title": "cctr directive design: %skip, %platform, %shell" +} \ No newline at end of file diff --git a/.veta/notes/6.json b/.veta/notes/6.json new file mode 100644 index 0000000..7643d51 --- /dev/null +++ b/.veta/notes/6.json @@ -0,0 +1,9 @@ +{ + "body": "On Windows, `bash` in PATH may point to WSL's bash.exe (in C:\\Windows\\System32) which:\n1. Doesn't work with Windows filesystem paths \n2. Errors out asking you to install a WSL distribution\n\nv0.23.1 tried to fix this by hardcoding Git Bash path, but that broke systems without Git Bash.\n\nv0.23.2 fix: Use OnceLock to cache bash detection. Try `bash -c 'echo ok'` first - if it returns 'ok', use PATH bash. Otherwise fall back to Git Bash at C:\\Program Files\\Git\\bin\\bash.exe.\n\nAdded test/windows/bash.txt to verify bash works on Windows CI.", + "modified": "2026-01-29 11:41:12", + "references": [ + "crates/cctr/src/runner.rs:15-45", + "test/windows/bash.txt" + ], + "title": "Windows bash detection: WSL vs Git Bash" +} \ No newline at end of file diff --git a/.veta/notes/7.json b/.veta/notes/7.json new file mode 100644 index 0000000..5ff4835 --- /dev/null +++ b/.veta/notes/7.json @@ -0,0 +1,10 @@ +{ + "body": "v0.24.0 adds -vv flag for streaming test output in real-time.\n\nImplementation:\n- Uses mpsc channel to receive lines from stdout/stderr as they arrive\n- Two threads read stdout and stderr separately, sending lines to the channel\n- Main thread receives lines and calls the callback immediately\n- StreamingContext struct holds progress_tx and test metadata for sending TestOutput events\n\nKey files:\n- runner.rs: run_command_streaming(), StreamingContext\n- output.rs: print_progress() handles TestOutput event\n- cli.rs: verbose is now u8 (count) instead of bool\n\nNote: With -vv, stdout/stderr lines may interleave in non-deterministic order since both are read concurrently.", + "modified": "2026-01-30 14:35:38", + "references": [ + "crates/cctr/src/runner.rs:205-270", + "crates/cctr/src/output.rs:41-80", + "crates/cctr/src/cli.rs" + ], + "title": "cctr -vv streaming output implementation" +} \ No newline at end of file diff --git a/.veta/notes/8.json b/.veta/notes/8.json new file mode 100644 index 0000000..d2b7173 --- /dev/null +++ b/.veta/notes/8.json @@ -0,0 +1,10 @@ +{ + "body": "v0.24.0 adds automatic stripping of ANSI escape codes from command output using the strip-ansi-escapes crate.\n\nThis allows testing CLI tools that output colored text without needing to disable colors or match escape sequences.\n\nImplementation:\n- Added strip-ansi-escapes = \"0.2\" dependency\n- Both run_command() and run_command_streaming() strip ANSI codes\n- Stripping happens per-line in streaming mode, on combined output in normal mode\n\nTest: test/ansi_stripping/ verifies bold, color, 256-color, RGB, and cursor movement codes are all stripped.", + "modified": "2026-01-30 23:05:42", + "references": [ + "crates/cctr/Cargo.toml", + "crates/cctr/src/runner.rs:191-199", + "test/ansi_stripping/" + ], + "title": "cctr strips ANSI escape codes from command output" +} \ No newline at end of file diff --git a/.veta/notes/9.json b/.veta/notes/9.json new file mode 100644 index 0000000..a18965e --- /dev/null +++ b/.veta/notes/9.json @@ -0,0 +1,9 @@ +{ + "title": "Teardown always runs - implementation details", + "body": "v0.27.0 adds guaranteed teardown execution:\n\n1. Teardown runs even when setup fails (was returning early before)\n2. Teardown runs on SIGINT/SIGTERM (ctrlc crate with termination feature)\n3. Added ignore_interruption param to run_corpus_file for teardown\n\nSignal handling gotcha: When cctr runs in a subshell (bash -c), signal handling may not work reliably because the process can be terminated before teardown runs. Works correctly when cctr runs directly.\n\nKey code changes:\n- run_suite no longer returns early on setup failure\n- Added is_interrupted() check between tests (but not for teardown)\n- run_teardown_if_exists passes ignore_interruption=true", + "references": [ + "crates/cctr/src/runner.rs:640-775", + "crates/cctr/src/main.rs:20-30" + ], + "modified": "2026-02-02 22:00:07" +} \ No newline at end of file diff --git a/.veta/tags/cctr/1.json b/.veta/tags/cctr/1.json new file mode 120000 index 0000000..97b33c5 --- /dev/null +++ b/.veta/tags/cctr/1.json @@ -0,0 +1 @@ +../../notes/1.json \ No newline at end of file diff --git a/.veta/tags/cctr/2.json b/.veta/tags/cctr/2.json new file mode 120000 index 0000000..bc568e0 --- /dev/null +++ b/.veta/tags/cctr/2.json @@ -0,0 +1 @@ +../../notes/2.json \ No newline at end of file diff --git a/.veta/tags/cctr/3.json b/.veta/tags/cctr/3.json new file mode 120000 index 0000000..5a35a9b --- /dev/null +++ b/.veta/tags/cctr/3.json @@ -0,0 +1 @@ +../../notes/3.json \ No newline at end of file diff --git a/.veta/tags/cctr/4.json b/.veta/tags/cctr/4.json new file mode 120000 index 0000000..d71e688 --- /dev/null +++ b/.veta/tags/cctr/4.json @@ -0,0 +1 @@ +../../notes/4.json \ No newline at end of file diff --git a/.veta/tags/cctr/5.json b/.veta/tags/cctr/5.json new file mode 120000 index 0000000..662bc05 --- /dev/null +++ b/.veta/tags/cctr/5.json @@ -0,0 +1 @@ +../../notes/5.json \ No newline at end of file diff --git a/.veta/tags/cctr/6.json b/.veta/tags/cctr/6.json new file mode 120000 index 0000000..0dd7e59 --- /dev/null +++ b/.veta/tags/cctr/6.json @@ -0,0 +1 @@ +../../notes/6.json \ No newline at end of file diff --git a/.veta/tags/cctr/7.json b/.veta/tags/cctr/7.json new file mode 120000 index 0000000..4f53f48 --- /dev/null +++ b/.veta/tags/cctr/7.json @@ -0,0 +1 @@ +../../notes/7.json \ No newline at end of file diff --git a/.veta/tags/cctr/8.json b/.veta/tags/cctr/8.json new file mode 120000 index 0000000..ca47dff --- /dev/null +++ b/.veta/tags/cctr/8.json @@ -0,0 +1 @@ +../../notes/8.json \ No newline at end of file diff --git a/.veta/tags/cctr/9.json b/.veta/tags/cctr/9.json new file mode 120000 index 0000000..22c7c14 --- /dev/null +++ b/.veta/tags/cctr/9.json @@ -0,0 +1 @@ +../../notes/9.json \ No newline at end of file diff --git a/.veta/tags/debugging/1.json b/.veta/tags/debugging/1.json new file mode 120000 index 0000000..97b33c5 --- /dev/null +++ b/.veta/tags/debugging/1.json @@ -0,0 +1 @@ +../../notes/1.json \ No newline at end of file diff --git a/.veta/tags/debugging/6.json b/.veta/tags/debugging/6.json new file mode 120000 index 0000000..0dd7e59 --- /dev/null +++ b/.veta/tags/debugging/6.json @@ -0,0 +1 @@ +../../notes/6.json \ No newline at end of file diff --git a/.veta/tags/decisions/4.json b/.veta/tags/decisions/4.json new file mode 120000 index 0000000..d71e688 --- /dev/null +++ b/.veta/tags/decisions/4.json @@ -0,0 +1 @@ +../../notes/4.json \ No newline at end of file diff --git a/.veta/tags/feature/5.json b/.veta/tags/feature/5.json new file mode 120000 index 0000000..662bc05 --- /dev/null +++ b/.veta/tags/feature/5.json @@ -0,0 +1 @@ +../../notes/5.json \ No newline at end of file diff --git a/.veta/tags/feature/7.json b/.veta/tags/feature/7.json new file mode 120000 index 0000000..4f53f48 --- /dev/null +++ b/.veta/tags/feature/7.json @@ -0,0 +1 @@ +../../notes/7.json \ No newline at end of file diff --git a/.veta/tags/feature/8.json b/.veta/tags/feature/8.json new file mode 120000 index 0000000..ca47dff --- /dev/null +++ b/.veta/tags/feature/8.json @@ -0,0 +1 @@ +../../notes/8.json \ No newline at end of file diff --git a/.veta/tags/feature/9.json b/.veta/tags/feature/9.json new file mode 120000 index 0000000..22c7c14 --- /dev/null +++ b/.veta/tags/feature/9.json @@ -0,0 +1 @@ +../../notes/9.json \ No newline at end of file diff --git a/.veta/tags/gotchas/1.json b/.veta/tags/gotchas/1.json new file mode 120000 index 0000000..97b33c5 --- /dev/null +++ b/.veta/tags/gotchas/1.json @@ -0,0 +1 @@ +../../notes/1.json \ No newline at end of file diff --git a/.veta/tags/gotchas/2.json b/.veta/tags/gotchas/2.json new file mode 120000 index 0000000..bc568e0 --- /dev/null +++ b/.veta/tags/gotchas/2.json @@ -0,0 +1 @@ +../../notes/2.json \ No newline at end of file diff --git a/.veta/tags/gotchas/3.json b/.veta/tags/gotchas/3.json new file mode 120000 index 0000000..5a35a9b --- /dev/null +++ b/.veta/tags/gotchas/3.json @@ -0,0 +1 @@ +../../notes/3.json \ No newline at end of file diff --git a/.veta/tags/gotchas/4.json b/.veta/tags/gotchas/4.json new file mode 120000 index 0000000..d71e688 --- /dev/null +++ b/.veta/tags/gotchas/4.json @@ -0,0 +1 @@ +../../notes/4.json \ No newline at end of file diff --git a/.veta/tags/gotchas/6.json b/.veta/tags/gotchas/6.json new file mode 120000 index 0000000..0dd7e59 --- /dev/null +++ b/.veta/tags/gotchas/6.json @@ -0,0 +1 @@ +../../notes/6.json \ No newline at end of file diff --git a/.veta/tags/gotchas/9.json b/.veta/tags/gotchas/9.json new file mode 120000 index 0000000..22c7c14 --- /dev/null +++ b/.veta/tags/gotchas/9.json @@ -0,0 +1 @@ +../../notes/9.json \ No newline at end of file diff --git a/.veta/tags/release/2.json b/.veta/tags/release/2.json new file mode 120000 index 0000000..bc568e0 --- /dev/null +++ b/.veta/tags/release/2.json @@ -0,0 +1 @@ +../../notes/2.json \ No newline at end of file diff --git a/.veta/tags/shell/5.json b/.veta/tags/shell/5.json new file mode 120000 index 0000000..662bc05 --- /dev/null +++ b/.veta/tags/shell/5.json @@ -0,0 +1 @@ +../../notes/5.json \ No newline at end of file diff --git a/.veta/tags/windows/4.json b/.veta/tags/windows/4.json new file mode 120000 index 0000000..d71e688 --- /dev/null +++ b/.veta/tags/windows/4.json @@ -0,0 +1 @@ +../../notes/4.json \ No newline at end of file diff --git a/.veta/tags/windows/6.json b/.veta/tags/windows/6.json new file mode 120000 index 0000000..0dd7e59 --- /dev/null +++ b/.veta/tags/windows/6.json @@ -0,0 +1 @@ +../../notes/6.json \ No newline at end of file diff --git a/.veta/tags/workflow/3.json b/.veta/tags/workflow/3.json new file mode 120000 index 0000000..5a35a9b --- /dev/null +++ b/.veta/tags/workflow/3.json @@ -0,0 +1 @@ +../../notes/3.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 833c442..180827d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bstr" version = "1.12.1" @@ -118,7 +127,7 @@ dependencies = [ [[package]] name = "cctr" -version = "0.23.2" +version = "0.26.0" dependencies = [ "anyhow", "assert_cmd", @@ -126,12 +135,14 @@ dependencies = [ "cctr-corpus", "cctr-expr", "clap", + "ctrlc", "libc", "predicates", "rayon", "regex", "serde_json", "similar", + "strip-ansi-escapes", "tempfile", "termcolor", "thiserror", @@ -140,7 +151,7 @@ dependencies = [ [[package]] name = "cctr-corpus" -version = "0.23.2" +version = "0.26.0" dependencies = [ "tempfile", "thiserror", @@ -149,7 +160,7 @@ dependencies = [ [[package]] name = "cctr-expr" -version = "0.23.2" +version = "0.26.0" dependencies = [ "regex", "thiserror", @@ -162,6 +173,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.54" @@ -234,12 +251,35 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "ctrlc" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "either" version = "1.15.0" @@ -328,6 +368,18 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -343,6 +395,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.3" @@ -528,6 +595,15 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -615,6 +691,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index f02afc4..324b844 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.26.0" +version = "0.27.0" edition = "2021" authors = ["Andreas Jansson"] license = "MIT" @@ -11,8 +11,8 @@ homepage = "https://github.com/andreasjansson/cctr" repository = "https://github.com/andreasjansson/cctr" [workspace.dependencies] -cctr-expr = { version = "0.26.0", path = "crates/cctr-expr" } -cctr-corpus = { version = "0.26.0", path = "crates/cctr-corpus" } +cctr-expr = { version = "0.27.0", path = "crates/cctr-expr" } +cctr-corpus = { version = "0.27.0", path = "crates/cctr-corpus" } [profile.release] lto = true diff --git a/README.md b/README.md index 6b606a2..aaf197d 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,12 @@ seed test data --- ``` -`_teardown.txt` runs after all tests complete, regardless of whether they passed or failed: +`_teardown.txt` **always runs** after the suite, regardless of: +- Whether tests passed or failed +- Whether setup failed (main tests are skipped but teardown still runs) +- Whether the process was interrupted by SIGINT (Ctrl-C) or SIGTERM + +This ensures cleanup happens even in failure scenarios: ``` === diff --git a/crates/cctr/Cargo.toml b/crates/cctr/Cargo.toml index 2d62861..6a533c5 100644 --- a/crates/cctr/Cargo.toml +++ b/crates/cctr/Cargo.toml @@ -24,6 +24,7 @@ regex = "1" atty = "0.2" serde_json = "1" strip-ansi-escapes = "0.2" +ctrlc = { version = "3", features = ["termination"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/cctr/src/main.rs b/crates/cctr/src/main.rs index e4cd350..e4bb9d8 100644 --- a/crates/cctr/src/main.rs +++ b/crates/cctr/src/main.rs @@ -2,7 +2,7 @@ use cctr::cli::Cli; use cctr::discover::discover_suites; use cctr::output::Output; use cctr::parse_file; -use cctr::runner::{run_from_stdin, run_suite, ProgressEvent, SuiteResult}; +use cctr::runner::{run_from_stdin, run_suite, set_interrupted, ProgressEvent, SuiteResult}; use cctr::update::update_corpus_file; use clap::Parser; use rayon::prelude::*; @@ -19,6 +19,19 @@ fn main() -> anyhow::Result<()> { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } } + + // Set up signal handler for graceful shutdown + // When interrupted, we set a flag that tells running suites to skip remaining tests + // but still run their teardown + if let Err(e) = ctrlc::set_handler(move || { + // Use write! to stderr directly since eprintln! may not be signal-safe + use std::io::Write; + let _ = writeln!(std::io::stderr(), "\nInterrupted - running teardown..."); + set_interrupted(); + }) { + eprintln!("Warning: Could not set signal handler: {}", e); + } + let cli = Cli::parse(); let use_color = !cli.no_color && atty::is(atty::Stream::Stdout); diff --git a/crates/cctr/src/runner.rs b/crates/cctr/src/runner.rs index f968751..a7d2ed6 100644 --- a/crates/cctr/src/runner.rs +++ b/crates/cctr/src/runner.rs @@ -4,11 +4,26 @@ use crate::{parse_content, parse_file, TestCase}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::OnceLock; use std::time::{Duration, Instant}; use tempfile::TempDir; +/// Global flag to indicate the process has been interrupted (SIGINT/SIGTERM) +/// When set, running suites will skip remaining tests but still run teardown +static INTERRUPTED: AtomicBool = AtomicBool::new(false); + +/// Set the interrupted flag - called from signal handler +pub fn set_interrupted() { + INTERRUPTED.store(true, Ordering::SeqCst); +} + +/// Check if the process has been interrupted +pub fn is_interrupted() -> bool { + INTERRUPTED.load(Ordering::SeqCst) +} + /// Cached bash path - computed once per invocation static BASH_PATH: OnceLock = OnceLock::new(); @@ -437,6 +452,7 @@ fn run_test( } } +#[allow(clippy::too_many_arguments)] fn run_corpus_file( file_path: &Path, work_dir: &Path, @@ -445,6 +461,7 @@ fn run_corpus_file( pattern: Option<&str>, progress_tx: Option<&Sender>, stream_output: bool, + ignore_interruption: bool, ) -> FileResult { let corpus = match parse_file(file_path) { Ok(corpus) => corpus, @@ -537,6 +554,11 @@ fn run_corpus_file( let mut require_failed: Option = None; for test in corpus.tests { + // Check for interruption before starting each test (unless running teardown) + if !ignore_interruption && is_interrupted() { + break; + } + if let Some(pat) = pattern { // Match if either the file name OR the test name contains the pattern if !file_matches && !test.name.contains(pat) { @@ -602,6 +624,11 @@ fn run_corpus_file( let _ = tx.send(ProgressEvent::TestComplete(Box::new(result.clone()))); } results.push(result); + + // Check for interruption after each test completes (for faster response) + if !ignore_interruption && is_interrupted() { + break; + } } FileResult { @@ -658,6 +685,15 @@ pub fn run_suite( if suite.has_fixture { let fixture_src = suite.path.join("fixture"); if let Err(e) = copy_dir_recursive(&fixture_src, work_dir) { + // Even if fixture copy fails, we should run teardown if it exists + run_teardown_if_exists( + suite, + work_dir, + &env_vars, + progress_tx, + stream_output, + &mut file_results, + ); return SuiteResult { suite: suite.clone(), file_results, @@ -671,6 +707,9 @@ pub fn run_suite( )); } + // Track whether setup passed - if not, skip main tests but still run teardown + let mut setup_passed = true; + if suite.has_setup { let setup_file = suite.path.join("_setup.txt"); let file_result = run_corpus_file( @@ -681,54 +720,78 @@ pub fn run_suite( None, // Setup always runs all tests regardless of pattern progress_tx, stream_output, + false, // Setup can be interrupted ); - let setup_passed = file_result.passed(); + setup_passed = file_result.passed(); file_results.push(file_result); if !setup_passed { setup_error = Some("Setup failed".to_string()); - return SuiteResult { - suite: suite.clone(), - file_results, - setup_error, - elapsed: start.elapsed(), - }; + // Don't return early - fall through to run teardown } } - for corpus_file in suite.corpus_files() { - let file_result = run_corpus_file( - &corpus_file, - work_dir, - &suite.name, - &env_vars, - pattern, - progress_tx, - stream_output, - ); - file_results.push(file_result); + // Only run main tests if setup passed (or there was no setup) and not interrupted + if setup_passed && !is_interrupted() { + for corpus_file in suite.corpus_files() { + // Check for interruption before each file + if is_interrupted() { + break; + } + let file_result = run_corpus_file( + &corpus_file, + work_dir, + &suite.name, + &env_vars, + pattern, + progress_tx, + stream_output, + false, // Main tests can be interrupted + ); + file_results.push(file_result); + } } + // ALWAYS run teardown, regardless of setup/test results or interruption + run_teardown_if_exists( + suite, + work_dir, + &env_vars, + progress_tx, + stream_output, + &mut file_results, + ); + + SuiteResult { + suite: suite.clone(), + file_results, + setup_error, + elapsed: start.elapsed(), + } +} + +fn run_teardown_if_exists( + suite: &Suite, + work_dir: &Path, + env_vars: &[(String, String)], + progress_tx: Option<&Sender>, + stream_output: bool, + file_results: &mut Vec, +) { if suite.has_teardown { let teardown_file = suite.path.join("_teardown.txt"); let file_result = run_corpus_file( &teardown_file, work_dir, &suite.name, - &env_vars, + env_vars, None, // Teardown always runs all tests regardless of pattern progress_tx, stream_output, + true, // CRITICAL: Teardown must ALWAYS run, even if interrupted ); file_results.push(file_result); } - - SuiteResult { - suite: suite.clone(), - file_results, - setup_error, - elapsed: start.elapsed(), - } } fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { diff --git a/test/teardown_always/fixture/failing_setup/_setup.txt b/test/teardown_always/fixture/failing_setup/_setup.txt new file mode 100644 index 0000000..aa0859b --- /dev/null +++ b/test/teardown_always/fixture/failing_setup/_setup.txt @@ -0,0 +1,6 @@ +=== +setup that fails +=== +touch /tmp/cctr_teardown_test_setup_ran +false +--- diff --git a/test/teardown_always/fixture/failing_setup/_teardown.txt b/test/teardown_always/fixture/failing_setup/_teardown.txt new file mode 100644 index 0000000..060aa8b --- /dev/null +++ b/test/teardown_always/fixture/failing_setup/_teardown.txt @@ -0,0 +1,5 @@ +=== +teardown that writes marker +=== +touch /tmp/cctr_teardown_test_teardown_ran +--- diff --git a/test/teardown_always/fixture/failing_setup/test.txt b/test/teardown_always/fixture/failing_setup/test.txt new file mode 100644 index 0000000..036ed0d --- /dev/null +++ b/test/teardown_always/fixture/failing_setup/test.txt @@ -0,0 +1,5 @@ +=== +main test that should be skipped +=== +touch /tmp/cctr_teardown_test_main_ran +--- diff --git a/test/teardown_always/fixture/failing_setup_win/_setup.txt b/test/teardown_always/fixture/failing_setup_win/_setup.txt new file mode 100644 index 0000000..c76aa3e --- /dev/null +++ b/test/teardown_always/fixture/failing_setup_win/_setup.txt @@ -0,0 +1,7 @@ +%platform windows +=== +setup that fails +=== +echo setup > "$env:TEMP\cctr_teardown_test_setup.txt" +exit 1 +--- diff --git a/test/teardown_always/fixture/failing_setup_win/_teardown.txt b/test/teardown_always/fixture/failing_setup_win/_teardown.txt new file mode 100644 index 0000000..79a499b --- /dev/null +++ b/test/teardown_always/fixture/failing_setup_win/_teardown.txt @@ -0,0 +1,6 @@ +%platform windows +=== +teardown that writes marker +=== +echo teardown > "$env:TEMP\cctr_teardown_test_teardown.txt" +--- diff --git a/test/teardown_always/fixture/failing_setup_win/test.txt b/test/teardown_always/fixture/failing_setup_win/test.txt new file mode 100644 index 0000000..c1f9ef4 --- /dev/null +++ b/test/teardown_always/fixture/failing_setup_win/test.txt @@ -0,0 +1,6 @@ +%platform windows +=== +main test that should be skipped +=== +echo main > "$env:TEMP\cctr_teardown_test_main.txt" +--- diff --git a/test/teardown_always/fixture/signal_sync/_setup.txt b/test/teardown_always/fixture/signal_sync/_setup.txt new file mode 100644 index 0000000..4842420 --- /dev/null +++ b/test/teardown_always/fixture/signal_sync/_setup.txt @@ -0,0 +1,5 @@ +=== +setup +=== +touch /tmp/cctr_signal_sync_setup +--- diff --git a/test/teardown_always/fixture/signal_sync/_teardown.txt b/test/teardown_always/fixture/signal_sync/_teardown.txt new file mode 100644 index 0000000..d3e0e90 --- /dev/null +++ b/test/teardown_always/fixture/signal_sync/_teardown.txt @@ -0,0 +1,5 @@ +=== +teardown +=== +touch /tmp/cctr_signal_sync_teardown +--- diff --git a/test/teardown_always/fixture/signal_sync/tests.txt b/test/teardown_always/fixture/signal_sync/tests.txt new file mode 100644 index 0000000..45d10b6 --- /dev/null +++ b/test/teardown_always/fixture/signal_sync/tests.txt @@ -0,0 +1,31 @@ +=== +test 1 - signals ready then waits +=== +touch /tmp/cctr_signal_sync_test1_started +sleep 5 +touch /tmp/cctr_signal_sync_test1_finished +--- + +=== +test 2 - should be skipped if interrupted during test 1 +=== +touch /tmp/cctr_signal_sync_test2 +--- + +=== +test 3 - should be skipped if interrupted during test 1 +=== +touch /tmp/cctr_signal_sync_test3 +--- + +=== +test 4 - should be skipped if interrupted during test 1 +=== +touch /tmp/cctr_signal_sync_test4 +--- + +=== +test 5 - should be skipped if interrupted during test 1 +=== +touch /tmp/cctr_signal_sync_test5 +--- diff --git a/test/teardown_always/fixture/signal_test.sh b/test/teardown_always/fixture/signal_test.sh new file mode 100755 index 0000000..c644a9f --- /dev/null +++ b/test/teardown_always/fixture/signal_test.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Helper script to test SIGINT handling +# This script runs cctr and sends it SIGINT, then reports results + +FIXTURE_DIR="$1" +rm -f /tmp/cctr_signal_sync_* + +# Run cctr in background +cctr "$FIXTURE_DIR/signal_sync" --no-color & +CCTR_PID=$! + +# Poll until test1 signals it has started (max 10 seconds) +started=false +for i in $(seq 1 100); do + if [ -f /tmp/cctr_signal_sync_test1_started ]; then + started=true + # Small extra delay to ensure we're mid-test + sleep 0.1 + break + fi + sleep 0.1 +done + +if [ "$started" != "true" ]; then + echo "ERROR: test1 never started" + kill $CCTR_PID 2>/dev/null || true + exit 1 +fi + +# Send SIGINT +kill -INT $CCTR_PID 2>/dev/null || true + +# Wait for cctr to finish (with timeout) +wait $CCTR_PID 2>/dev/null || true + +# Report results +echo "teardown_exists=$(test -f /tmp/cctr_signal_sync_teardown && echo yes || echo no)" +echo "test1_started=$(test -f /tmp/cctr_signal_sync_test1_started && echo yes || echo no)" +echo "test2_ran=$(test -f /tmp/cctr_signal_sync_test2 && echo yes || echo no)" diff --git a/test/teardown_always/fixture/slow_tests/_setup.txt b/test/teardown_always/fixture/slow_tests/_setup.txt new file mode 100644 index 0000000..f7c7117 --- /dev/null +++ b/test/teardown_always/fixture/slow_tests/_setup.txt @@ -0,0 +1,5 @@ +=== +setup +=== +touch /tmp/cctr_signal_test_setup_ran +--- diff --git a/test/teardown_always/fixture/slow_tests/_teardown.txt b/test/teardown_always/fixture/slow_tests/_teardown.txt new file mode 100644 index 0000000..163a22b --- /dev/null +++ b/test/teardown_always/fixture/slow_tests/_teardown.txt @@ -0,0 +1,5 @@ +=== +teardown +=== +touch /tmp/cctr_signal_test_teardown_ran +--- diff --git a/test/teardown_always/fixture/slow_tests/slow.txt b/test/teardown_always/fixture/slow_tests/slow.txt new file mode 100644 index 0000000..98e92f7 --- /dev/null +++ b/test/teardown_always/fixture/slow_tests/slow.txt @@ -0,0 +1,34 @@ +=== +slow test 1 +=== +sleep 0.5 +touch /tmp/cctr_signal_test_test1_ran +--- + +=== +slow test 2 +=== +sleep 0.5 +touch /tmp/cctr_signal_test_test2_ran +--- + +=== +slow test 3 +=== +sleep 0.5 +touch /tmp/cctr_signal_test_test3_ran +--- + +=== +slow test 4 +=== +sleep 0.5 +touch /tmp/cctr_signal_test_test4_ran +--- + +=== +slow test 5 +=== +sleep 0.5 +touch /tmp/cctr_signal_test_test5_ran +--- diff --git a/test/teardown_always/teardown_always.txt b/test/teardown_always/teardown_always.txt new file mode 100644 index 0000000..9c0785f --- /dev/null +++ b/test/teardown_always/teardown_always.txt @@ -0,0 +1,73 @@ +%platform unix +=== +cleanup any previous marker files +%require +=== +rm -f /tmp/cctr_teardown_test_setup_ran /tmp/cctr_teardown_test_teardown_ran /tmp/cctr_teardown_test_main_ran +--- + +=== +run suite with failing setup +%require +=== +cctr $CCTR_FIXTURE_DIR/failing_setup --no-color 2>&1 || true +--- +F. + +⊘ failing_setup: Setup failed (3 tests skipped) + +Failures: + +✗ failing_setup/_setup: setup that fails + {{ path }}_setup.txt:1 + Command: touch /tmp/cctr_teardown_test_setup_ran +false + + +Summary: 0 passed, 0 failed, 3 skipped in {{ t }}s +--- +where +* t < 5 +* path endswith "failing_setup/" + +=== +setup marker file exists +=== +test -f /tmp/cctr_teardown_test_setup_ran && echo "setup ran" +--- +setup ran + +=== +teardown marker file exists (CRITICAL - teardown must run even when setup fails) +=== +test -f /tmp/cctr_teardown_test_teardown_ran && echo "teardown ran" +--- +teardown ran + +=== +main test marker file does NOT exist (main tests skipped when setup fails) +=== +test ! -f /tmp/cctr_teardown_test_main_ran && echo "main skipped" +--- +main skipped + +=== +cleanup signal sync markers +%require +=== +rm -f /tmp/cctr_signal_sync_* +--- + +=== +SIGINT during test run goes straight to teardown +%require +=== +# Use helper script for more reliable signal testing +# Redirect stderr to /dev/null to suppress cctr's output +"$CCTR_TEST_PATH/fixture/signal_test.sh" "$CCTR_FIXTURE_DIR" 2>&1 | grep -E "^(teardown_exists|test1_started|test2_ran)=" +--- +teardown_exists=yes +test1_started=yes +test2_ran=no + + diff --git a/test/teardown_always/teardown_always_windows.txt b/test/teardown_always/teardown_always_windows.txt new file mode 100644 index 0000000..e102a17 --- /dev/null +++ b/test/teardown_always/teardown_always_windows.txt @@ -0,0 +1,42 @@ +%platform windows +=== +cleanup any previous marker files +%require +=== +Remove-Item -Path "$env:TEMP\cctr_teardown_test_*.txt" -Force -ErrorAction SilentlyContinue +Write-Output "cleaned" +--- +cleaned + +=== +run suite with failing setup +%require +=== +cctr $env:CCTR_FIXTURE_DIR\failing_setup_win --no-color 2>&1 | Out-String +$true +--- +{{ output }} +--- +where +* output contains "Setup failed" + +=== +setup marker file exists +=== +if (Test-Path "$env:TEMP\cctr_teardown_test_setup.txt") { "setup ran" } else { "no setup" } +--- +setup ran + +=== +teardown marker file exists (CRITICAL - teardown must run even when setup fails) +=== +if (Test-Path "$env:TEMP\cctr_teardown_test_teardown.txt") { "teardown ran" } else { "no teardown" } +--- +teardown ran + +=== +main test marker file does NOT exist (main tests skipped when setup fails) +=== +if (-not (Test-Path "$env:TEMP\cctr_teardown_test_main.txt")) { "main skipped" } else { "main ran" } +--- +main skipped