1use 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, TestReport, TestScript},
6 terminology::{BundleType, HttpVerb, IssueType, ReportResultCodes},
7 types::{Extension, ExtensionValueTypeChoice, FHIRBoolean, 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, warn};
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 #[arg(long)]
57 index_wait_ms: Option<u64>,
58 #[arg(long = "allow-failure", value_name = "TESTSCRIPT_ID")]
61 allowed_failures: Vec<String>,
62 },
63}
64
65fn load_testscript_files(path: &Path) -> Result<Vec<TestScript>, String> {
69 let mut testscripts = vec![];
70
71 let data = std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?;
72 let resource = serde_json::from_str::<Resource>(&data)
73 .map_err(|e| format!("Failed to parse FHIR resource: {e}"))?;
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 Ok(testscripts)
97}
98
99const EXPECTED_FAILURE_URL: &str =
103 "https://haste.health/fhir/StructureDefinition/testreport-expected-failure";
104
105fn mark_expected_failure(test_report: &mut TestReport) {
106 test_report
107 .extension
108 .get_or_insert_with(Vec::new)
109 .push(Extension {
110 url: EXPECTED_FAILURE_URL.to_string(),
111 value: Some(ExtensionValueTypeChoice::Boolean(Box::new(FHIRBoolean {
112 value: Some(true),
113 ..Default::default()
114 }))),
115 ..Default::default()
116 });
117}
118
119pub(crate) async fn run(
121 state: Arc<Mutex<CliState>>,
122 command: &TestScriptCommands,
123) -> Result<(), OperationOutcomeError> {
124 match command {
125 TestScriptCommands::Run {
126 output,
127 input: inputs,
128 wait_between_operations_ms,
129 index_wait_ms,
130 allowed_failures,
131 } => {
132 let fhir_client = crate::cli::client::fhir_client(state).await?;
133
134 let mut testreport_entries = vec![];
135 let testrunner_options = Arc::new(TestRunnerOptions {
136 wait_between_operations: wait_between_operations_ms
137 .map(|ms| std::time::Duration::from_millis(ms)),
138 index_wait_timeout: index_wait_ms.map(std::time::Duration::from_millis),
139 });
140
141 let mut status_code = 0;
142 let mut test_runs = JoinSet::new();
143 let is_allowed_failure = |test_report: &TestReport| {
144 test_report
145 .id
146 .as_ref()
147 .is_some_and(|id| allowed_failures.contains(id))
148 };
149
150 for input in inputs {
151 let walker = walkdir::WalkDir::new(&input).into_iter();
152
153 for entry in walker
154 .filter_map(|e| e.ok())
155 .filter(|e| e.metadata().unwrap().is_file())
156 .filter(|f| f.file_name().to_string_lossy().ends_with(".json"))
157 {
158 println!("Processing file: {}", entry.path().display());
159 let testscripts = match load_testscript_files(entry.path()) {
160 Ok(testscripts) => testscripts,
161 Err(e) => {
162 status_code = 1;
163 error!("Skipping {}: {e}", entry.path().display());
164 continue;
165 }
166 };
167 for testscript in testscripts.into_iter() {
168 let testscript = Arc::new(testscript);
169
170 let Some(testscript_id) = testscript.id.as_ref() else {
171 info!(
172 "Skipping TestScript without ID from file: {}",
173 entry.path().to_string_lossy()
174 );
175 continue;
176 };
177
178 info!(
179 "Running TestScript '{}' from file: {}",
180 testscript
181 .name
182 .value
183 .clone()
184 .unwrap_or("<Unnamed TestScript>".to_string()),
185 entry.path().to_string_lossy()
186 );
187
188 let testscript_id = testscript_id.clone();
189 let testscript_name = testscript
190 .name
191 .value
192 .clone()
193 .unwrap_or("<Unnamed TestScript>".to_string());
194 let testscript_file = entry.path().to_string_lossy().to_string();
195 let testrunner_options = testrunner_options.clone();
196 let fhir_client = fhir_client.clone();
197
198 test_runs.spawn(async move {
199 let result = haste_testscript_runner::run(
200 fhir_client.as_ref(),
201 TestScriptContext::default(),
202 testscript,
203 testrunner_options,
204 )
205 .await;
206
207 (
208 testscript_name,
209 testscript_file,
210 match result {
211 Ok(mut test_report) => {
212 test_report.id = Some(testscript_id);
213 Ok(test_report)
214 }
215 Err(e) => Err(e),
216 },
217 )
218 });
219 }
220 }
221 }
222
223 while let Some(Ok((testscript_name, testscript_file, res))) =
224 test_runs.join_next().await
225 {
226 match res {
227 Ok(mut test_report) => {
228 match &test_report.result.clone() {
229 r if r == &ReportResultCodes::pass()
231 || r == &ReportResultCodes::pending()
232 || r == &ReportResultCodes::null() =>
233 {
234 if is_allowed_failure(&test_report) {
235 warn!(
236 "TestScript '{testscript_name}' passed but is listed with --allow-failure; remove it (TestReport id: {})",
237 test_report.id.as_deref().unwrap_or("<none>")
238 );
239 }
240 }
241 r if r == &ReportResultCodes::fail()
242 && is_allowed_failure(&test_report) =>
243 {
244 warn!(
245 "TestScript '{testscript_name}' FAILED as expected (--allow-failure; file: {testscript_file}, TestReport id: {})",
246 test_report.id.as_deref().unwrap_or("<none>")
247 );
248 mark_expected_failure(&mut test_report);
249 }
250 r if r == &ReportResultCodes::fail() => {
251 status_code = 1;
252 error!(
253 "TestScript '{testscript_name}' FAILED (file: {testscript_file}, TestReport id: {})",
254 test_report.id.as_deref().unwrap_or("<none>")
255 );
256 }
257 _ => status_code = 1,
258 }
259
260 testreport_entries.push(BundleEntry {
261 request: Some(BundleEntryRequest {
262 method: HttpVerb::put(),
263 url: Box::new(FHIRUri {
264 value: Some(format!(
265 "TestReport/{}",
266 test_report.id.as_ref().map(|id| id.as_str()).unwrap_or("")
267 )),
268 ..Default::default()
269 }),
270 ..Default::default()
271 }),
272 resource: Some(Box::new(Resource::TestReport(test_report))),
273 ..Default::default()
274 });
275 }
276 Err(e) => {
277 status_code = 1;
278 error!(
279 "TestScript '{testscript_name}' ERRORED (file: {testscript_file}): {e:?}"
280 );
281 }
282 }
283 }
284
285 let testreport_bundle = Bundle {
286 type_: BundleType::transaction(),
287 entry: Some(testreport_entries),
288 ..Default::default()
289 };
290
291 if let Some(output) = output {
292 tokio::fs::write(
293 output,
294 serde_json::to_string(&testreport_bundle).map_err(|e| {
295 OperationOutcomeError::fatal(
296 IssueType::exception(),
297 format!("Failed to serialize TestReport bundle: {}", e),
298 )
299 })?,
300 )
301 .await
302 .expect("Failed to write TestReport bundle to file");
303 } else {
304 println!(
305 "{}",
306 serde_json::to_string(&testreport_bundle).map_err(|e| {
307 OperationOutcomeError::fatal(
308 IssueType::exception(),
309 format!("Failed to serialize TestReport bundle: {}", e),
310 )
311 })?
312 );
313 }
314
315 if status_code != 0 {
316 Err(OperationOutcomeError::fatal(
317 IssueType::exception(),
318 "One or more TestScripts failed".to_string(),
319 ))
320 } else {
321 Ok(())
322 }
323 }
324 }
325}