Add command to citool for generating a test dashboard

This commit is contained in:
Jakub Beránek 2025-04-17 09:41:12 +02:00
parent 111c15c48e
commit c8a882b7b5
No known key found for this signature in database
GPG key ID: 909CD0D26483516B
7 changed files with 433 additions and 1 deletions

View file

@ -64,12 +64,63 @@ version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
name = "askama"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
dependencies = [
"askama_derive",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn",
]
[[package]]
name = "askama_parser"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "build_helper"
version = "0.1.0"
@ -104,6 +155,7 @@ name = "citool"
version = "0.1.0"
dependencies = [
"anyhow",
"askama",
"build_helper",
"clap",
"csv",
@ -646,6 +698,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustls"
version = "0.23.23"
@ -1026,6 +1084,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10"
dependencies = [
"memchr",
]
[[package]]
name = "write16"
version = "1.0.0"

View file

@ -5,6 +5,7 @@ edition = "2021"
[dependencies]
anyhow = "1"
askama = "0.13"
clap = { version = "4.5", features = ["derive"] }
csv = "1"
diff = "0.1"

View file

@ -4,6 +4,7 @@ mod datadog;
mod github;
mod jobs;
mod metrics;
mod test_dashboard;
mod utils;
use std::collections::{BTreeMap, HashMap};
@ -22,6 +23,7 @@ use crate::datadog::upload_datadog_metric;
use crate::github::JobInfoResolver;
use crate::jobs::RunType;
use crate::metrics::{JobMetrics, download_auto_job_metrics, download_job_metrics, load_metrics};
use crate::test_dashboard::generate_test_dashboard;
use crate::utils::load_env_var;
const CI_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
@ -234,6 +236,14 @@ enum Args {
/// Current commit that will be compared to `parent`.
current: String,
},
/// Generate a directory containing a HTML dashboard of test results from a CI run.
TestDashboard {
/// Commit SHA that was tested on CI to analyze.
current: String,
/// Output path for the HTML directory.
#[clap(long)]
output_dir: PathBuf,
},
}
#[derive(clap::ValueEnum, Clone)]
@ -275,7 +285,11 @@ fn main() -> anyhow::Result<()> {
postprocess_metrics(metrics_path, parent, job_name)?;
}
Args::PostMergeReport { current, parent } => {
post_merge_report(load_db(default_jobs_file)?, current, parent)?;
post_merge_report(load_db(&default_jobs_file)?, current, parent)?;
}
Args::TestDashboard { current, output_dir } => {
let db = load_db(&default_jobs_file)?;
generate_test_dashboard(db, &current, &output_dir)?;
}
}

View file

@ -0,0 +1,239 @@
use std::collections::{BTreeMap, HashMap};
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use askama::Template;
use build_helper::metrics::{TestOutcome, TestSuiteMetadata};
use crate::jobs::JobDatabase;
use crate::metrics::{JobMetrics, JobName, download_auto_job_metrics, get_test_suites};
use crate::utils::normalize_path_delimiters;
pub struct TestInfo {
name: String,
jobs: Vec<JobTestResult>,
}
struct JobTestResult {
job_name: String,
outcome: TestOutcome,
}
#[derive(Default)]
struct TestSuiteInfo {
name: String,
tests: BTreeMap<String, TestInfo>,
}
/// Generate a set of HTML files into a directory that contain a dashboard of test results.
pub fn generate_test_dashboard(
db: JobDatabase,
current: &str,
output_dir: &Path,
) -> anyhow::Result<()> {
let metrics = download_auto_job_metrics(&db, None, current)?;
let suites = gather_test_suites(&metrics);
std::fs::create_dir_all(output_dir)?;
let test_count = suites.test_count();
write_page(output_dir, "index.html", &TestSuitesPage { suites, test_count })?;
Ok(())
}
fn write_page<T: Template>(dir: &Path, name: &str, template: &T) -> anyhow::Result<()> {
let mut file = BufWriter::new(File::create(dir.join(name))?);
Template::write_into(template, &mut file)?;
Ok(())
}
fn gather_test_suites(job_metrics: &HashMap<JobName, JobMetrics>) -> TestSuites {
struct CoarseTestSuite<'a> {
kind: TestSuiteKind,
tests: BTreeMap<String, Test<'a>>,
}
let mut suites: HashMap<String, CoarseTestSuite> = HashMap::new();
// First, gather tests from all jobs, stages and targets, and aggregate them per suite
for (job, metrics) in job_metrics {
let test_suites = get_test_suites(&metrics.current);
for suite in test_suites {
let (suite_name, stage, target, kind) = match &suite.metadata {
TestSuiteMetadata::CargoPackage { crates, stage, target, .. } => {
(crates.join(","), *stage, target, TestSuiteKind::Cargo)
}
TestSuiteMetadata::Compiletest { suite, stage, target, .. } => {
(suite.clone(), *stage, target, TestSuiteKind::Compiletest)
}
};
let suite_entry = suites
.entry(suite_name.clone())
.or_insert_with(|| CoarseTestSuite { kind, tests: Default::default() });
let test_metadata = TestMetadata { job, stage, target };
for test in &suite.tests {
let test_name = normalize_test_name(&test.name, &suite_name);
let test_entry = suite_entry
.tests
.entry(test_name.clone())
.or_insert_with(|| Test { name: test_name, passed: vec![], ignored: vec![] });
match test.outcome {
TestOutcome::Passed => {
test_entry.passed.push(test_metadata);
}
TestOutcome::Ignored { ignore_reason: _ } => {
test_entry.ignored.push(test_metadata);
}
TestOutcome::Failed => {
eprintln!("Warning: failed test");
}
}
}
}
}
// Then, split the suites per directory
let mut suites = suites.into_iter().collect::<Vec<_>>();
suites.sort_by(|a, b| a.1.kind.cmp(&b.1.kind).then_with(|| a.0.cmp(&b.0)));
let mut target_suites = vec![];
for (suite_name, suite) in suites {
let suite = match suite.kind {
TestSuiteKind::Compiletest => TestSuite {
name: suite_name.clone(),
kind: TestSuiteKind::Compiletest,
group: build_test_group(&suite_name, suite.tests),
},
TestSuiteKind::Cargo => {
let mut tests: Vec<_> = suite.tests.into_iter().collect();
tests.sort_by(|a, b| a.0.cmp(&b.0));
TestSuite {
name: format!("[cargo] {}", suite_name.clone()),
kind: TestSuiteKind::Cargo,
group: TestGroup {
name: suite_name,
root_tests: tests.into_iter().map(|t| t.1).collect(),
groups: vec![],
},
}
}
};
target_suites.push(suite);
}
TestSuites { suites: target_suites }
}
/// Recursively expand a test group based on filesystem hierarchy.
fn build_test_group<'a>(name: &str, tests: BTreeMap<String, Test<'a>>) -> TestGroup<'a> {
let mut root_tests = vec![];
let mut subdirs: BTreeMap<String, BTreeMap<String, Test<'a>>> = Default::default();
// Split tests into root tests and tests located in subdirectories
for (name, test) in tests {
let mut components = Path::new(&name).components().peekable();
let subdir = components.next().unwrap();
if components.peek().is_none() {
// This is a root test
root_tests.push(test);
} else {
// This is a test in a nested directory
let subdir_tests =
subdirs.entry(subdir.as_os_str().to_str().unwrap().to_string()).or_default();
let test_name =
components.into_iter().collect::<PathBuf>().to_str().unwrap().to_string();
subdir_tests.insert(test_name, test);
}
}
let dirs = subdirs
.into_iter()
.map(|(name, tests)| {
let group = build_test_group(&name, tests);
(name, group)
})
.collect();
TestGroup { name: name.to_string(), root_tests, groups: dirs }
}
/// Compiletest tests start with `[suite] tests/[suite]/a/b/c...`.
/// Remove the `[suite] tests/[suite]/` prefix so that we can find the filesystem path.
/// Also normalizes path delimiters.
fn normalize_test_name(name: &str, suite_name: &str) -> String {
let name = normalize_path_delimiters(name);
let name = name.as_ref();
let name = name.strip_prefix(&format!("[{suite_name}]")).unwrap_or(name).trim();
let name = name.strip_prefix("tests/").unwrap_or(name);
let name = name.strip_prefix(suite_name).unwrap_or(name);
name.trim_start_matches("/").to_string()
}
#[derive(serde::Serialize)]
struct TestSuites<'a> {
suites: Vec<TestSuite<'a>>,
}
impl<'a> TestSuites<'a> {
fn test_count(&self) -> u64 {
self.suites.iter().map(|suite| suite.group.test_count()).sum::<u64>()
}
}
#[derive(serde::Serialize)]
struct TestSuite<'a> {
name: String,
kind: TestSuiteKind,
group: TestGroup<'a>,
}
#[derive(Debug, serde::Serialize)]
struct Test<'a> {
name: String,
passed: Vec<TestMetadata<'a>>,
ignored: Vec<TestMetadata<'a>>,
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
struct TestMetadata<'a> {
job: &'a str,
stage: u32,
target: &'a str,
}
// We have to use a template for the TestGroup instead of a macro, because
// macros cannot be recursive in askama at the moment.
#[derive(Template, serde::Serialize)]
#[template(path = "test_group.askama")]
/// Represents a group of tests
struct TestGroup<'a> {
name: String,
/// Tests located directly in this directory
root_tests: Vec<Test<'a>>,
/// Nested directories with additional tests
groups: Vec<(String, TestGroup<'a>)>,
}
impl<'a> TestGroup<'a> {
fn test_count(&self) -> u64 {
let root = self.root_tests.len() as u64;
self.groups.iter().map(|(_, group)| group.test_count()).sum::<u64>() + root
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
enum TestSuiteKind {
Compiletest,
Cargo,
}
#[derive(Template)]
#[template(path = "test_suites.askama")]
struct TestSuitesPage<'a> {
suites: TestSuites<'a>,
test_count: u64,
}

View file

@ -0,0 +1,71 @@
<html>
<head>
<meta charset="UTF-8">
<title>Rust CI Test Dashboard</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
max-width: 1500px;
margin: 0 auto;
padding: 20px;
background: #F5F5F5;
}
h1 {
text-align: center;
color: #333333;
margin-bottom: 30px;
}
.test-suites {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 20px;
}
ul {
padding-left: 0;
}
li {
list-style: none;
padding-left: 20px;
}
summary {
margin-bottom: 5px;
padding: 6px;
background-color: #F4F4F4;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
summary:hover {
background-color: #CFCFCF;
}
/* Style the disclosure triangles */
details > summary {
list-style: none;
position: relative;
}
details > summary::before {
content: "▶";
position: absolute;
left: -15px;
transform: rotate(0);
transition: transform 0.2s;
}
details[open] > summary::before {
transform: rotate(90deg);
}
</style>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,22 @@
<li>
<details>
<summary>{{ name }} ({{ test_count() }} test{{ test_count() | pluralize }})</summary>
{% if !groups.is_empty() %}
<ul>
{% for (dir_name, subgroup) in groups %}
{{ subgroup|safe }}
{% endfor %}
</ul>
{% endif %}
{% if !root_tests.is_empty() %}
<ul>
{% for test in root_tests %}
<li><b>{{ test.name }}</b> ({{ test.passed.len() }} passed, {{ test.ignored.len() }} ignored)</li>
{% endfor %}
</ul>
{% endif %}
</details>
</li>

View file

@ -0,0 +1,18 @@
{% extends "layout.askama" %}
{% block content %}
<h1>Rust CI Test Dashboard</h1>
<div class="test-suites">
<div class="summary">
Total tests: {{ test_count }}
</div>
<ul>
{% for suite in suites.suites %}
{% if suite.kind == TestSuiteKind::Compiletest %}
{{ suite.group|safe }}
{% endif %}
{% endfor %}
</ul>
</div>
{% endblock %}