greentic_conformance/
runner_suite.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Command,
4};
5
6use anyhow::{bail, Context, Result};
7use serde_json::Value;
8
9/// Runtime options controlling how the runner is exercised.
10#[derive(Debug, Clone)]
11pub struct RunnerOptions {
12    /// Additional arguments passed to the runner after the pack path.
13    pub args: Vec<String>,
14    /// Additional environment variables set for the runner invocation.
15    pub env: Vec<(String, String)>,
16    /// Optional working directory for the runner.
17    pub working_dir: Option<PathBuf>,
18    /// Optional stdin payload forwarded to the runner.
19    pub stdin: Option<String>,
20    /// Expectations that should be asserted on the runner output.
21    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    /// Adds a CLI argument that will be appended after the pack path.
38    pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
39        self.args.push(arg.into());
40        self
41    }
42
43    /// Adds an environment variable that will be set for the runner process.
44    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    /// Specifies the working directory used when spawning the runner.
50    pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
51        self.working_dir = Some(path.into());
52        self
53    }
54
55    /// Provides an optional stdin payload that will be written to the process.
56    pub fn with_stdin(mut self, payload: impl Into<String>) -> Self {
57        self.stdin = Some(payload.into());
58        self
59    }
60
61    /// Overrides the expectation used when validating the runner outputs.
62    pub fn with_expectation(mut self, expectation: RunnerExpectation) -> Self {
63        self.expectation = Some(expectation);
64        self
65    }
66
67    /// Disables all expectations; the harness will only capture outputs.
68    pub fn disable_expectation(mut self) -> Self {
69        self.expectation = None;
70        self
71    }
72}
73
74/// Defines the behaviour we expect from the runner invocation.
75#[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    /// Create an expectation that simply checks for process success.
94    pub fn success() -> Self {
95        Self::default()
96    }
97
98    /// Require that stdout is valid JSON.
99    pub fn require_json_stdout(mut self) -> Self {
100        self.stdout_must_be_json = true;
101        self
102    }
103
104    /// Provide an expected JSON fragment that must be contained in stdout.
105    pub fn with_expected_egress(mut self, value: Value) -> Self {
106        self.expected_egress = Some(value);
107        self
108    }
109
110    /// Allow the runner to exit with a non-zero status.
111    pub fn allow_failure(mut self) -> Self {
112        self.expect_success = false;
113        self
114    }
115}
116
117/// Snapshot of a single runner invocation.
118#[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/// Report returned after running the smoke test.
127#[derive(Debug, Clone)]
128pub struct RunnerReport {
129    pub binary: PathBuf,
130    pub pack_path: PathBuf,
131    pub snapshot: RunnerSnapshot,
132}
133
134/// Smoke test a runner binary with mock connectors and a pack path.
135pub 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    /// Smoke test helper using the provided options.
141    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}