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