haste_health/commands/
testscript.rs1use crate::cli::state::CliState;
2use clap::Subcommand;
3use haste_fhir_client::http::{HeaderMap, HttpRequestHeaders, WithRequestHeaders};
4use haste_fhir_model::r4::generated::{
5 resources::{Bundle, BundleEntry, BundleEntryRequest, Resource, TestScript},
6 terminology::{BundleType, HttpVerb, IssueType, ReportResultCodes},
7 types::FHIRUri,
8};
9use haste_fhir_operation_error::OperationOutcomeError;
10use haste_testscript_runner::TestRunnerOptions;
11use std::{path::Path, sync::Arc};
12use tokio::{sync::Mutex, task::JoinSet};
13use tracing::{error, info};
14
15#[derive(Debug, Default, Clone)]
20pub(crate) struct TestScriptContext {
21 headers: Option<HeaderMap>,
22}
23
24impl HttpRequestHeaders for TestScriptContext {
25 fn request_headers(&self) -> Option<&HeaderMap> {
26 self.headers.as_ref()
27 }
28}
29
30impl WithRequestHeaders for TestScriptContext {
31 fn with_request_headers(mut self, headers: HeaderMap) -> Self {
32 self.headers = Some(headers);
33 self
34 }
35}
36
37#[derive(Subcommand, Debug)]
39pub(crate) enum TestScriptCommands {
40 Run {
43 #[arg(short, long)]
45 input: Vec<String>,
46 #[arg(short, long)]
48 output: Option<String>,
49 #[arg(short, long)]
51 wait_between_operations_ms: Option<u64>,
52 },
53}
54
55fn load_testscript_files(path: &Path) -> Vec<TestScript> {
56 let mut testscripts = vec![];
57
58 let Ok(data) = std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))
59 else {
60 return vec![];
61 };
62
63 let resource = match serde_json::from_str::<Resource>(&data) {
64 Ok(resource) => resource,
65 Err(e) => {
66 println!(
67 "Failed to parse FHIR resource from file {}: {}",
68 path.display(),
69 e
70 );
71 return vec![];
72 }
73 };
74
75 match resource {
76 Resource::Bundle(bundle) => bundle
77 .entry
78 .unwrap_or(vec![])
79 .into_iter()
80 .for_each(|entry| {
81 if let Some(resource) = entry.resource {
82 match *resource {
83 Resource::TestScript(testscript) => {
84 testscripts.push(testscript);
85 }
86 _ => {}
87 }
88 }
89 }),
90 Resource::TestScript(testscript) => {
91 testscripts.push(testscript);
92 }
93 _ => {}
94 }
95
96 testscripts
97}
98
99pub(crate) async fn run(
101 state: Arc<Mutex<CliState>>,
102 command: &TestScriptCommands,
103) -> Result<(), OperationOutcomeError> {
104 match command {
105 TestScriptCommands::Run {
106 output,
107 input: inputs,
108 wait_between_operations_ms,
109 } => {
110 let fhir_client = crate::cli::client::fhir_client(state).await?;
111
112 let mut testreport_entries = vec![];
113 let testrunner_options = Arc::new(TestRunnerOptions {
114 wait_between_operations: wait_between_operations_ms
115 .map(|ms| std::time::Duration::from_millis(ms)),
116 });
117
118 let mut status_code = 0;
119 let mut test_runs = JoinSet::new();
120
121 for input in inputs {
122 let walker = walkdir::WalkDir::new(&input).into_iter();
123
124 for entry in walker
125 .filter_map(|e| e.ok())
126 .filter(|e| e.metadata().unwrap().is_file())
127 .filter(|f| f.file_name().to_string_lossy().ends_with(".json"))
128 {
129 println!("Processing file: {}", entry.path().display());
130 let testscripts = load_testscript_files(&entry.path());
131 for testscript in testscripts.into_iter() {
132 let testscript = Arc::new(testscript);
133
134 let Some(testscript_id) = testscript.id.as_ref() else {
135 info!(
136 "Skipping TestScript without ID from file: {}",
137 entry.path().to_string_lossy()
138 );
139 continue;
140 };
141
142 info!(
143 "Running TestScript '{}' from file: {}",
144 testscript
145 .name
146 .value
147 .clone()
148 .unwrap_or("<Unnamed TestScript>".to_string()),
149 entry.path().to_string_lossy()
150 );
151
152 let testscript_id = testscript_id.clone();
153 let testscript_name = testscript
154 .name
155 .value
156 .clone()
157 .unwrap_or("<Unnamed TestScript>".to_string());
158 let testscript_file = entry.path().to_string_lossy().to_string();
159 let testrunner_options = testrunner_options.clone();
160 let fhir_client = fhir_client.clone();
161
162 test_runs.spawn(async move {
163 let result = haste_testscript_runner::run(
164 fhir_client.as_ref(),
165 TestScriptContext::default(),
166 testscript,
167 testrunner_options,
168 )
169 .await;
170
171 (
172 testscript_name,
173 testscript_file,
174 match result {
175 Ok(mut test_report) => {
176 test_report.id = Some(testscript_id);
177 Ok(test_report)
178 }
179 Err(e) => Err(e),
180 },
181 )
182 });
183 }
184 }
185 }
186
187 while let Some(Ok((testscript_name, testscript_file, res))) =
188 test_runs.join_next().await
189 {
190 match res {
191 Ok(test_report) => {
192 match &test_report.result {
193 r if r == &ReportResultCodes::pass()
195 || r == &ReportResultCodes::pending()
196 || r == &ReportResultCodes::null() => {}
197 r if r == &ReportResultCodes::fail() => {
198 status_code = 1;
199 error!(
200 "TestScript '{testscript_name}' FAILED (file: {testscript_file}, TestReport id: {})",
201 test_report.id.as_deref().unwrap_or("<none>")
202 );
203 }
204 _ => status_code = 1,
205 }
206
207 testreport_entries.push(BundleEntry {
208 request: Some(BundleEntryRequest {
209 method: HttpVerb::put(),
210 url: Box::new(FHIRUri {
211 value: Some(format!(
212 "TestReport/{}",
213 test_report.id.as_ref().map(|id| id.as_str()).unwrap_or("")
214 )),
215 ..Default::default()
216 }),
217 ..Default::default()
218 }),
219 resource: Some(Box::new(Resource::TestReport(test_report))),
220 ..Default::default()
221 });
222 }
223 Err(e) => {
224 status_code = 1;
225 error!(
226 "TestScript '{testscript_name}' ERRORED (file: {testscript_file}): {e:?}"
227 );
228 }
229 }
230 }
231
232 let testreport_bundle = Bundle {
233 type_: BundleType::transaction(),
234 entry: Some(testreport_entries),
235 ..Default::default()
236 };
237
238 if let Some(output) = output {
239 tokio::fs::write(
240 output,
241 serde_json::to_string(&testreport_bundle).map_err(|e| {
242 OperationOutcomeError::fatal(
243 IssueType::exception(),
244 format!("Failed to serialize TestReport bundle: {}", e),
245 )
246 })?,
247 )
248 .await
249 .expect("Failed to write TestReport bundle to file");
250 } else {
251 println!(
252 "{}",
253 serde_json::to_string(&testreport_bundle).map_err(|e| {
254 OperationOutcomeError::fatal(
255 IssueType::exception(),
256 format!("Failed to serialize TestReport bundle: {}", e),
257 )
258 })?
259 );
260 }
261
262 if status_code != 0 {
263 Err(OperationOutcomeError::fatal(
264 IssueType::exception(),
265 "One or more TestScripts failed".to_string(),
266 ))
267 } else {
268 Ok(())
269 }
270 }
271 }
272}