kranz_engine/test_capability.rs
1//! Runtime-gated tests: skip loudly, and fail where the capability is required.
2//!
3//! A test that returns early because a tool is missing prints `ok`. libtest
4//! reports it identically to a test that did the work, and the `eprintln!`
5//! explaining the skip is captured and never shown unless the test fails. So a
6//! capability can quietly stop being exercised while CI keeps reporting
7//! success — indefinitely, and invisibly.
8//!
9//! That is not hypothetical. `command_available` did not consult `PATHEXT`, so
10//! `sandbox_container::detect()` never found `docker.exe` and the Windows
11//! container tests skipped for the life of that CI lane. When the lookup was
12//! fixed they ran for the first time and immediately failed on two real bugs.
13//! The lane had been green throughout.
14//!
15//! `AGENTS.md` rule 5 already guards the neighbouring shape — a test FILTER
16//! matching zero tests — with `grep -qE 'test result: ok\. [1-9]'`. This module
17//! guards the other one.
18//!
19//! # Contract
20//!
21//! Call `skip` instead of a bare `eprintln!` + `return`. It emits a stable,
22//! greppable marker, and PANICS when the capability appears in
23//! `KRANZ_REQUIRED_CAPABILITIES` — so a platform that is supposed to have a
24//! tool fails loudly the moment it stops having one, instead of silently
25//! reverting to skips.
26//!
27//! CI declares per-platform expectations rather than asserting a skip list
28//! after the fact: ubuntu requires `git,bwrap,container,grep`, macOS requires
29//! `git,sandbox-exec`, and Windows requires `git`. macOS and Windows omit
30//! `container` — the provider is supported only on Linux and session and gate
31//! resolution fail closed elsewhere.
32
33/// Environment variable naming the capabilities that MUST be present.
34/// Comma-separated; matching is exact and case-insensitive.
35pub const REQUIRED_CAPABILITIES_ENV: &str = "KRANZ_REQUIRED_CAPABILITIES";
36
37/// Prefix on every skip line, so a run can be searched for what it did not do.
38pub const SKIP_MARKER: &str = "KRANZ_TEST_SKIP";
39
40/// Optional file the skip ledger is appended to.
41///
42/// Printing alone does NOT make a skip visible: libtest captures stdout and
43/// stderr for a PASSING test, and a skipping test passes, so the marker is
44/// swallowed in exactly the case that matters. `--nocapture` would surface it
45/// but floods the log and interleaves badly under parallelism. A file survives
46/// capture, so CI can print the ledger after the suite and show what the run
47/// did not exercise.
48pub const SKIP_LOG_ENV: &str = "KRANZ_SKIP_LOG";
49
50/// Capability names. Constants rather than loose strings so a typo in a test
51/// cannot silently opt out of the requirement it meant to declare.
52pub mod capability {
53 /// `git` on PATH. Every mission test needs it; a skip here is close to a
54 /// total loss of coverage.
55 pub const GIT: &str = "git";
56 /// A container runtime (`docker`/`podman`/`nerdctl`/`container`) whose
57 /// daemon can run the shipped Linux images.
58 pub const CONTAINER: &str = "container";
59 /// Linux `bwrap` — the tier-2 sandbox backend.
60 pub const BWRAP: &str = "bwrap";
61 /// macOS `sandbox-exec` — the Seatbelt backend.
62 pub const SANDBOX_EXEC: &str = "sandbox-exec";
63 /// POSIX `grep`, for the assertions that are ABOUT its exit-status
64 /// semantics and cannot be rewritten portably.
65 pub const GREP: &str = "grep";
66 /// macOS `security` able to CREATE a login keychain under a relocated
67 /// HOME. The GitHub macOS runner image 20260831.0337.3 broke this
68 /// (image 20260728.0273.1 did not); the cursor keychain tests probe it
69 /// and skip rather than report a runner regression as a code failure.
70 pub const KEYCHAIN: &str = "keychain";
71}
72
73/// True when `capability` is listed in [`REQUIRED_CAPABILITIES_ENV`].
74pub fn is_required(capability: &str) -> bool {
75 std::env::var(REQUIRED_CAPABILITIES_ENV)
76 .map(|raw| {
77 raw.split(',')
78 .map(str::trim)
79 .any(|entry| entry.eq_ignore_ascii_case(capability))
80 })
81 .unwrap_or(false)
82}
83
84/// Record that a runtime-gated test is skipping for want of `capability`.
85///
86/// Panics when the capability is required on this platform. The caller still
87/// writes its own `return`, so the skip stays visible at the call site:
88///
89/// ```ignore
90/// let Some(runtime) = detect() else {
91/// test_capability::skip(capability::CONTAINER, "no runtime on PATH");
92/// return;
93/// };
94/// ```
95///
96/// The message is deliberately explicit about why a skip is being escalated:
97/// whoever hits it is usually not the person who set the CI variable.
98pub fn skip(capability: &str, detail: &str) {
99 if is_required(capability) {
100 panic!(
101 "required capability {capability:?} is missing on this host: {detail}\n\
102 \n\
103 {REQUIRED_CAPABILITIES_ENV} lists {capability:?}, so this platform is \
104 expected to exercise it. Skipping here would report `ok` for a test \
105 that never ran, which is how the Windows container tests hid two real \
106 bugs for the life of that CI lane.\n\
107 \n\
108 Either install the capability on this host, or remove it from \
109 {REQUIRED_CAPABILITIES_ENV} for this platform and say why."
110 );
111 }
112 let line = format!("{SKIP_MARKER}: {capability}: {detail}");
113 // Visible under `--nocapture`, and to a human reading a failing target.
114 println!("{line}");
115 // Survives libtest's capture. Append rather than truncate: every test
116 // binary in the workspace writes to the same ledger, and they run as
117 // separate processes. Best-effort by design — a test must never fail
118 // because the ledger could not be written.
119 if let Ok(path) = std::env::var(SKIP_LOG_ENV) {
120 use std::io::Write as _;
121 if let Ok(mut file) = std::fs::OpenOptions::new()
122 .create(true)
123 .append(true)
124 .open(path)
125 {
126 let _ = writeln!(file, "{line}");
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn required_matching_is_exact_and_case_insensitive() {
137 // Parsing is checked directly rather than through the env, which is
138 // process-global and would race the rest of the suite.
139 let parse = |raw: &str, want: &str| {
140 raw.split(',')
141 .map(str::trim)
142 .any(|entry| entry.eq_ignore_ascii_case(want))
143 };
144 assert!(parse("git,container", "git"));
145 assert!(parse("git, container", "container"));
146 assert!(parse("GIT", "git"), "matching is case-insensitive");
147 assert!(!parse("git-lfs", "git"), "matching must not be a substring");
148 assert!(!parse("", "git"));
149 }
150
151 #[test]
152 fn an_unrequired_capability_skips_without_panicking() {
153 // Nothing sets a requirement for this name, so this must not panic.
154 skip("a-capability-no-platform-requires", "unit test");
155 }
156}