flowey_lib_common/
publish_test_results.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Publish test results.
5//!
6//! - On ADO, this will hook into the backend's native JUnit handling.
7//! - On Github, this will publish artifacts containing the raw JUnit XML file
8//!   and any optional attachments.
9//! - When running locally, this will optionally copy the XML files and any
10//!   attachments to the provided artifact directory.
11
12use crate::_util::copy_dir_all;
13use flowey::node::prelude::*;
14use std::collections::BTreeMap;
15
16flowey_request! {
17    pub struct Request {
18        /// Path to a junit.xml file
19        ///
20        /// HACK: this is an optional since `flowey` doesn't (yet?) have any way
21        /// to perform conditional-requests, and there are instances where nodes
22        /// will only conditionally output JUnit XML.
23        ///
24        /// To keep making forward progress, I've tweaked this node to accept an
25        /// optional... but this ain't great.
26        pub junit_xml: ReadVar<Option<PathBuf>>,
27        /// Brief string used when publishing the test.
28        /// Must be unique to the pipeline.
29        pub test_label: String,
30        /// Additional files or directories to upload.
31        ///
32        /// The boolean indicates whether the attachment is referenced in the
33        /// JUnit XML file. On backends with native JUnit attachment support,
34        /// these attachments will not be uploaded as distinct artifacts and
35        /// will instead be uploaded via the JUnit integration.
36        pub attachments: BTreeMap<String, (ReadVar<PathBuf>, bool)>,
37        /// Copy the xml file and attachments to the provided directory.
38        /// Only supported on local backend.
39        pub output_dir: Option<ReadVar<PathBuf>>,
40        /// Side-effect confirming that the publish has succeeded
41        pub done: WriteVar<SideEffect>,
42    }
43}
44
45new_flow_node!(struct Node);
46
47impl FlowNode for Node {
48    type Request = Request;
49
50    fn imports(ctx: &mut ImportCtx<'_>) {
51        ctx.import::<crate::ado_task_publish_test_results::Node>();
52    }
53
54    fn emit(requests: Vec<Self::Request>, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
55        let mut use_side_effects = Vec::new();
56        let mut resolve_side_effects = Vec::new();
57
58        for Request {
59            junit_xml,
60            test_label: label,
61            attachments,
62            output_dir,
63            done,
64        } in requests
65        {
66            resolve_side_effects.push(done);
67
68            if output_dir.is_some() && !matches!(ctx.backend(), FlowBackend::Local) {
69                anyhow::bail!(
70                    "Copying to a custom output directory is only supported on local backend."
71                )
72            }
73
74            let step_name = format!("publish test results: {label} (JUnit XML)");
75            let artifact_name = format!("{label}-junit-xml");
76
77            let has_junit_xml = junit_xml.map(ctx, |p| p.is_some());
78            let junit_xml = junit_xml.map(ctx, |p| p.unwrap_or_default());
79
80            match ctx.backend() {
81                FlowBackend::Ado => {
82                    use_side_effects.push(ctx.reqv(|v| {
83                        crate::ado_task_publish_test_results::Request {
84                            step_name,
85                            format:
86                                crate::ado_task_publish_test_results::AdoTestResultsFormat::JUnit,
87                            results_file: junit_xml,
88                            test_title: label.clone(),
89                            condition: Some(has_junit_xml),
90                            done: v,
91                        }
92                    }));
93                }
94                FlowBackend::Github => {
95                    let junit_xml = junit_xml.map(ctx, |p| {
96                        p.absolute().expect("invalid path").display().to_string()
97                    });
98
99                    // Note: usually flowey's built-in artifact publishing API
100                    // should be used instead of this, but here we need to
101                    // manually upload the artifact now so that it is still
102                    // uploaded even if the pipeline fails.
103                    use_side_effects.push(
104                        ctx.emit_gh_step(step_name, "actions/upload-artifact@v4")
105                            .condition(has_junit_xml)
106                            .with("name", artifact_name)
107                            .with("path", junit_xml)
108                            .finish(ctx),
109                    );
110                }
111                FlowBackend::Local => {
112                    if let Some(output_dir) = output_dir.clone() {
113                        ctx.emit_rust_step(step_name, |ctx| {
114                            let output_dir = output_dir.claim(ctx);
115                            let has_junit_xml = has_junit_xml.claim(ctx);
116                            let junit_xml = junit_xml.claim(ctx);
117
118                            move |rt| {
119                                let output_dir = rt.read(output_dir);
120                                let has_junit_xml = rt.read(has_junit_xml);
121                                let junit_xml = rt.read(junit_xml);
122
123                                if has_junit_xml {
124                                    fs_err::copy(
125                                        junit_xml,
126                                        output_dir.join(format!("{artifact_name}.xml")),
127                                    )?;
128                                }
129
130                                Ok(())
131                            }
132                        });
133                    }
134                }
135            }
136
137            for (attachment_label, (attachment_path, publish_on_ado)) in attachments {
138                let step_name = format!("publish test results: {attachment_label} ({label})");
139                let artifact_name = format!("{label}-{attachment_label}");
140
141                let attachment_exists = attachment_path.map(ctx, |p| {
142                    p.exists()
143                        && (p.is_file()
144                            || p.read_dir()
145                                .expect("failed to read attachment dir")
146                                .next()
147                                .is_some())
148                });
149                let attachment_path_string = attachment_path.map(ctx, |p| {
150                    p.absolute().expect("invalid path").display().to_string()
151                });
152
153                match ctx.backend() {
154                    FlowBackend::Ado => {
155                        if publish_on_ado {
156                            let (published_read, published_write) = ctx.new_var();
157                            use_side_effects.push(published_read);
158
159                            // Note: usually flowey's built-in artifact publishing API
160                            // should be used instead of this, but here we need to
161                            // manually upload the artifact now so that it is still
162                            // uploaded even if the pipeline fails.
163                            ctx.emit_ado_step_with_condition(
164                                step_name.clone(),
165                                attachment_exists,
166                                |ctx| {
167                                    published_write.claim(ctx);
168                                    let attachment_path_string = attachment_path_string.claim(ctx);
169                                    move |rt| {
170                                        let path_var =
171                                            rt.get_var(attachment_path_string).as_raw_var_name();
172                                        // Artifact name includes the JobAttempt to
173                                        // differentiate between artifacts that were
174                                        // generated when rerunning failed jobs.
175                                        format!(
176                                            r#"
177                                            - publish: $({path_var})
178                                              artifact: {artifact_name}-$({})
179                                            "#,
180                                            AdoRuntimeVar::SYSTEM__JOB_ATTEMPT.as_raw_var_name()
181                                        )
182                                    }
183                                },
184                            );
185                        } else {
186                            use_side_effects.push(attachment_exists.into_side_effect());
187                            use_side_effects.push(attachment_path_string.into_side_effect());
188                        }
189                    }
190                    FlowBackend::Github => {
191                        // See above comment about manually publishing artifacts
192                        use_side_effects.push(
193                            ctx.emit_gh_step(step_name.clone(), "actions/upload-artifact@v4")
194                                .condition(attachment_exists)
195                                .with("name", artifact_name)
196                                .with("path", attachment_path_string)
197                                .finish(ctx),
198                        );
199                    }
200                    FlowBackend::Local => {
201                        if let Some(output_dir) = output_dir.clone() {
202                            ctx.emit_rust_step(step_name, |ctx| {
203                                let output_dir = output_dir.claim(ctx);
204                                let attachment_exists = attachment_exists.claim(ctx);
205                                let attachment_path = attachment_path.claim(ctx);
206
207                                move |rt| {
208                                    let output_dir = rt.read(output_dir);
209                                    let attachment_exists = rt.read(attachment_exists);
210                                    let attachment_path = rt.read(attachment_path);
211
212                                    if attachment_exists {
213                                        copy_dir_all(
214                                            attachment_path,
215                                            output_dir.join(artifact_name),
216                                        )?;
217                                    }
218
219                                    Ok(())
220                                }
221                            });
222                        }
223                    }
224                }
225            }
226        }
227        ctx.emit_side_effect_step(use_side_effects, resolve_side_effects);
228
229        Ok(())
230    }
231}