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}