Skip to content

Commit b171452

Browse files
authored
test: add CLI integration tests for find and resolve commands (Fixes #355) (#363)
Adds CLI integration tests for the `find` and `resolve` commands, covering all 6 test categories from #355 plus two additional tests. **Tests added** (`crates/pet/tests/cli_test.rs`): 1. `find --json` basic output validation (structure, required fields) 2. `resolve --json` output validation (version populated, executable matches) 3. `find --kind` filtering (all returned envs match requested kind) 4. `find --workspace` scoping (empty temp dir yields fewer results) 5. CLI flag and env var equivalence (`--conda-executable` vs `PET_CONDA_EXECUTABLE`) 6. CLI flag takes precedence over env var (crash-safety) 7. Glob expansion in search paths (temp dirs with glob pattern) 8. `--environment-directories` via `PET_ENVIRONMENT_DIRECTORIES` env var All tests are gated behind the `ci` feature flag using `#[cfg_attr(feature = "ci", test)]`. Fixes #355
1 parent a3f3f7b commit b171452

File tree

1 file changed

+362
-0
lines changed

1 file changed

+362
-0
lines changed

crates/pet/tests/cli_test.rs

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! CLI integration tests for the `pet find` and `pet resolve` commands.
5+
//!
6+
//! These tests spawn the pet binary via `std::process::Command` and validate
7+
//! its JSON output. All tests are gated behind the `ci` feature flag since
8+
//! they require a real Python installation on PATH.
9+
10+
use serde_json::Value;
11+
use std::process::Command;
12+
13+
/// Helper to run `pet find --json` with optional extra args and return parsed JSON.
14+
fn run_find_json(extra_args: &[&str]) -> (Value, std::process::Output) {
15+
let mut cmd = Command::new(env!("CARGO_BIN_EXE_pet"));
16+
cmd.arg("find").arg("--json");
17+
for arg in extra_args {
18+
cmd.arg(arg);
19+
}
20+
let output = cmd.output().expect("failed to run pet find");
21+
assert!(
22+
output.status.success(),
23+
"pet find failed with stderr: {}",
24+
String::from_utf8_lossy(&output.stderr)
25+
);
26+
let json: Value =
27+
serde_json::from_slice(&output.stdout).expect("pet find stdout is not valid JSON");
28+
(json, output)
29+
}
30+
31+
/// Test 1: `find --json` produces valid output with `managers` and `environments` arrays.
32+
#[cfg_attr(feature = "ci", test)]
33+
#[allow(dead_code)]
34+
fn find_json_output_is_valid() {
35+
let (json, _) = run_find_json(&[]);
36+
37+
assert!(
38+
json["managers"].is_array(),
39+
"expected 'managers' array in output"
40+
);
41+
assert!(
42+
json["environments"].is_array(),
43+
"expected 'environments' array in output"
44+
);
45+
46+
// Each environment should have at minimum a kind.
47+
// Executable may be null for environments without Python installed (e.g. Conda
48+
// envs created without specifying python as a dependency).
49+
let environments = json["environments"].as_array().unwrap();
50+
assert!(
51+
!environments.is_empty(),
52+
"expected at least one environment to be discovered"
53+
);
54+
let mut has_executable = false;
55+
for env in environments {
56+
assert!(env["kind"].is_string(), "environment missing 'kind': {env}");
57+
if env["executable"].is_string() {
58+
has_executable = true;
59+
}
60+
}
61+
assert!(
62+
has_executable,
63+
"expected at least one environment with an executable"
64+
);
65+
}
66+
67+
/// Test 2: `resolve --json` returns a resolved environment with a version.
68+
#[cfg_attr(feature = "ci", test)]
69+
#[allow(dead_code)]
70+
fn resolve_json_output_has_version() {
71+
// First, find an environment to resolve
72+
let (found, _) = run_find_json(&[]);
73+
let environments = found["environments"]
74+
.as_array()
75+
.expect("expected environments array");
76+
assert!(
77+
!environments.is_empty(),
78+
"need at least one environment to test resolve"
79+
);
80+
81+
// Pick an environment that has an executable path (skip broken entries)
82+
let exe = environments
83+
.iter()
84+
.find_map(|e| e["executable"].as_str())
85+
.expect("no environment with an executable found");
86+
87+
// Now resolve it
88+
let output = Command::new(env!("CARGO_BIN_EXE_pet"))
89+
.args(["resolve", exe, "--json"])
90+
.output()
91+
.expect("failed to run pet resolve");
92+
93+
assert!(
94+
output.status.success(),
95+
"pet resolve failed with stderr: {}",
96+
String::from_utf8_lossy(&output.stderr)
97+
);
98+
99+
let resolved: Value =
100+
serde_json::from_slice(&output.stdout).expect("pet resolve stdout is not valid JSON");
101+
102+
// resolved should not be null
103+
assert!(!resolved.is_null(), "resolve returned null for {exe}");
104+
105+
// Should have a version
106+
assert!(
107+
resolved["version"].is_string(),
108+
"resolved environment missing 'version' for {exe}: {resolved}"
109+
);
110+
111+
// Executable should match what we passed in (or be a symlink equivalent)
112+
assert!(
113+
resolved["executable"].is_string(),
114+
"resolved environment missing 'executable'"
115+
);
116+
}
117+
118+
/// Convert a PascalCase kind from JSON to the kebab-case format expected by clap's `--kind` flag.
119+
fn to_cli_kind(json_kind: &str) -> String {
120+
let mut result = String::new();
121+
for (i, ch) in json_kind.chars().enumerate() {
122+
if ch.is_uppercase() && i > 0 {
123+
result.push('-');
124+
}
125+
result.push(ch.to_ascii_lowercase());
126+
}
127+
result
128+
}
129+
130+
/// Test 3: `find --kind <kind> --json` filters environments by kind.
131+
#[cfg_attr(feature = "ci", test)]
132+
#[allow(dead_code)]
133+
fn find_kind_filter_works() {
134+
// First find all environments to pick a kind that exists
135+
let (all, _) = run_find_json(&[]);
136+
let environments = all["environments"]
137+
.as_array()
138+
.expect("expected environments array");
139+
assert!(
140+
!environments.is_empty(),
141+
"need at least one environment to test kind filtering"
142+
);
143+
144+
// Pick the kind of the first environment and convert to CLI format
145+
let json_kind = environments[0]["kind"]
146+
.as_str()
147+
.expect("expected kind string");
148+
let cli_kind = to_cli_kind(json_kind);
149+
150+
// Now filter by that kind
151+
let (filtered, _) = run_find_json(&["--kind", &cli_kind]);
152+
let filtered_envs = filtered["environments"]
153+
.as_array()
154+
.expect("expected environments array");
155+
156+
assert!(
157+
!filtered_envs.is_empty(),
158+
"expected at least one environment of kind '{json_kind}'"
159+
);
160+
161+
// All returned environments must match the requested kind
162+
for env in filtered_envs {
163+
assert_eq!(
164+
env["kind"].as_str().unwrap(),
165+
json_kind,
166+
"environment kind mismatch: expected '{json_kind}', got {:?}",
167+
env["kind"]
168+
);
169+
}
170+
171+
// Filtered count should be <= total count
172+
assert!(
173+
filtered_envs.len() <= environments.len(),
174+
"filtered count ({}) should not exceed total count ({})",
175+
filtered_envs.len(),
176+
environments.len()
177+
);
178+
}
179+
180+
/// Test 4: `find --workspace --json` scopes to workspace environments only.
181+
#[cfg_attr(feature = "ci", test)]
182+
#[allow(dead_code)]
183+
fn find_workspace_scoping() {
184+
// Use an empty temp dir as the workspace — should find zero or very few environments
185+
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
186+
let temp_path = temp_dir.path().to_str().expect("temp path not valid UTF-8");
187+
188+
let (json, _) = run_find_json(&["--workspace", temp_path]);
189+
190+
assert!(
191+
json["managers"].is_array(),
192+
"expected 'managers' array in workspace output"
193+
);
194+
assert!(
195+
json["environments"].is_array(),
196+
"expected 'environments' array in workspace output"
197+
);
198+
199+
let scoped_envs = json["environments"].as_array().unwrap();
200+
for env in scoped_envs {
201+
// executable may be null for environments without Python installed.
202+
// No has_executable check: workspace-scoped finds may return zero environments.
203+
assert!(
204+
env["kind"].is_string(),
205+
"workspace environment missing 'kind': {env}"
206+
);
207+
}
208+
209+
// An empty temp dir should yield fewer environments than an unrestricted find
210+
let (all, _) = run_find_json(&[]);
211+
let all_envs = all["environments"].as_array().unwrap();
212+
assert!(
213+
scoped_envs.len() <= all_envs.len(),
214+
"workspace-scoped count ({}) should not exceed global count ({})",
215+
scoped_envs.len(),
216+
all_envs.len()
217+
);
218+
}
219+
220+
/// Test 5: CLI flag and env var produce equivalent results for conda executable.
221+
#[cfg_attr(feature = "ci", test)]
222+
#[allow(dead_code)]
223+
fn cli_flag_and_env_var_equivalence() {
224+
// Use a non-existent path — the point is to verify both delivery mechanisms
225+
// produce the same Configuration, not that conda actually works.
226+
let fake_conda = if cfg!(windows) {
227+
"C:\\nonexistent\\conda.exe"
228+
} else {
229+
"/nonexistent/conda"
230+
};
231+
232+
// Via CLI flag
233+
let output_flag = Command::new(env!("CARGO_BIN_EXE_pet"))
234+
.args(["find", "--json", "--conda-executable", fake_conda])
235+
.output()
236+
.expect("failed to run pet find with --conda-executable");
237+
238+
// Via env var
239+
let output_env = Command::new(env!("CARGO_BIN_EXE_pet"))
240+
.args(["find", "--json"])
241+
.env("PET_CONDA_EXECUTABLE", fake_conda)
242+
.output()
243+
.expect("failed to run pet find with PET_CONDA_EXECUTABLE");
244+
245+
assert!(output_flag.status.success());
246+
assert!(output_env.status.success());
247+
248+
let json_flag: Value =
249+
serde_json::from_slice(&output_flag.stdout).expect("flag output is not valid JSON");
250+
let json_env: Value =
251+
serde_json::from_slice(&output_env.stdout).expect("env var output is not valid JSON");
252+
253+
// Both should produce valid output with the same structure
254+
assert!(json_flag["environments"].is_array());
255+
assert!(json_env["environments"].is_array());
256+
257+
// Environment counts should match (same discovery, just different config delivery)
258+
assert_eq!(
259+
json_flag["environments"].as_array().unwrap().len(),
260+
json_env["environments"].as_array().unwrap().len(),
261+
"CLI flag and env var should produce the same number of environments"
262+
);
263+
}
264+
265+
/// Test 6: CLI flag takes precedence over env var when both are set.
266+
/// Note: This is a crash-safety test — clap handles flag/env precedence internally,
267+
/// and the effective config isn't exposed in JSON output, so we verify the binary
268+
/// runs successfully when both are provided without conflicting.
269+
#[cfg_attr(feature = "ci", test)]
270+
#[allow(dead_code)]
271+
fn cli_flag_takes_precedence_over_env_var() {
272+
// Set env var to one value, CLI flag to another.
273+
// Both are non-existent paths — validates the binary handles both without error.
274+
let flag_value = if cfg!(windows) {
275+
"C:\\flag\\conda.exe"
276+
} else {
277+
"/flag/conda"
278+
};
279+
let env_value = if cfg!(windows) {
280+
"C:\\envvar\\conda.exe"
281+
} else {
282+
"/envvar/conda"
283+
};
284+
285+
let output = Command::new(env!("CARGO_BIN_EXE_pet"))
286+
.args(["find", "--json", "--conda-executable", flag_value])
287+
.env("PET_CONDA_EXECUTABLE", env_value)
288+
.output()
289+
.expect("failed to run pet find");
290+
291+
assert!(
292+
output.status.success(),
293+
"pet find failed: {}",
294+
String::from_utf8_lossy(&output.stderr)
295+
);
296+
297+
let json: Value = serde_json::from_slice(&output.stdout).expect("output is not valid JSON");
298+
assert!(json["environments"].is_array());
299+
}
300+
301+
/// Test 7: Glob expansion in search paths works for quoted globs.
302+
/// Requires glob expansion support from issue #354.
303+
#[cfg_attr(feature = "ci", test)]
304+
#[allow(dead_code)]
305+
fn find_glob_expansion_in_search_paths() {
306+
// Create a temp directory structure that matches a glob pattern
307+
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
308+
let sub_a = temp_dir.path().join("project_a");
309+
let sub_b = temp_dir.path().join("project_b");
310+
std::fs::create_dir_all(&sub_a).unwrap();
311+
std::fs::create_dir_all(&sub_b).unwrap();
312+
313+
// Build a glob pattern: <tempdir>/project_*
314+
let glob_pattern = format!(
315+
"{}{}project_*",
316+
temp_dir.path().display(),
317+
std::path::MAIN_SEPARATOR
318+
);
319+
320+
// Run pet find with the glob pattern as a search path — this should not error
321+
let output = Command::new(env!("CARGO_BIN_EXE_pet"))
322+
.args(["find", "--json", &glob_pattern])
323+
.output()
324+
.expect("failed to run pet find with glob pattern");
325+
326+
assert!(
327+
output.status.success(),
328+
"pet find with glob pattern failed: {}",
329+
String::from_utf8_lossy(&output.stderr)
330+
);
331+
332+
let json: Value = serde_json::from_slice(&output.stdout).expect("output is not valid JSON");
333+
assert!(
334+
json["environments"].is_array(),
335+
"expected valid JSON output with glob search path"
336+
);
337+
}
338+
339+
/// Test 8: `find --json` with `--environment-directories` via env var.
340+
#[cfg_attr(feature = "ci", test)]
341+
#[allow(dead_code)]
342+
fn find_environment_directories_via_env_var() {
343+
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
344+
345+
let output = Command::new(env!("CARGO_BIN_EXE_pet"))
346+
.args(["find", "--json"])
347+
.env(
348+
"PET_ENVIRONMENT_DIRECTORIES",
349+
temp_dir.path().to_string_lossy().as_ref(),
350+
)
351+
.output()
352+
.expect("failed to run pet find with PET_ENVIRONMENT_DIRECTORIES");
353+
354+
assert!(
355+
output.status.success(),
356+
"pet find with PET_ENVIRONMENT_DIRECTORIES failed: {}",
357+
String::from_utf8_lossy(&output.stderr)
358+
);
359+
360+
let json: Value = serde_json::from_slice(&output.stdout).expect("output is not valid JSON");
361+
assert!(json["environments"].is_array());
362+
}

0 commit comments

Comments
 (0)