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                        use_side_effects.push(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                    } else {
134                        use_side_effects.push(has_junit_xml.into_side_effect());
135                        use_side_effects.push(junit_xml.into_side_effect());
136                    }
137                }
138            }
139
140            for (attachment_label, (attachment_path, publish_on_ado)) in attachments {
141                let step_name = format!("publish test results: {label} ({attachment_label})");
142                let artifact_name = format!("{label}-{attachment_label}");
143
144                let attachment_exists = attachment_path.map(ctx, |p| {
145                    p.exists()
146                        && (p.is_file()
147                            || p.read_dir()
148                                .expect("failed to read attachment dir")
149                                .next()
150                                .is_some())
151                });
152                let attachment_path_string = attachment_path.map(ctx, |p| {
153                    p.absolute().expect("invalid path").display().to_string()
154                });
155
156                match ctx.backend() {
157                    FlowBackend::Ado => {
158                        if publish_on_ado {
159                            let (published_read, published_write) = ctx.new_var();
160                            use_side_effects.push(published_read);
161
162                            // Note: usually flowey's built-in artifact publishing API
163                            // should be used instead of this, but here we need to
164                            // manually upload the artifact now so that it is still
165                            // uploaded even if the pipeline fails.
166                            ctx.emit_ado_step_with_condition(
167                                step_name.clone(),
168                                attachment_exists,
169                                |ctx| {
170                                    published_write.claim(ctx);
171                                    let attachment_path_string = attachment_path_string.claim(ctx);
172                                    move |rt| {
173                                        let path_var =
174                                            rt.get_var(attachment_path_string).as_raw_var_name();
175                                        // Artifact name includes the JobAttempt to
176                                        // differentiate between artifacts that were
177                                        // generated when rerunning failed jobs.
178                                        format!(
179                                            r#"
180                                            - publish: $({path_var})
181                                              artifact: {artifact_name}-$({})
182                                            "#,
183                                            AdoRuntimeVar::SYSTEM__JOB_ATTEMPT.as_raw_var_name()
184                                        )
185                                    }
186                                },
187                            );
188                        } else {
189                            use_side_effects.push(attachment_exists.into_side_effect());
190                            use_side_effects.push(attachment_path_string.into_side_effect());
191                        }
192                    }
193                    FlowBackend::Github => {
194                        // See above comment about manually publishing artifacts
195                        use_side_effects.push(
196                            ctx.emit_gh_step(step_name.clone(), "actions/upload-artifact@v4")
197                                .condition(attachment_exists)
198                                .with("name", artifact_name)
199                                .with("path", attachment_path_string)
200                                .finish(ctx),
201                        );
202                    }
203                    FlowBackend::Local => {
204                        if let Some(output_dir) = output_dir.clone() {
205                            use_side_effects.push(ctx.emit_rust_step(step_name, |ctx| {
206                                let output_dir = output_dir.claim(ctx);
207                                let attachment_exists = attachment_exists.claim(ctx);
208                                let attachment_path = attachment_path.claim(ctx);
209
210                                move |rt| {
211                                    let output_dir = rt.read(output_dir);
212                                    let attachment_exists = rt.read(attachment_exists);
213                                    let attachment_path = rt.read(attachment_path);
214
215                                    if attachment_exists {
216                                        copy_dir_all(
217                                            attachment_path,
218                                            output_dir.join(artifact_name),
219                                        )?;
220                                    }
221
222                                    Ok(())
223                                }
224                            }));
225                        } else {
226                            use_side_effects.push(attachment_exists.into_side_effect());
227                        }
228                        use_side_effects.push(attachment_path_string.into_side_effect());
229                    }
230                }
231            }
232        }
233        ctx.emit_side_effect_step(use_side_effects, resolve_side_effects);
234
235        Ok(())
236    }
237}