Skip to main content

haste_health/commands/
testscript.rs

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, 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/// Per-operation client context for a TestScript run.
16///
17/// Carries the headers declared by an operation's `requestHeader` entries so they
18/// reach the outgoing HTTP request. Commands that never set headers use `()` instead.
19#[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/// Run FHIR TestScript resources against the active profile's server.
38#[derive(Subcommand, Debug)]
39pub(crate) enum TestScriptCommands {
40    /// Run every TestScript resource found under the given input path(s), in parallel,
41    /// and write a transaction Bundle of the resulting TestReports.
42    Run {
43        /// File or directory to search for TestScript resources (JSON). Repeatable.
44        #[arg(short, long)]
45        input: Vec<String>,
46        /// Write the resulting TestReport bundle to this file instead of stdout.
47        #[arg(short, long)]
48        output: Option<String>,
49        /// Delay between operations within a TestScript, in milliseconds.
50        #[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
99/// Runs the `testscript` command group.
100pub(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                            // Ignore for rest.
194                            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}