Skip to main content

executable_stories/
collector.rs

1use std::path::{Path, PathBuf};
2use std::sync::{Mutex, Once};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use crate::json_writer;
6use crate::types::{RawCIInfo, RawRun, RawTestCase};
7
8static COLLECTED: Mutex<Vec<RawTestCase>> = Mutex::new(Vec::new());
9static FEATURES: Mutex<Vec<crate::types::RawFeature>> = Mutex::new(Vec::new());
10static ORDER_SEQ: Mutex<u32> = Mutex::new(0);
11/// When this test binary first recorded anything. Reports stamp their freshness
12/// from the run's timestamps, so a run without them cannot say how old it is.
13static STARTED_AT_MS: Mutex<Option<f64>> = Mutex::new(None);
14
15static EXIT_HOOK: Once = Once::new();
16
17/// How long to wait for `git rev-parse` before giving up on the commit sha.
18const GIT_TIMEOUT: Duration = Duration::from_secs(5);
19
20pub(crate) fn epoch_ms() -> f64 {
21    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0.0, |d| d.as_secs_f64() * 1000.0)
22}
23
24extern "C" fn write_results_at_exit() {
25    // Unwinding across an FFI boundary is undefined behaviour, so swallow any
26    // panic here. A failed write already printed on the way out.
27    let _ = std::panic::catch_unwind(write_results);
28}
29
30/// Flush results when the process exits.
31///
32/// The test harness calls `std::process::exit`, which runs `atexit` handlers,
33/// and it never calls a teardown function of ours. Registering from the first
34/// `Story` keeps the boilerplate out of the consuming crate.
35pub(crate) fn ensure_exit_hook() {
36    EXIT_HOOK.call_once(|| {
37        *STARTED_AT_MS.lock().unwrap() = Some(epoch_ms());
38        // SAFETY: `atexit` takes an `extern "C" fn()`. The handler touches only
39        // process-wide statics, which outlive every atexit callback, and cannot
40        // unwind past the boundary.
41        unsafe {
42            libc::atexit(write_results_at_exit);
43        }
44    });
45}
46
47/// Record a completed test case into the global collector.
48pub fn record(tc: RawTestCase) {
49    COLLECTED.lock().unwrap().push(tc);
50}
51
52/// Get the next source order value and increment the sequence.
53pub fn next_order() -> u32 {
54    let mut seq = ORDER_SEQ.lock().unwrap();
55    let val = *seq;
56    *seq += 1;
57    val
58}
59
60/// Get a clone of all collected test cases.
61pub fn get_all() -> Vec<RawTestCase> {
62    COLLECTED.lock().unwrap().clone()
63}
64
65/// Store a declaration, replacing an earlier one for the same file so a
66/// re-declaration reads the way it does in source order.
67pub(crate) fn record_feature(feature: crate::types::RawFeature) {
68    let mut features = FEATURES.lock().unwrap();
69    match features.iter_mut().find(|f| f.source_file == feature.source_file) {
70        Some(existing) => *existing = feature,
71        None => features.push(feature),
72    }
73}
74
75/// Get a clone of all declared features.
76pub(crate) fn get_features() -> Vec<crate::types::RawFeature> {
77    FEATURES.lock().unwrap().clone()
78}
79
80fn detect_ci() -> Option<RawCIInfo> {
81    if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
82        let url = match (
83            std::env::var("GITHUB_SERVER_URL"),
84            std::env::var("GITHUB_REPOSITORY"),
85            std::env::var("GITHUB_RUN_ID"),
86        ) {
87            (Ok(s), Ok(r), Ok(id)) => Some(format!("{s}/{r}/actions/runs/{id}")),
88            _ => None,
89        };
90        return Some(RawCIInfo {
91            name: "github".to_string(),
92            build_number: std::env::var("GITHUB_RUN_NUMBER").ok(),
93            url,
94        });
95    }
96    if std::env::var("CIRCLECI").as_deref() == Ok("true") {
97        return Some(RawCIInfo {
98            name: "circleci".to_string(),
99            build_number: std::env::var("CIRCLE_BUILD_NUM").ok(),
100            url: std::env::var("CIRCLE_BUILD_URL").ok(),
101        });
102    }
103    if std::env::var("JENKINS_URL").is_ok() {
104        return Some(RawCIInfo {
105            name: "jenkins".to_string(),
106            build_number: std::env::var("BUILD_NUMBER").ok(),
107            url: std::env::var("BUILD_URL").ok(),
108        });
109    }
110    if std::env::var("TRAVIS").as_deref() == Ok("true") {
111        return Some(RawCIInfo {
112            name: "travis".to_string(),
113            build_number: std::env::var("TRAVIS_BUILD_NUMBER").ok(),
114            url: std::env::var("TRAVIS_BUILD_WEB_URL").ok(),
115        });
116    }
117    if std::env::var("GITLAB_CI").as_deref() == Ok("true") {
118        return Some(RawCIInfo {
119            name: "gitlab".to_string(),
120            build_number: std::env::var("CI_PIPELINE_IID").ok(),
121            url: std::env::var("CI_PIPELINE_URL").ok(),
122        });
123    }
124    if std::env::var("CI").as_deref() == Ok("true") {
125        return Some(RawCIInfo { name: "ci".to_string(), build_number: None, url: None });
126    }
127    None
128}
129
130/// Write all collected results to JSON.
131///
132/// The output path is determined by the `EXECUTABLE_STORIES_OUTPUT` environment variable,
133/// defaulting to `.executable-stories/raw-run.json`.
134///
135/// Called automatically when the test binary exits. Call it directly only to
136/// control when the file lands.
137///
138/// libtest flags that consume the argument after them. Their value is a bare
139/// word in argv that names no test, so it must not be read as a filter.
140const VALUE_TAKING_FLAGS: &[&str] =
141    &["--test-threads", "--logfile", "--format", "--color", "--skip", "-Z", "--shuffle-seed"];
142
143/// Whether the run was narrowed by test name, so the tests reported for each
144/// source file are not that file's complete set.
145///
146/// `cargo test <filter>` reaches the test binary as a positional argument, and
147/// `--skip` narrows a run just as much, so both count. Everything else in argv
148/// is a flag or a flag's value.
149fn is_name_filtered(args: &[String]) -> bool {
150    let mut rest = args.iter().skip(1); // argv[0] is the binary
151    while let Some(arg) = rest.next() {
152        // `--skip` narrows the run, and `--ignored` runs only the tests an
153        // ordinary run leaves out, so neither is a file's complete set.
154        if arg == "--skip" || arg == "--ignored" {
155            return true;
156        }
157        if VALUE_TAKING_FLAGS.contains(&arg.as_str()) {
158            rest.next(); // consume the value so it is not read as a filter
159            continue;
160        }
161        if arg == "--" || arg.starts_with('-') {
162            continue;
163        }
164        return true;
165    }
166    false
167}
168
169/// Where the run file lands.
170///
171/// A relative `EXECUTABLE_STORIES_OUTPUT` is resolved against the project root
172/// rather than the working directory, so the file lands where the docs say it
173/// does however the test binary was started. An absolute one passes through.
174fn resolve_output_path(project_root: &str) -> PathBuf {
175    let declared = std::env::var("EXECUTABLE_STORIES_OUTPUT")
176        .unwrap_or_else(|_| ".executable-stories/raw-run.json".to_string());
177    Path::new(project_root).join(declared)
178}
179
180/// The commit this run describes.
181///
182/// CI exports it, and asking Git is the fallback. The call is bounded: this
183/// runs as the process exits, and a Git that hangs would hold that up.
184fn git_sha(project_root: &str) -> Option<String> {
185    for key in ["GITHUB_SHA", "GIT_COMMIT", "CI_COMMIT_SHA"] {
186        if let Ok(value) = std::env::var(key) {
187            if !value.is_empty() {
188                return Some(value);
189            }
190        }
191    }
192
193    let mut child = std::process::Command::new("git")
194        .args(["rev-parse", "HEAD"])
195        .current_dir(project_root)
196        .stdout(std::process::Stdio::piped())
197        // Nothing reads stderr, and Git writes to it whenever this is not a
198        // repository.
199        .stderr(std::process::Stdio::null())
200        .stdin(std::process::Stdio::null())
201        .spawn()
202        .ok()?;
203
204    let deadline = std::time::Instant::now() + GIT_TIMEOUT;
205    loop {
206        match child.try_wait() {
207            Ok(Some(status)) => {
208                if !status.success() {
209                    return None;
210                }
211                break;
212            }
213            Ok(None) => {
214                if std::time::Instant::now() >= deadline {
215                    let _ = child.kill();
216                    let _ = child.wait();
217                    return None;
218                }
219                std::thread::sleep(Duration::from_millis(10));
220            }
221            Err(_) => return None,
222        }
223    }
224
225    let mut output = String::new();
226    std::io::Read::read_to_string(child.stdout.as_mut()?, &mut output).ok()?;
227    let sha = output.trim().to_string();
228    if sha.is_empty() { None } else { Some(sha) }
229}
230
231/// # Panics
232///
233/// Panics if writing the JSON file fails (e.g. permission or I/O error).
234#[allow(clippy::missing_panics_doc)]
235pub fn write_results() {
236    let cases = get_all();
237    if cases.is_empty() {
238        return;
239    }
240
241    let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
242    let output = resolve_output_path(&cwd);
243
244    let run = RawRun {
245        schema_version: 1,
246        test_cases: cases,
247        features: get_features(),
248        project_root: cwd.clone(),
249        started_at_ms: *STARTED_AT_MS.lock().unwrap(),
250        finished_at_ms: Some(epoch_ms()),
251        ci: detect_ci(),
252        git_sha: git_sha(&cwd),
253        package_version: Some(env!("CARGO_PKG_VERSION").to_string()),
254        run_scope: Some(if is_name_filtered(&std::env::args().collect::<Vec<_>>()) {
255            "filtered".to_string()
256        } else {
257            "full".to_string()
258        }),
259    };
260
261    let output = output.to_string_lossy().to_string();
262    json_writer::write_raw_run(&run, &output).expect("Failed to write raw run JSON");
263    print_next_step(&output);
264}
265
266/// Tell the user how to turn the run JSON into a report.
267///
268/// The JS adapters render reports in-process, so their users never need to know
269/// the CLI exists. Rust hands off to the CLI instead, so without this the run
270/// ends with a file and no indication of what to do with it. stderr keeps piped
271/// output clean; `EXECUTABLE_STORIES_QUIET` silences it in CI.
272fn print_next_step(output_path: &str) {
273    if std::env::var_os("EXECUTABLE_STORIES_QUIET").is_some() {
274        return;
275    }
276    eprintln!("\nexecutable-stories: wrote {output_path}");
277    eprintln!("  next: executable-stories format {output_path} --format html");
278}
279
280/// Reset the collector state. Useful for testing.
281#[allow(dead_code)]
282pub fn reset() {
283    COLLECTED.lock().unwrap().clear();
284    FEATURES.lock().unwrap().clear();
285    *ORDER_SEQ.lock().unwrap() = 0;
286}
287
288#[cfg(test)]
289mod output_path_tests {
290    use super::resolve_output_path;
291
292    #[test]
293    fn a_relative_override_lands_under_the_project_root() {
294        temp_env("reports/raw-run.json", || {
295            assert_eq!(
296                resolve_output_path("/projects/app"),
297                std::path::Path::new("/projects/app/reports/raw-run.json")
298            );
299        });
300    }
301
302    #[test]
303    fn an_absolute_override_is_used_as_given() {
304        temp_env("/elsewhere/raw-run.json", || {
305            assert_eq!(
306                resolve_output_path("/projects/app"),
307                std::path::Path::new("/elsewhere/raw-run.json")
308            );
309        });
310    }
311
312    /// The variable is process-wide, so these two run under one lock.
313    fn temp_env(value: &str, body: impl FnOnce()) {
314        static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
315        let _guard = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
316        // SAFETY: no other thread reads this variable while the lock is held.
317        unsafe { std::env::set_var("EXECUTABLE_STORIES_OUTPUT", value) };
318        body();
319        unsafe { std::env::remove_var("EXECUTABLE_STORIES_OUTPUT") };
320    }
321}
322
323#[cfg(test)]
324mod name_filter_tests {
325    use super::is_name_filtered;
326
327    fn args(list: &[&str]) -> Vec<String> {
328        list.iter().map(|s| (*s).to_string()).collect()
329    }
330
331    #[test]
332    fn plain_run_is_not_filtered() {
333        assert!(!is_name_filtered(&args(&["target/debug/deps/story-abc123"])));
334    }
335
336    #[test]
337    fn positional_filter_marks_the_run_filtered() {
338        // `cargo test refuses` reaches the binary as a positional argument.
339        assert!(is_name_filtered(&args(&["bin", "refuses"])));
340    }
341
342    #[test]
343    fn skip_flag_marks_the_run_filtered() {
344        assert!(is_name_filtered(&args(&["bin", "--skip", "slow"])));
345    }
346
347    #[test]
348    fn libtest_flags_alone_are_not_a_filter() {
349        assert!(!is_name_filtered(&args(&["bin", "--nocapture", "--exact"])));
350    }
351
352    #[test]
353    fn ignored_only_marks_the_run_filtered() {
354        assert!(is_name_filtered(&args(&["bin", "--ignored"])));
355    }
356
357    #[test]
358    fn including_ignored_tests_is_not_a_filter() {
359        assert!(!is_name_filtered(&args(&["bin", "--include-ignored"])));
360    }
361
362    #[test]
363    fn a_flags_value_is_not_mistaken_for_a_filter() {
364        // `--test-threads 1` puts a bare "1" in argv that names no test.
365        assert!(!is_name_filtered(&args(&["bin", "--test-threads", "1"])));
366        assert!(!is_name_filtered(&args(&["bin", "--format", "terse"])));
367    }
368}