Skip to content

Commit 963de36

Browse files
authored
test: add hermetic Homeboy test context (#8171)
1 parent 53eccb1 commit 963de36

4 files changed

Lines changed: 226 additions & 52 deletions

File tree

docs/internals/test-tiers.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,19 @@ cargo test --lib --features slow-tests collect_refactor_sources_audit_write_uses
3030
```
3131

3232
Use the slow tier when changing audit detector orchestration, audit fixability planning, or audit-driven refactor planning. These tests remain runnable, but they are not part of the default unit gate because they scan real fixture/checkouts and dominated local suite wall-clock time.
33+
34+
## Hermetic CLI Fixtures
35+
36+
Ordinary Rust tests must use `homeboy::test_support::HermeticTestContext` for
37+
Homeboy subprocesses. It supplies owned HOME, config, data, artifact, runtime,
38+
temporary, daemon, and runner locations, and requires an explicit binary choice:
39+
`TestBinary::HomeboyFixture` for Cargo's fixture binary or
40+
`TestBinary::CurrentTest` for the running test executable. This prevents tests
41+
from reading operator configuration or resolving an installed `homeboy` through
42+
`PATH`.
43+
44+
Host integration tests are opt-in: place them behind an explicit Cargo feature
45+
or an explicit command-line opt-in and document the required host service,
46+
credentials, and cleanup contract beside the test. They may use host state only
47+
when that contract is the behavior under test; they are excluded from the
48+
ordinary Rust gate.

src/lib.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ pub mod core;
2525
pub mod extensions;
2626
pub mod help_topics;
2727

28-
#[cfg(test)]
29-
pub(crate) mod test_support;
28+
/// Test-only fixtures and hermetic process contexts.
29+
///
30+
/// This is public so integration tests can use the same isolation contract as
31+
/// unit tests. It is hidden from normal API documentation and has no role in
32+
/// production command execution.
33+
#[doc(hidden)]
34+
#[allow(dead_code)] // Unit-test-only helpers share this module with public CLI fixtures.
35+
pub mod test_support;
3036

3137
/// Helper for `#[serde(skip_serializing_if = "is_zero")]` on `usize` fields.
3238
pub fn is_zero(v: &usize) -> bool {

src/test_support.rs

Lines changed: 136 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,127 @@ use tempfile::TempDir;
88
static SHARED_EMPTY_GIT_REPO_TEMPLATE: OnceLock<TempDir> = OnceLock::new();
99
static SHARED_COMMITTED_GIT_REPO_TEMPLATE: OnceLock<TempDir> = OnceLock::new();
1010

11+
/// An explicit executable selection for a hermetic test command.
12+
///
13+
/// Fixture commands never resolve `homeboy` through `PATH`: integration tests
14+
/// select Cargo's binary and unit tests can select their current test binary.
15+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16+
pub enum TestBinary {
17+
CurrentTest,
18+
HomeboyFixture,
19+
}
20+
21+
/// Isolated filesystem and process environment for one test.
22+
///
23+
/// Constructing this type does not mutate the parent process. Prefer
24+
/// [`HermeticTestContext::command`] for subprocess tests. `HomeGuard` exists
25+
/// only for legacy in-process tests whose dependencies still read environment
26+
/// variables directly.
27+
pub struct HermeticTestContext {
28+
root: TempDir,
29+
runtime: TempDir,
30+
invocation_runtime: TempDir,
31+
}
32+
33+
impl HermeticTestContext {
34+
pub fn new() -> Self {
35+
let context = Self {
36+
root: exec_capable_tempdir(),
37+
runtime: exec_capable_tempdir(),
38+
invocation_runtime: short_invocation_tempdir(),
39+
};
40+
for path in [
41+
context.root().join(".config"),
42+
context.data_dir(),
43+
context.artifact_dir(),
44+
context.temp_dir(),
45+
context.daemon_dir(),
46+
context.runner_dir(),
47+
] {
48+
fs::create_dir_all(path).expect("create hermetic test path");
49+
}
50+
context
51+
}
52+
53+
pub fn root(&self) -> &Path {
54+
self.root.path()
55+
}
56+
57+
pub fn home(&self) -> &Path {
58+
self.root()
59+
}
60+
61+
pub fn config_dir(&self) -> PathBuf {
62+
self.home().join(".config/homeboy")
63+
}
64+
65+
pub fn data_dir(&self) -> PathBuf {
66+
self.root().join("data/homeboy")
67+
}
68+
69+
pub fn artifact_dir(&self) -> PathBuf {
70+
self.root().join("artifacts")
71+
}
72+
73+
pub fn runtime_dir(&self) -> &Path {
74+
self.runtime.path()
75+
}
76+
77+
pub fn temp_dir(&self) -> PathBuf {
78+
self.root().join("tmp")
79+
}
80+
81+
pub fn daemon_dir(&self) -> PathBuf {
82+
self.config_dir().join("daemon")
83+
}
84+
85+
pub fn runner_dir(&self) -> PathBuf {
86+
self.config_dir().join("runners")
87+
}
88+
89+
pub fn binary_path(&self, binary: TestBinary) -> PathBuf {
90+
match binary {
91+
TestBinary::CurrentTest => std::env::current_exe().expect("current test executable"),
92+
TestBinary::HomeboyFixture => PathBuf::from(
93+
std::env::var_os("CARGO_BIN_EXE_homeboy")
94+
.expect("CARGO_BIN_EXE_homeboy fixture binary"),
95+
),
96+
}
97+
}
98+
99+
/// Build a command whose Homeboy state is wholly owned by this context.
100+
pub fn command(&self, binary: TestBinary) -> Command {
101+
let mut command = Command::new(self.binary_path(binary));
102+
command
103+
.env("HOME", self.home())
104+
.env("XDG_CONFIG_HOME", self.root().join(".config"))
105+
.env("XDG_DATA_HOME", self.root().join("data"))
106+
.env("HOMEBOY_ARTIFACT_ROOT", self.artifact_dir())
107+
.env("HOMEBOY_RUNTIME_TMPDIR", self.runtime_dir())
108+
.env("TMPDIR", self.temp_dir())
109+
.env(
110+
crate::core::engine::invocation::HOMEBOY_INVOCATION_RUNTIME_DIR_ENV,
111+
self.invocation_runtime.path(),
112+
)
113+
.env("HOMEBOY_NO_UPDATE_CHECK", "1");
114+
command
115+
}
116+
}
117+
118+
impl Default for HermeticTestContext {
119+
fn default() -> Self {
120+
Self::new()
121+
}
122+
}
123+
11124
pub(crate) struct HomeGuard {
12125
prior: Option<String>,
13126
prior_xdg_data_home: Option<String>,
14127
prior_artifact_root: Option<String>,
15128
prior_runtime_tmpdir: Option<String>,
16129
prior_invocation_runtime: Option<String>,
17130
prior_no_update_check: Option<String>,
18-
dir: TempDir,
19-
_runtime_dir: TempDir,
20-
/// Held alongside `dir` so the short invocation runtime tempdir is
21-
/// dropped only after the test completes. Distinct from `dir` so the
22-
/// invocation root can live on a short path (e.g. `/tmp/hb-XXXX`)
23-
/// regardless of where `$TMPDIR` lands.
24-
_inv_dir: Option<TempDir>,
131+
context: HermeticTestContext,
25132
_guard: MutexGuard<'static, ()>,
26133
}
27134

@@ -75,8 +182,7 @@ impl AuditHomeGuard {
75182
impl HomeGuard {
76183
pub(crate) fn new() -> Self {
77184
let guard = home_lock().lock().unwrap_or_else(|e| e.into_inner());
78-
crate::core::defaults::reset_config_cache_for_test();
79-
crate::commands::utils::entity_suggest::reset_entity_suggestion_cache_for_test();
185+
reset_cached_test_state();
80186
let prior = std::env::var("HOME").ok();
81187
let prior_xdg_data_home = std::env::var("XDG_DATA_HOME").ok();
82188
let prior_artifact_root = std::env::var("HOMEBOY_ARTIFACT_ROOT").ok();
@@ -90,23 +196,23 @@ impl HomeGuard {
90196
// failing every capability-script test with exit 126 (#6760). Anchor
91197
// it (and the runtime tmpdir, which also hosts executables) on an
92198
// exec-capable root.
93-
let dir = exec_capable_tempdir();
94-
std::env::set_var("HOME", dir.path());
95-
std::env::set_var("XDG_DATA_HOME", dir.path().join(".local").join("share"));
199+
let context = HermeticTestContext::new();
200+
std::env::set_var("HOME", context.home());
201+
// Preserve the legacy in-process defaults while the subprocess context
202+
// uses explicit paths. These tests exercise fallback path resolution.
203+
std::env::set_var("XDG_DATA_HOME", context.home().join(".local").join("share"));
96204
std::env::remove_var("HOMEBOY_ARTIFACT_ROOT");
97205
std::env::set_var("HOMEBOY_NO_UPDATE_CHECK", "1");
98-
let runtime_dir = exec_capable_tempdir();
99-
std::env::set_var("HOMEBOY_RUNTIME_TMPDIR", runtime_dir.path());
206+
std::env::set_var("HOMEBOY_RUNTIME_TMPDIR", context.runtime_dir());
100207
crate::core::set_artifact_root_override(None);
101208
// Pin invocation runtime to a SHORT tempdir, isolated from `$TMPDIR`
102209
// and from the home tempdir (which itself can already live on a long
103210
// path on macOS, e.g. `/var/folders/<14>/T/.tmpXXXXXX/...`). Using
104211
// `/tmp` directly keeps tests within the platform `sockaddr_un`
105212
// budget regardless of host configuration.
106-
let inv_dir = short_invocation_tempdir();
107213
std::env::set_var(
108214
crate::core::engine::invocation::HOMEBOY_INVOCATION_RUNTIME_DIR_ENV,
109-
inv_dir.path(),
215+
context.invocation_runtime.path(),
110216
);
111217
Self {
112218
prior,
@@ -115,9 +221,7 @@ impl HomeGuard {
115221
prior_runtime_tmpdir,
116222
prior_invocation_runtime,
117223
prior_no_update_check,
118-
dir,
119-
_runtime_dir: runtime_dir,
120-
_inv_dir: Some(inv_dir),
224+
context,
121225
_guard: guard,
122226
}
123227
}
@@ -227,7 +331,7 @@ pub(crate) fn exec_capable_tempdir() -> TempDir {
227331
#[cfg(unix)]
228332
{
229333
let mut roots: Vec<PathBuf> = Vec::new();
230-
let mut push = |path: PathBuf, roots: &mut Vec<PathBuf>| {
334+
let push = |path: PathBuf, roots: &mut Vec<PathBuf>| {
231335
if path.is_dir() && !roots.contains(&path) {
232336
roots.push(path);
233337
}
@@ -286,21 +390,29 @@ impl Drop for HomeGuard {
286390
Some(value) => std::env::set_var("HOMEBOY_NO_UPDATE_CHECK", value),
287391
None => std::env::remove_var("HOMEBOY_NO_UPDATE_CHECK"),
288392
}
289-
crate::core::defaults::reset_config_cache_for_test();
290-
crate::commands::utils::entity_suggest::reset_entity_suggestion_cache_for_test();
393+
reset_cached_test_state();
291394
}
292395
}
293396

294397
pub(crate) fn with_isolated_home<R>(body: impl FnOnce(&TempDir) -> R) -> R {
295398
let home = HomeGuard::new();
296-
body(&home.dir)
399+
body(&home.context.root)
297400
}
298401

299402
pub(crate) fn with_isolated_audit_home<R>(body: impl FnOnce(&TempDir) -> R) -> R {
300403
let guard = AuditHomeGuard::new();
301-
body(&guard.home.dir)
404+
body(&guard.home.context.root)
302405
}
303406

407+
#[cfg(test)]
408+
fn reset_cached_test_state() {
409+
crate::core::defaults::reset_config_cache_for_test();
410+
crate::commands::utils::entity_suggest::reset_entity_suggestion_cache_for_test();
411+
}
412+
413+
#[cfg(not(test))]
414+
fn reset_cached_test_state() {}
415+
304416
pub(crate) fn write_source_extension(home: &std::path::Path, id: &str, file_extension: &str) {
305417
let extension_dir = home.join(".config/homeboy/extensions").join(id);
306418
std::fs::create_dir_all(&extension_dir).expect("extension dir");

0 commit comments

Comments
 (0)