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, 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/// 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        /// Before a search, an operation or a conditional delete/update, wait up
53        /// to this many milliseconds for the TestScript's latest writes to be
54        /// indexed. Search indexing is asynchronous; this waits only as long as
55        /// it takes.
56        #[arg(long)]
57        index_wait_ms: Option<u64>,
58        /// A TestScript id whose failure is known and shouldn't fail the run.
59        /// Its TestReport is still recorded as `fail`. Repeatable.
60        #[arg(long = "allow-failure", value_name = "TESTSCRIPT_ID")]
61        allowed_failures: Vec<String>,
62    },
63}
64
65/// The TestScripts in a file (a TestScript or a Bundle of them). A file that
66/// can't be read or parsed is an error, so a broken script fails the run
67/// instead of silently not running.
68fn 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
99/// Marks a TestReport whose failure is known (`--allow-failure`). It stays
100/// `fail`, so the result is truthful, but readers such as the website's
101/// coverage page can show its failures as expected rather than as errors.
102const 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
119/// Runs the `testscript` command group.
120pub(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                            // Ignore for rest.
230                            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}