1use std::{
2 path::{Path, PathBuf},
3 process::Command,
4};
5
6use anyhow::{bail, Context, Result};
7use serde_json::Value;
8
9#[derive(Debug, Clone)]
11pub struct RunnerOptions {
12 pub args: Vec<String>,
14 pub env: Vec<(String, String)>,
16 pub working_dir: Option<PathBuf>,
18 pub stdin: Option<String>,
20 pub expectation: Option<RunnerExpectation>,
22}
23
24impl Default for RunnerOptions {
25 fn default() -> Self {
26 Self {
27 args: Vec::new(),
28 env: Vec::new(),
29 working_dir: None,
30 stdin: None,
31 expectation: Some(RunnerExpectation::default()),
32 }
33 }
34}
35
36impl RunnerOptions {
37 pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
39 self.args.push(arg.into());
40 self
41 }
42
43 pub fn add_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
45 self.env.push((key.into(), value.into()));
46 self
47 }
48
49 pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
51 self.working_dir = Some(path.into());
52 self
53 }
54
55 pub fn with_stdin(mut self, payload: impl Into<String>) -> Self {
57 self.stdin = Some(payload.into());
58 self
59 }
60
61 pub fn with_expectation(mut self, expectation: RunnerExpectation) -> Self {
63 self.expectation = Some(expectation);
64 self
65 }
66
67 pub fn disable_expectation(mut self) -> Self {
69 self.expectation = None;
70 self
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct RunnerExpectation {
77 pub expect_success: bool,
78 pub expected_egress: Option<Value>,
79 pub stdout_must_be_json: bool,
80}
81
82impl Default for RunnerExpectation {
83 fn default() -> Self {
84 Self {
85 expect_success: true,
86 expected_egress: None,
87 stdout_must_be_json: false,
88 }
89 }
90}
91
92impl RunnerExpectation {
93 pub fn success() -> Self {
95 Self::default()
96 }
97
98 pub fn require_json_stdout(mut self) -> Self {
100 self.stdout_must_be_json = true;
101 self
102 }
103
104 pub fn with_expected_egress(mut self, value: Value) -> Self {
106 self.expected_egress = Some(value);
107 self
108 }
109
110 pub fn allow_failure(mut self) -> Self {
112 self.expect_success = false;
113 self
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct RunnerSnapshot {
120 pub status: i32,
121 pub stdout: String,
122 pub stderr: String,
123 pub stdout_json: Option<Value>,
124}
125
126#[derive(Debug, Clone)]
128pub struct RunnerReport {
129 pub binary: PathBuf,
130 pub pack_path: PathBuf,
131 pub snapshot: RunnerSnapshot,
132}
133
134pub fn smoke_run_with_mocks(host_bin: &str, pack_path: &str) -> Result<RunnerReport> {
136 RunnerOptions::default().smoke_run_with_mocks(host_bin, pack_path)
137}
138
139impl RunnerOptions {
140 pub fn smoke_run_with_mocks(
142 self,
143 host_bin: impl AsRef<Path>,
144 pack_path: impl AsRef<Path>,
145 ) -> Result<RunnerReport> {
146 let host_bin = host_bin.as_ref();
147 let pack_path = pack_path.as_ref();
148
149 if !host_bin.exists() {
150 bail!("runner binary '{}' does not exist", host_bin.display());
151 }
152 if !pack_path.exists() {
153 bail!("pack path '{}' does not exist", pack_path.display());
154 }
155
156 let mut command = Command::new(host_bin);
157 command.arg(pack_path);
158 for arg in &self.args {
159 command.arg(arg);
160 }
161 command.env("GREENTIC_CONFORMANCE", "1");
162 command.env("GREENTIC_CONFORMANCE_MODE", "mock");
163 command.env("GREENTIC_PACK_PATH", pack_path);
164
165 let online_enabled = std::env::var("GREENTIC_ENABLE_ONLINE")
166 .map(|val| val == "1" || val.eq_ignore_ascii_case("true"))
167 .unwrap_or(false);
168 if !online_enabled {
169 command.env("GREENTIC_DISABLE_NETWORK", "1");
170 }
171
172 for (key, value) in &self.env {
173 command.env(key, value);
174 }
175
176 if let Some(dir) = &self.working_dir {
177 command.current_dir(dir);
178 }
179
180 if let Some(stdin_payload) = &self.stdin {
181 use std::io::Write;
182 let mut child = command
183 .stdin(std::process::Stdio::piped())
184 .stdout(std::process::Stdio::piped())
185 .stderr(std::process::Stdio::piped())
186 .spawn()
187 .with_context(|| {
188 format!(
189 "failed to spawn runner '{}' in directory '{}'",
190 host_bin.display(),
191 self.working_dir
192 .as_ref()
193 .map(|p| p.display().to_string())
194 .unwrap_or_else(|| std::env::current_dir()
195 .map(|cwd| cwd.display().to_string())
196 .unwrap_or_else(|_| "<unknown>".into()))
197 )
198 })?;
199 if let Some(stdin) = &mut child.stdin {
200 stdin
201 .write_all(stdin_payload.as_bytes())
202 .context("failed to write stdin payload to runner")?;
203 }
204 let output = child
205 .wait_with_output()
206 .context("failed to wait for runner")?;
207 return self.handle_output(host_bin, pack_path, output);
208 }
209
210 let output = command
211 .output()
212 .with_context(|| format!("failed to invoke runner '{}'", host_bin.display()))?;
213
214 self.handle_output(host_bin, pack_path, output)
215 }
216
217 fn handle_output(
218 self,
219 host_bin: &Path,
220 pack_path: &Path,
221 output: std::process::Output,
222 ) -> Result<RunnerReport> {
223 let exit_code = output.status.code().unwrap_or_default();
224 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
225 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
226
227 let mut stdout_json = None;
228 if let Some(expectation) = &self.expectation {
229 if expectation.expect_success && !output.status.success() {
230 bail!(
231 "runner '{}' exited with non-zero status {}. stderr:\n{}",
232 host_bin.display(),
233 exit_code,
234 stderr
235 );
236 }
237
238 if expectation.stdout_must_be_json || expectation.expected_egress.is_some() {
239 let parsed: Value = serde_json::from_str(stdout.trim()).with_context(|| {
240 format!(
241 "runner '{}' stdout is not valid JSON:\n{}",
242 host_bin.display(),
243 stdout
244 )
245 })?;
246 if let Some(expected) = &expectation.expected_egress {
247 if !json_contains(&parsed, expected) {
248 bail!(
249 "runner '{}' stdout does not contain expected egress\nexpected: {}\nactual: {}",
250 host_bin.display(),
251 expected,
252 parsed
253 );
254 }
255 }
256 stdout_json = Some(parsed);
257 }
258 }
259
260 let snapshot = RunnerSnapshot {
261 status: exit_code,
262 stdout,
263 stderr,
264 stdout_json,
265 };
266
267 Ok(RunnerReport {
268 binary: host_bin.to_path_buf(),
269 pack_path: pack_path.to_path_buf(),
270 snapshot,
271 })
272 }
273}
274
275fn json_contains(actual: &Value, expected: &Value) -> bool {
276 match (actual, expected) {
277 (Value::Object(actual), Value::Object(expected)) => expected.iter().all(|(key, value)| {
278 actual
279 .get(key)
280 .map(|actual_value| json_contains(actual_value, value))
281 .unwrap_or(false)
282 }),
283 (Value::Array(actual), Value::Array(expected)) => expected.iter().all(|expected_item| {
284 actual
285 .iter()
286 .any(|actual_item| json_contains(actual_item, expected_item))
287 }),
288 _ => actual == expected,
289 }
290}