Skip to content

Commit c8a882b

Browse files
committed
Add command to citool for generating a test dashboard
1 parent 111c15c commit c8a882b

File tree

7 files changed

+433
-1
lines changed

7 files changed

+433
-1
lines changed

Diff for: src/ci/citool/Cargo.lock

+67
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,63 @@ version = "1.0.95"
6464
source = "registry+https://github.com/rust-lang/crates.io-index"
6565
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
6666

67+
[[package]]
68+
name = "askama"
69+
version = "0.13.1"
70+
source = "registry+https://github.com/rust-lang/crates.io-index"
71+
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
72+
dependencies = [
73+
"askama_derive",
74+
"itoa",
75+
"percent-encoding",
76+
"serde",
77+
"serde_json",
78+
]
79+
80+
[[package]]
81+
name = "askama_derive"
82+
version = "0.13.1"
83+
source = "registry+https://github.com/rust-lang/crates.io-index"
84+
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
85+
dependencies = [
86+
"askama_parser",
87+
"basic-toml",
88+
"memchr",
89+
"proc-macro2",
90+
"quote",
91+
"rustc-hash",
92+
"serde",
93+
"serde_derive",
94+
"syn",
95+
]
96+
97+
[[package]]
98+
name = "askama_parser"
99+
version = "0.13.0"
100+
source = "registry+https://github.com/rust-lang/crates.io-index"
101+
checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
102+
dependencies = [
103+
"memchr",
104+
"serde",
105+
"serde_derive",
106+
"winnow",
107+
]
108+
67109
[[package]]
68110
name = "base64"
69111
version = "0.22.1"
70112
source = "registry+https://github.com/rust-lang/crates.io-index"
71113
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
72114

115+
[[package]]
116+
name = "basic-toml"
117+
version = "0.1.10"
118+
source = "registry+https://github.com/rust-lang/crates.io-index"
119+
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
120+
dependencies = [
121+
"serde",
122+
]
123+
73124
[[package]]
74125
name = "build_helper"
75126
version = "0.1.0"
@@ -104,6 +155,7 @@ name = "citool"
104155
version = "0.1.0"
105156
dependencies = [
106157
"anyhow",
158+
"askama",
107159
"build_helper",
108160
"clap",
109161
"csv",
@@ -646,6 +698,12 @@ dependencies = [
646698
"windows-sys 0.52.0",
647699
]
648700

701+
[[package]]
702+
name = "rustc-hash"
703+
version = "2.1.1"
704+
source = "registry+https://github.com/rust-lang/crates.io-index"
705+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
706+
649707
[[package]]
650708
name = "rustls"
651709
version = "0.23.23"
@@ -1026,6 +1084,15 @@ version = "0.52.6"
10261084
source = "registry+https://github.com/rust-lang/crates.io-index"
10271085
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
10281086

1087+
[[package]]
1088+
name = "winnow"
1089+
version = "0.7.6"
1090+
source = "registry+https://github.com/rust-lang/crates.io-index"
1091+
checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10"
1092+
dependencies = [
1093+
"memchr",
1094+
]
1095+
10291096
[[package]]
10301097
name = "write16"
10311098
version = "1.0.0"

Diff for: src/ci/citool/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ edition = "2021"
55

66
[dependencies]
77
anyhow = "1"
8+
askama = "0.13"
89
clap = { version = "4.5", features = ["derive"] }
910
csv = "1"
1011
diff = "0.1"

Diff for: src/ci/citool/src/main.rs

+15-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod datadog;
44
mod github;
55
mod jobs;
66
mod metrics;
7+
mod test_dashboard;
78
mod utils;
89

910
use std::collections::{BTreeMap, HashMap};
@@ -22,6 +23,7 @@ use crate::datadog::upload_datadog_metric;
2223
use crate::github::JobInfoResolver;
2324
use crate::jobs::RunType;
2425
use crate::metrics::{JobMetrics, download_auto_job_metrics, download_job_metrics, load_metrics};
26+
use crate::test_dashboard::generate_test_dashboard;
2527
use crate::utils::load_env_var;
2628

2729
const CI_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
@@ -234,6 +236,14 @@ enum Args {
234236
/// Current commit that will be compared to `parent`.
235237
current: String,
236238
},
239+
/// Generate a directory containing a HTML dashboard of test results from a CI run.
240+
TestDashboard {
241+
/// Commit SHA that was tested on CI to analyze.
242+
current: String,
243+
/// Output path for the HTML directory.
244+
#[clap(long)]
245+
output_dir: PathBuf,
246+
},
237247
}
238248

239249
#[derive(clap::ValueEnum, Clone)]
@@ -275,7 +285,11 @@ fn main() -> anyhow::Result<()> {
275285
postprocess_metrics(metrics_path, parent, job_name)?;
276286
}
277287
Args::PostMergeReport { current, parent } => {
278-
post_merge_report(load_db(default_jobs_file)?, current, parent)?;
288+
post_merge_report(load_db(&default_jobs_file)?, current, parent)?;
289+
}
290+
Args::TestDashboard { current, output_dir } => {
291+
let db = load_db(&default_jobs_file)?;
292+
generate_test_dashboard(db, &current, &output_dir)?;
279293
}
280294
}
281295

Diff for: src/ci/citool/src/test_dashboard/mod.rs

+239
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use std::collections::{BTreeMap, HashMap};
2+
use std::fs::File;
3+
use std::io::BufWriter;
4+
use std::path::{Path, PathBuf};
5+
6+
use askama::Template;
7+
use build_helper::metrics::{TestOutcome, TestSuiteMetadata};
8+
9+
use crate::jobs::JobDatabase;
10+
use crate::metrics::{JobMetrics, JobName, download_auto_job_metrics, get_test_suites};
11+
use crate::utils::normalize_path_delimiters;
12+
13+
pub struct TestInfo {
14+
name: String,
15+
jobs: Vec<JobTestResult>,
16+
}
17+
18+
struct JobTestResult {
19+
job_name: String,
20+
outcome: TestOutcome,
21+
}
22+
23+
#[derive(Default)]
24+
struct TestSuiteInfo {
25+
name: String,
26+
tests: BTreeMap<String, TestInfo>,
27+
}
28+
29+
/// Generate a set of HTML files into a directory that contain a dashboard of test results.
30+
pub fn generate_test_dashboard(
31+
db: JobDatabase,
32+
current: &str,
33+
output_dir: &Path,
34+
) -> anyhow::Result<()> {
35+
let metrics = download_auto_job_metrics(&db, None, current)?;
36+
37+
let suites = gather_test_suites(&metrics);
38+
39+
std::fs::create_dir_all(output_dir)?;
40+
41+
let test_count = suites.test_count();
42+
write_page(output_dir, "index.html", &TestSuitesPage { suites, test_count })?;
43+
44+
Ok(())
45+
}
46+
47+
fn write_page<T: Template>(dir: &Path, name: &str, template: &T) -> anyhow::Result<()> {
48+
let mut file = BufWriter::new(File::create(dir.join(name))?);
49+
Template::write_into(template, &mut file)?;
50+
Ok(())
51+
}
52+
53+
fn gather_test_suites(job_metrics: &HashMap<JobName, JobMetrics>) -> TestSuites {
54+
struct CoarseTestSuite<'a> {
55+
kind: TestSuiteKind,
56+
tests: BTreeMap<String, Test<'a>>,
57+
}
58+
59+
let mut suites: HashMap<String, CoarseTestSuite> = HashMap::new();
60+
61+
// First, gather tests from all jobs, stages and targets, and aggregate them per suite
62+
for (job, metrics) in job_metrics {
63+
let test_suites = get_test_suites(&metrics.current);
64+
for suite in test_suites {
65+
let (suite_name, stage, target, kind) = match &suite.metadata {
66+
TestSuiteMetadata::CargoPackage { crates, stage, target, .. } => {
67+
(crates.join(","), *stage, target, TestSuiteKind::Cargo)
68+
}
69+
TestSuiteMetadata::Compiletest { suite, stage, target, .. } => {
70+
(suite.clone(), *stage, target, TestSuiteKind::Compiletest)
71+
}
72+
};
73+
let suite_entry = suites
74+
.entry(suite_name.clone())
75+
.or_insert_with(|| CoarseTestSuite { kind, tests: Default::default() });
76+
let test_metadata = TestMetadata { job, stage, target };
77+
78+
for test in &suite.tests {
79+
let test_name = normalize_test_name(&test.name, &suite_name);
80+
let test_entry = suite_entry
81+
.tests
82+
.entry(test_name.clone())
83+
.or_insert_with(|| Test { name: test_name, passed: vec![], ignored: vec![] });
84+
match test.outcome {
85+
TestOutcome::Passed => {
86+
test_entry.passed.push(test_metadata);
87+
}
88+
TestOutcome::Ignored { ignore_reason: _ } => {
89+
test_entry.ignored.push(test_metadata);
90+
}
91+
TestOutcome::Failed => {
92+
eprintln!("Warning: failed test");
93+
}
94+
}
95+
}
96+
}
97+
}
98+
99+
// Then, split the suites per directory
100+
let mut suites = suites.into_iter().collect::<Vec<_>>();
101+
suites.sort_by(|a, b| a.1.kind.cmp(&b.1.kind).then_with(|| a.0.cmp(&b.0)));
102+
103+
let mut target_suites = vec![];
104+
for (suite_name, suite) in suites {
105+
let suite = match suite.kind {
106+
TestSuiteKind::Compiletest => TestSuite {
107+
name: suite_name.clone(),
108+
kind: TestSuiteKind::Compiletest,
109+
group: build_test_group(&suite_name, suite.tests),
110+
},
111+
TestSuiteKind::Cargo => {
112+
let mut tests: Vec<_> = suite.tests.into_iter().collect();
113+
tests.sort_by(|a, b| a.0.cmp(&b.0));
114+
TestSuite {
115+
name: format!("[cargo] {}", suite_name.clone()),
116+
kind: TestSuiteKind::Cargo,
117+
group: TestGroup {
118+
name: suite_name,
119+
root_tests: tests.into_iter().map(|t| t.1).collect(),
120+
groups: vec![],
121+
},
122+
}
123+
}
124+
};
125+
target_suites.push(suite);
126+
}
127+
128+
TestSuites { suites: target_suites }
129+
}
130+
131+
/// Recursively expand a test group based on filesystem hierarchy.
132+
fn build_test_group<'a>(name: &str, tests: BTreeMap<String, Test<'a>>) -> TestGroup<'a> {
133+
let mut root_tests = vec![];
134+
let mut subdirs: BTreeMap<String, BTreeMap<String, Test<'a>>> = Default::default();
135+
136+
// Split tests into root tests and tests located in subdirectories
137+
for (name, test) in tests {
138+
let mut components = Path::new(&name).components().peekable();
139+
let subdir = components.next().unwrap();
140+
141+
if components.peek().is_none() {
142+
// This is a root test
143+
root_tests.push(test);
144+
} else {
145+
// This is a test in a nested directory
146+
let subdir_tests =
147+
subdirs.entry(subdir.as_os_str().to_str().unwrap().to_string()).or_default();
148+
let test_name =
149+
components.into_iter().collect::<PathBuf>().to_str().unwrap().to_string();
150+
subdir_tests.insert(test_name, test);
151+
}
152+
}
153+
let dirs = subdirs
154+
.into_iter()
155+
.map(|(name, tests)| {
156+
let group = build_test_group(&name, tests);
157+
(name, group)
158+
})
159+
.collect();
160+
161+
TestGroup { name: name.to_string(), root_tests, groups: dirs }
162+
}
163+
164+
/// Compiletest tests start with `[suite] tests/[suite]/a/b/c...`.
165+
/// Remove the `[suite] tests/[suite]/` prefix so that we can find the filesystem path.
166+
/// Also normalizes path delimiters.
167+
fn normalize_test_name(name: &str, suite_name: &str) -> String {
168+
let name = normalize_path_delimiters(name);
169+
let name = name.as_ref();
170+
let name = name.strip_prefix(&format!("[{suite_name}]")).unwrap_or(name).trim();
171+
let name = name.strip_prefix("tests/").unwrap_or(name);
172+
let name = name.strip_prefix(suite_name).unwrap_or(name);
173+
name.trim_start_matches("/").to_string()
174+
}
175+
176+
#[derive(serde::Serialize)]
177+
struct TestSuites<'a> {
178+
suites: Vec<TestSuite<'a>>,
179+
}
180+
181+
impl<'a> TestSuites<'a> {
182+
fn test_count(&self) -> u64 {
183+
self.suites.iter().map(|suite| suite.group.test_count()).sum::<u64>()
184+
}
185+
}
186+
187+
#[derive(serde::Serialize)]
188+
struct TestSuite<'a> {
189+
name: String,
190+
kind: TestSuiteKind,
191+
group: TestGroup<'a>,
192+
}
193+
194+
#[derive(Debug, serde::Serialize)]
195+
struct Test<'a> {
196+
name: String,
197+
passed: Vec<TestMetadata<'a>>,
198+
ignored: Vec<TestMetadata<'a>>,
199+
}
200+
201+
#[derive(Clone, Copy, Debug, serde::Serialize)]
202+
struct TestMetadata<'a> {
203+
job: &'a str,
204+
stage: u32,
205+
target: &'a str,
206+
}
207+
208+
// We have to use a template for the TestGroup instead of a macro, because
209+
// macros cannot be recursive in askama at the moment.
210+
#[derive(Template, serde::Serialize)]
211+
#[template(path = "test_group.askama")]
212+
/// Represents a group of tests
213+
struct TestGroup<'a> {
214+
name: String,
215+
/// Tests located directly in this directory
216+
root_tests: Vec<Test<'a>>,
217+
/// Nested directories with additional tests
218+
groups: Vec<(String, TestGroup<'a>)>,
219+
}
220+
221+
impl<'a> TestGroup<'a> {
222+
fn test_count(&self) -> u64 {
223+
let root = self.root_tests.len() as u64;
224+
self.groups.iter().map(|(_, group)| group.test_count()).sum::<u64>() + root
225+
}
226+
}
227+
228+
#[derive(PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
229+
enum TestSuiteKind {
230+
Compiletest,
231+
Cargo,
232+
}
233+
234+
#[derive(Template)]
235+
#[template(path = "test_suites.askama")]
236+
struct TestSuitesPage<'a> {
237+
suites: TestSuites<'a>,
238+
test_count: u64,
239+
}

0 commit comments

Comments
 (0)