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}