faucet_cli/commands/
test.rs1use crate::cli::TestArgs;
10use crate::config::PipelineConfig;
11use crate::error::{CliError, CliResult};
12use crate::expand::expand;
13use crate::pipeline_test::report::{CaseOutcome, TestReport};
14use crate::pipeline_test::runner::{ResolvedCase, run_case};
15use crate::pipeline_test::spec::{TestCase, load_spec};
16use crate::pipeline_test::{diff, fixtures};
17use chrono::{DateTime, FixedOffset};
18use std::path::Path;
19
20pub async fn run(args: TestArgs) -> CliResult<()> {
22 let cwd = std::env::current_dir()?;
23 let env_path =
24 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
25 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
26
27 let default_clock = crate::commands::run::resolve_run_clock(args.clock.as_deref())?;
28
29 let mut outcomes: Vec<CaseOutcome> = Vec::new();
30 for spec_path in &args.specs {
31 let spec = load_spec(spec_path)?;
32 let spec_dir = spec_path.parent().unwrap_or(Path::new("."));
33 for case in &spec.tests {
34 if let Some(f) = &args.filter
35 && !case.name.contains(f.as_str())
36 {
37 continue;
38 }
39 let resolved = resolve_case(case, spec_path, spec_dir, default_clock, &args).await?;
40 let run = run_case(&resolved).await?;
41 let failures = diff::evaluate(&case.expect, &run);
42 outcomes.push(CaseOutcome::new(
43 case.name.clone(),
44 spec_path.display().to_string(),
45 failures,
46 ));
47 }
48 }
49
50 if outcomes.is_empty() {
51 return Err(CliError::Config(match &args.filter {
52 Some(f) => format!("no test cases match --filter '{f}'"),
53 None => "no test cases found in the given spec file(s)".to_string(),
54 }));
55 }
56
57 let report = TestReport::new(outcomes);
58 if args.json {
59 println!("{}", report.render_json());
60 } else {
61 print!("{}", report.render_human());
62 }
63 if report.failed > 0 {
64 return Err(CliError::TestsFailed {
65 failed: report.failed,
66 });
67 }
68 Ok(())
69}
70
71async fn resolve_case(
73 case: &TestCase,
74 spec_path: &Path,
75 spec_dir: &Path,
76 default_clock: DateTime<FixedOffset>,
77 args: &TestArgs,
78) -> CliResult<ResolvedCase> {
79 let at = |msg: String| {
80 CliError::Config(format!(
81 "{}: test '{}': {msg}",
82 spec_path.display(),
83 case.name
84 ))
85 };
86 let clock = match &case.clock {
87 Some(s) => crate::commands::run::resolve_run_clock(Some(s))
88 .map_err(|e| at(format!("clock: {e}")))?,
89 None => default_clock,
90 };
91 let input = fixtures::load_input(spec_dir, &case.input)?;
92
93 let resolved = match (&case.config, &case.pipeline) {
94 (Some(config_rel), None) => {
95 let config_path = spec_dir.join(config_rel);
96 let cfg = if args.resolve_secrets {
102 PipelineConfig::from_path_async(&config_path, args.profile.as_deref()).await?
103 } else {
104 PipelineConfig::from_path_tolerating_secrets(&config_path, args.profile.as_deref())?
105 };
106 let nodes = expand(&cfg)?;
107 let node = match &case.row {
108 Some(row) => nodes.iter().find(|n| &n.id == row).ok_or_else(|| {
109 at(format!(
110 "row '{row}' not found in '{}' — available rows: {}",
111 config_path.display(),
112 ids(&nodes)
113 ))
114 })?,
115 None if nodes.len() == 1 => &nodes[0],
116 None => {
117 return Err(at(format!(
118 "'{}' expands to {} invocations — set `row` to one of: {}",
119 config_path.display(),
120 nodes.len(),
121 ids(&nodes)
122 )));
123 }
124 };
125 if node.schema.is_some() {
126 tracing::warn!(
127 test = %case.name,
128 "the config's `schema:` (drift) block is inert in `faucet test` — \
129 there is no destination schema offline"
130 );
131 }
132 ResolvedCase {
133 name: case.name.clone(),
134 transforms: node.transforms.clone(),
135 #[cfg(feature = "quality")]
136 quality: node.quality.clone(),
137 #[cfg(feature = "contract")]
138 contract: node.contract.clone(),
139 #[cfg(feature = "masking")]
140 masking: node.masking.clone(),
141 input,
142 page_size: case.page_size,
143 clock,
144 }
145 }
146 (None, Some(inline)) => ResolvedCase {
147 name: case.name.clone(),
148 transforms: inline.transforms.clone(),
149 #[cfg(feature = "quality")]
150 quality: inline.quality.clone(),
151 #[cfg(feature = "contract")]
152 contract: inline.contract.clone(),
153 #[cfg(feature = "masking")]
154 masking: inline.masking.clone(),
155 input,
156 page_size: case.page_size,
157 clock,
158 },
159 _ => unreachable!("spec validation enforces config XOR pipeline"),
161 };
162 Ok(resolved)
163}
164
165fn ids(nodes: &[crate::expand::ExpandedNode]) -> String {
166 nodes
167 .iter()
168 .map(|n| n.id.as_str())
169 .collect::<Vec<_>>()
170 .join(", ")
171}