Skip to content

Commit dfbbc11

Browse files
committed
feat(conformance): add standalone CLI runner
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 9065389 commit dfbbc11

13 files changed

Lines changed: 446 additions & 71 deletions

File tree

Cargo.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
[package]
5+
name = "openshell-conformance-cli"
6+
description = "Standalone OpenShell CLI conformance test runner"
7+
version.workspace = true
8+
edition.workspace = true
9+
rust-version.workspace = true
10+
license.workspace = true
11+
repository.workspace = true
12+
13+
[[bin]]
14+
name = "openshell-conformance"
15+
path = "src/main.rs"
16+
17+
[dependencies]
18+
clap.workspace = true
19+
openshell-conformance = { path = "../openshell-conformance" }
20+
serde.workspace = true
21+
serde_json.workspace = true
22+
tokio.workspace = true
23+
24+
[lints]
25+
workspace = true
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Standalone runner for `OpenShell` CLI conformance scenarios.
5+
6+
use std::path::PathBuf;
7+
use std::process::ExitCode;
8+
9+
use clap::{Parser, Subcommand, ValueEnum};
10+
use openshell_conformance::{OpenShellRunner, Scenario, scenario, scenarios};
11+
use serde::Serialize;
12+
13+
#[derive(Debug, Parser)]
14+
#[command(
15+
name = "openshell-conformance",
16+
about = "Run OpenShell CLI conformance scenarios"
17+
)]
18+
struct Cli {
19+
#[command(subcommand)]
20+
command: Command,
21+
}
22+
23+
#[derive(Debug, Subcommand)]
24+
enum Command {
25+
/// List registered scenarios.
26+
List {
27+
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
28+
output: OutputFormat,
29+
},
30+
/// Run all registered scenarios, or named scenarios.
31+
Run {
32+
/// Scenario names. Omit to run every registered scenario.
33+
scenarios: Vec<String>,
34+
/// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH.
35+
#[arg(long)]
36+
openshell_bin: Option<PathBuf>,
37+
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
38+
output: OutputFormat,
39+
},
40+
}
41+
42+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
43+
enum OutputFormat {
44+
Text,
45+
Json,
46+
}
47+
48+
#[derive(Serialize)]
49+
struct ScenarioDescription<'a> {
50+
name: &'a str,
51+
description: &'a str,
52+
}
53+
54+
#[derive(Serialize)]
55+
struct ScenarioResult<'a> {
56+
name: &'a str,
57+
passed: bool,
58+
diagnostic: Option<String>,
59+
}
60+
61+
#[derive(Serialize)]
62+
struct RunReport<'a> {
63+
scenarios: Vec<ScenarioResult<'a>>,
64+
passed: bool,
65+
}
66+
67+
#[tokio::main]
68+
async fn main() -> ExitCode {
69+
match execute(Cli::parse()).await {
70+
Ok(()) => ExitCode::SUCCESS,
71+
Err(error) => {
72+
eprintln!("openshell-conformance: {error}");
73+
ExitCode::FAILURE
74+
}
75+
}
76+
}
77+
78+
async fn execute(cli: Cli) -> Result<(), String> {
79+
match cli.command {
80+
Command::List { output } => list(output),
81+
Command::Run {
82+
scenarios: requested,
83+
openshell_bin,
84+
output,
85+
} => run(&requested, openshell_bin, output).await,
86+
}
87+
}
88+
89+
fn list(output: OutputFormat) -> Result<(), String> {
90+
match output {
91+
OutputFormat::Text => {
92+
for candidate in scenarios() {
93+
println!("{:<16} {}", candidate.name, candidate.description);
94+
}
95+
}
96+
OutputFormat::Json => {
97+
let result = scenarios()
98+
.iter()
99+
.map(|candidate| ScenarioDescription {
100+
name: candidate.name,
101+
description: candidate.description,
102+
})
103+
.collect::<Vec<_>>();
104+
println!(
105+
"{}",
106+
serde_json::to_string_pretty(&result).map_err(|error| error.to_string())?
107+
);
108+
}
109+
}
110+
Ok(())
111+
}
112+
113+
async fn run(
114+
requested: &[String],
115+
binary: Option<PathBuf>,
116+
output: OutputFormat,
117+
) -> Result<(), String> {
118+
let selected = select_scenarios(requested)?;
119+
let mut results = Vec::with_capacity(selected.len());
120+
for candidate in selected {
121+
let runner = binary.as_ref().map_or_else(
122+
|| OpenShellRunner::new(candidate.name),
123+
|path| OpenShellRunner::with_binary(path.clone(), candidate.name),
124+
);
125+
let mut runner = match runner {
126+
Ok(runner) => runner,
127+
Err(error) => {
128+
results.push(ScenarioResult {
129+
name: candidate.name,
130+
passed: false,
131+
diagnostic: Some(error.to_string()),
132+
});
133+
continue;
134+
}
135+
};
136+
eprintln!("CLI conformance run ID: {}", runner.id());
137+
let scenario_result = match runner.check_gateway_status().await {
138+
Ok(()) => candidate.run(&mut runner).await,
139+
Err(error) => Err(error),
140+
};
141+
let outcome = runner.finish(scenario_result).await;
142+
results.push(ScenarioResult {
143+
name: candidate.name,
144+
passed: outcome.is_ok(),
145+
diagnostic: outcome.err(),
146+
});
147+
}
148+
149+
let passed = results.iter().all(|result| result.passed);
150+
match output {
151+
OutputFormat::Text => {
152+
for result in &results {
153+
if result.passed {
154+
println!("PASS {}", result.name);
155+
} else {
156+
println!(
157+
"FAIL {}\n{}",
158+
result.name,
159+
result.diagnostic.as_deref().unwrap_or("unknown failure")
160+
);
161+
}
162+
}
163+
}
164+
OutputFormat::Json => println!(
165+
"{}",
166+
serde_json::to_string_pretty(&RunReport {
167+
scenarios: results,
168+
passed
169+
})
170+
.map_err(|error| error.to_string())?
171+
),
172+
}
173+
if passed {
174+
Ok(())
175+
} else {
176+
Err("one or more scenarios failed".to_string())
177+
}
178+
}
179+
180+
fn select_scenarios(requested: &[String]) -> Result<Vec<&'static Scenario>, String> {
181+
if requested.is_empty() {
182+
return Ok(scenarios().iter().collect());
183+
}
184+
requested
185+
.iter()
186+
.map(|name| {
187+
scenario(name).ok_or_else(|| {
188+
format!("unknown scenario '{name}'; run `openshell-conformance list`")
189+
})
190+
})
191+
.collect()
192+
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use clap::Parser;
197+
198+
use super::*;
199+
200+
#[test]
201+
fn selects_all_scenarios_by_default() {
202+
assert_eq!(
203+
select_scenarios(&[]).expect("select all").len(),
204+
scenarios().len()
205+
);
206+
}
207+
208+
#[test]
209+
fn selects_named_scenario() {
210+
let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke");
211+
assert_eq!(selected[0].name, "smoke");
212+
}
213+
214+
#[test]
215+
fn unknown_scenario_has_actionable_diagnostic() {
216+
let error = select_scenarios(&["missing".to_string()]).expect_err("unknown scenario");
217+
assert!(error.contains("openshell-conformance list"));
218+
}
219+
220+
#[test]
221+
fn parses_binary_override_and_json_output() {
222+
let cli = Cli::try_parse_from([
223+
"openshell-conformance",
224+
"run",
225+
"smoke",
226+
"--openshell-bin",
227+
"/opt/openshell",
228+
"--output",
229+
"json",
230+
])
231+
.expect("parse CLI");
232+
let Command::Run {
233+
openshell_bin,
234+
output,
235+
..
236+
} = cli.command
237+
else {
238+
panic!("expected run")
239+
};
240+
assert_eq!(openshell_bin, Some(PathBuf::from("/opt/openshell")));
241+
assert_eq!(output, OutputFormat::Json);
242+
}
243+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
[package]
5+
name = "openshell-conformance"
6+
description = "Reusable OpenShell CLI conformance scenarios and runner"
7+
version.workspace = true
8+
edition.workspace = true
9+
rust-version.workspace = true
10+
license.workspace = true
11+
repository.workspace = true
12+
13+
[dependencies]
14+
rand.workspace = true
15+
serde.workspace = true
16+
serde_json.workspace = true
17+
tokio.workspace = true
18+
19+
[dev-dependencies]
20+
tempfile = "3"
21+
22+
[lints]
23+
workspace = true

e2e/rust/src/harness/conformance/executor.rs renamed to crates/openshell-conformance/src/executor.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,24 @@ use std::time::Duration;
1111

1212
use tokio::time::timeout;
1313

14-
pub(super) type CliExecution<'a> =
14+
pub type CliExecution<'a> =
1515
Pin<Box<dyn Future<Output = Result<Output, CliExecutionError>> + Send + 'a>>;
1616

17-
pub(super) trait CliExecutor: Send + Sync {
17+
pub trait CliExecutor: Send + Sync {
1818
fn execute(&self, args: Vec<String>, command_timeout: Duration) -> CliExecution<'_>;
1919
}
2020

21-
pub(super) enum CliExecutionError {
21+
pub enum CliExecutionError {
2222
Spawn(std::io::Error),
2323
Timeout,
2424
}
2525

26-
pub(super) struct ProcessCli {
26+
pub struct ProcessCli {
2727
binary: PathBuf,
2828
}
2929

3030
impl ProcessCli {
31-
pub(super) fn new(binary: PathBuf) -> Self {
31+
pub fn new(binary: PathBuf) -> Self {
3232
Self { binary }
3333
}
3434
}

0 commit comments

Comments
 (0)