Skip to main content

whitaker_common/test_support/
mod.rs

1//! Shared test helpers for Whitaker crates.
2//!
3//! The helpers in this module are intended for use from unit and integration
4//! tests so repeated boilerplate (such as locale overrides) can live in one
5//! place with the necessary safety documentation.
6//!
7//! ## Available helpers
8//!
9//! - [`fixtures`]: Copies UI fixtures (source files, `.stderr` expectations and
10//!   support directories) into isolated workspaces for dylint UI harnesses.
11//! - [`decomposition`]: Reusable decomposition-advice fixtures for unit and
12//!   behaviour tests.
13//! - [`env_test_guard`]: Serializes tests that temporarily mutate process-wide
14//!   environment variables.
15//! - [`ui`]: Discovers fixtures, prepares isolated workspaces, and runs dylint
16//!   UI tests with consistent panic handling.
17//! - [`LocaleOverride`]: Temporarily mutates `DYLINT_LOCALE` so locale-sensitive
18//!   tests can execute without leaking global state between cases.
19
20pub mod decomposition;
21pub mod fixtures;
22pub mod ui;
23
24pub use fixtures::{copy_directory, copy_fixture};
25pub use ui::{
26    FixtureEnvironment, discover_fixtures, prepare_fixture, read_directory_config,
27    read_fixture_config, resolve_fixture_config, run_fixtures_with, run_test_runner,
28};
29
30use std::ffi::OsString;
31use std::sync::{Mutex, MutexGuard, OnceLock};
32
33/// Serializes tests that mutate process-wide environment variables.
34///
35/// Use this guard around helpers such as `temp_env::with_var` or
36/// `temp_env::with_vars_unset` when the test would otherwise race with other
37/// cases changing the same global process state.
38pub fn env_test_guard() -> MutexGuard<'static, ()> {
39    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
40    LOCK.get_or_init(|| Mutex::new(()))
41        .lock()
42        .unwrap_or_else(|error| panic!("expected environment test lock: {error}"))
43}
44
45/// Guard that overrides `DYLINT_LOCALE` for the lifetime of the instance.
46///
47/// The guard captures any existing value and restores it when dropped. The
48/// mutation itself must be executed under a serialized test harness (for
49/// example via the `serial_test::serial` attribute) to ensure the unsafe
50/// environment access remains race-free.
51///
52/// # Examples
53///
54/// ```ignore
55/// use whitaker_common::test_support::LocaleOverride;
56/// use serial_test::serial;
57///
58/// #[test]
59/// #[serial]
60/// fn ui_runs_in_welsh_locale() {
61///     let _guard = LocaleOverride::set("cy");
62///     // Execute lint UI harness here.
63/// }
64/// ```
65pub struct LocaleOverride {
66    previous: Option<OsString>,
67}
68
69impl LocaleOverride {
70    /// Sets `DYLINT_LOCALE` to `locale`, returning a guard that will restore
71    /// the prior value (if any) when dropped.
72    pub fn set(locale: &str) -> Self {
73        let previous = std::env::var_os("DYLINT_LOCALE");
74        // SAFETY: Callers must serialize the surrounding test using a
75        // synchronization primitive such as the `serial_test::serial`
76        // attribute. The guard is thread-local and dropped before another
77        // serialized test begins, so no two threads mutate the environment
78        // concurrently.
79        unsafe {
80            std::env::set_var("DYLINT_LOCALE", locale);
81        }
82        Self { previous }
83    }
84
85    /// Removes `DYLINT_LOCALE`, returning a guard that reinstates the prior
86    /// value (if any) when dropped.
87    ///
88    /// # Examples
89    ///
90    /// ```ignore
91    /// use whitaker_common::test_support::LocaleOverride;
92    /// use serial_test::serial;
93    /// use std::ffi::OsString;
94    ///
95    /// #[test]
96    /// #[serial]
97    /// fn clears_then_restores_locale() {
98    ///     unsafe {
99    ///         std::env::set_var("DYLINT_LOCALE", "cy");
100    ///     }
101    ///     {
102    ///         let _guard = LocaleOverride::clear();
103    ///         assert!(std::env::var_os("DYLINT_LOCALE").is_none());
104    ///     }
105    ///     assert_eq!(
106    ///         std::env::var_os("DYLINT_LOCALE"),
107    ///         Some(OsString::from("cy"))
108    ///     );
109    /// }
110    /// ```
111    pub fn clear() -> Self {
112        let previous = std::env::var_os("DYLINT_LOCALE");
113        // SAFETY: Callers must serialize the surrounding test using a
114        // synchronization primitive such as the `serial_test::serial`
115        // attribute. Clearing the environment therefore cannot race with other
116        // threads.
117        unsafe {
118            std::env::remove_var("DYLINT_LOCALE");
119        }
120        Self { previous }
121    }
122}
123
124impl Drop for LocaleOverride {
125    fn drop(&mut self) {
126        if let Some(value) = &self.previous {
127            // SAFETY: By construction the guard only lives within a serialized
128            // test, so restoring the prior value cannot race with another
129            // thread mutating the environment.
130            unsafe {
131                std::env::set_var("DYLINT_LOCALE", value);
132            }
133        } else {
134            // SAFETY: Serialized execution also guarantees removal has no
135            // concurrent callers.
136            unsafe {
137                std::env::remove_var("DYLINT_LOCALE");
138            }
139        }
140    }
141}