From e4e224f5dc0abdfe9f2b4ee50fcc80cb2b1dd12a Mon Sep 17 00:00:00 2001 From: Benjamin Lee Date: Fri, 21 Jun 2024 00:17:06 -0700 Subject: [PATCH] add live progress display to complement wrapper Added the `derive` feature to the workspace serde dependency here. Previously, the dependency was only used in the main package, which ended up enabling the `derive` feature through transitive serde dependencies. This is not the case for xtask, so we need to enable it explicitly. --- Cargo.lock | 57 ++++++++ Cargo.toml | 3 +- xtask/Cargo.toml | 4 + xtask/src/complement.rs | 9 +- xtask/src/complement/test2json.rs | 212 +++++++++++++++++++++++++++++- 5 files changed, 277 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b161e921..8ce87cc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,6 +453,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -609,6 +622,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1159,6 +1178,28 @@ dependencies = [ "serde", ] +[[package]] +name = "indicatif" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +dependencies = [ + "console", + "instant", + "number_prefix", + "portable-atomic", + "unicode-width", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -1558,6 +1599,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "object" version = "0.36.4" @@ -1831,6 +1878,12 @@ dependencies = [ "miniz_oxide 0.7.4", ] +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + [[package]] name = "powerfmt" version = "0.2.0" @@ -3812,7 +3865,11 @@ name = "xtask" version = "0.1.0" dependencies = [ "clap", + "indicatif", "miette", + "serde", + "serde_json", + "strum", "xshell", ] diff --git a/Cargo.toml b/Cargo.toml index 9493e894..9bfe1f7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ http-body-util = "0.1.2" hyper = "1.4.1" hyper-util = { version = "0.1.8", features = ["client", "client-legacy", "service"] } image = { version = "0.25.2", default-features = false, features = ["jpeg", "png", "gif"] } +indicatif = "0.17.8" jsonwebtoken = "9.3.0" lru-cache = "0.1.2" miette = { version = "7.2.0", features = ["fancy"] } @@ -120,7 +121,7 @@ ruma = { git = "https://github.com/ruma/ruma", branch = "main", features = ["com rusqlite = { version = "0.32.1", features = ["bundled"] } rustls = { version = "0.23.13", default-features = false, features = ["ring", "log", "logging", "std", "tls12"] } sd-notify = { version = "0.4.2" } -serde = { version = "1.0.210", features = ["rc"] } +serde = { version = "1.0.210", features = ["rc", "derive"] } serde_html_form = "0.2.6" serde_json = { version = "1.0.128", features = ["raw_value"] } serde_yaml = "0.9.34" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 5d282c75..511e054f 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -8,6 +8,10 @@ rust-version.workspace = true [dependencies] clap.workspace = true miette.workspace = true +indicatif.workspace = true +serde.workspace = true +serde_json.workspace = true +strum.workspace = true xshell.workspace = true [lints] diff --git a/xtask/src/complement.rs b/xtask/src/complement.rs index 456e9a41..78ac6247 100644 --- a/xtask/src/complement.rs +++ b/xtask/src/complement.rs @@ -6,7 +6,10 @@ use xshell::{cmd, Shell}; mod docker; mod test2json; -use self::{docker::load_docker_image, test2json::run_complement}; +use self::{ + docker::load_docker_image, + test2json::{count_complement_tests, run_complement}, +}; #[derive(clap::Args)] pub(crate) struct Args; @@ -19,7 +22,9 @@ pub(crate) fn main(_args: Args) -> Result<()> { let docker_image = load_docker_image(&sh, &toplevel).wrap_err( "failed to build and load complement-grapevine docker image", )?; - run_complement(&sh, &docker_image) + let test_count = count_complement_tests(&sh, &docker_image) + .wrap_err("failed to determine total complement test count")?; + run_complement(&sh, &docker_image, test_count) .wrap_err("failed to run complement tests")?; Ok(()) } diff --git a/xtask/src/complement/test2json.rs b/xtask/src/complement/test2json.rs index 595813e3..bfc80aaa 100644 --- a/xtask/src/complement/test2json.rs +++ b/xtask/src/complement/test2json.rs @@ -2,18 +2,220 @@ //! //! [test2json]: https://pkg.go.dev/cmd/test2json@go1.22.4 -use miette::{IntoDiagnostic, Result}; +use std::{ + io::{BufRead, BufReader}, + process::{Command, Stdio}, + time::Duration, +}; + +use indicatif::{ProgressBar, ProgressStyle}; +use miette::{miette, IntoDiagnostic, LabeledSpan, Result, WrapErr}; +use serde::Deserialize; +use strum::Display; use xshell::{cmd, Shell}; +/// Returns the total number of complement tests that will be run +/// +/// This is only able to count toplevel tests, and will not included subtests +/// (`A/B`) +pub(crate) fn count_complement_tests( + sh: &Shell, + docker_image: &str, +) -> Result { + let test_list = cmd!(sh, "go tool test2json complement.test -test.list .*") + .env("COMPLEMENT_BASE_IMAGE", docker_image) + .read() + .into_diagnostic()?; + let test_count = u64::try_from(test_list.lines().count()) + .into_diagnostic() + .wrap_err("test count overflowed u64")?; + Ok(test_count) +} + /// Runs complement test suite -pub(crate) fn run_complement(sh: &Shell, docker_image: &str) -> Result<()> { +pub(crate) fn run_complement( + sh: &Shell, + docker_image: &str, + test_count: u64, +) -> Result<()> { // TODO: handle SIG{INT,TERM} // TODO: XTASK_PATH variable, so that we don't need to pollute devshell with // go - cmd!(sh, "go tool test2json complement.test -test.v=test2json") + let cmd = cmd!(sh, "go tool test2json complement.test -test.v=test2json") .env("COMPLEMENT_BASE_IMAGE", docker_image) .env("COMPLEMENT_SPAWN_HS_TIMEOUT", "5") - .env("COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS", "1") - .run() + .env("COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS", "1"); + eprintln!("$ {cmd}"); + let child = Command::from(cmd) + .stdout(Stdio::piped()) + .spawn() .into_diagnostic() + .wrap_err("error spawning complement process")?; + let stdout = child + .stdout + .expect("child process spawned with piped stdout should have stdout"); + let lines = BufReader::new(stdout).lines(); + + let mut ctx = TestContext::new(test_count); + for line in lines { + let line = line + .into_diagnostic() + .wrap_err("error reading output from complement process")?; + ctx.handle_line(&line); + } + + Ok(()) +} + +/// Schema from +/// +/// Only the fields that we need are included here. +#[derive(Deserialize)] +#[serde( + rename_all = "snake_case", + rename_all_fields = "PascalCase", + tag = "Action" +)] +enum GoTestEvent { + Run { + test: Option, + }, + Pass { + test: Option, + }, + Fail { + test: Option, + }, + Skip { + test: Option, + }, + #[serde(other)] + OtherAction, +} + +#[derive(Copy, Clone, Display, Debug)] +#[strum(serialize_all = "UPPERCASE")] +enum TestResult { + Pass, + Fail, + Skip, +} + +struct TestContext { + pb: ProgressBar, + pass_count: u64, + fail_count: u64, + skip_count: u64, +} + +/// Returns a string to use for displaying a test name +/// +/// From the test2json docs: +/// +/// > The Test field, if present, specifies the test, example, or benchmark +/// > function that caused the event. Events for the overall package test do not +/// > set Test. +/// +/// For events that do not have a `Test` field, we display their test name as +/// `"GLOBAL"` instead. +fn test_str(test: &Option) -> &str { + if let Some(test) = test { + test + } else { + "GLOBAL" + } +} + +/// Returns whether a test name is a toplevel test (as opposed to a subtest) +fn test_is_toplevel(test: &str) -> bool { + !test.contains('/') +} + +impl TestContext { + fn new(test_count: u64) -> TestContext { + // TODO: figure out how to display ETA without it fluctuating wildly. + let style = ProgressStyle::with_template( + "({msg}) {pos}/{len} [{elapsed}] {wide_bar}", + ) + .expect("static progress bar template should be valid") + .progress_chars("##-"); + let pb = ProgressBar::new(test_count).with_style(style); + pb.enable_steady_tick(Duration::from_secs(1)); + let ctx = TestContext { + pb, + pass_count: 0, + fail_count: 0, + skip_count: 0, + }; + ctx.update_progress(); + ctx + } + + fn update_progress(&self) { + self.pb + .set_position(self.pass_count + self.fail_count + self.skip_count); + self.pb.set_message(format!( + "PASS {}, FAIL {}, SKIP {}", + self.pass_count, self.fail_count, self.skip_count + )); + } + + fn handle_test_result(&mut self, test: &str, result: TestResult) { + self.pb.println(format!("=== {result}\t{test}")); + // 'complement.test -test.list' is only able to count toplevel tests + // ahead-of-time, so we don't include subtests in the pass/fail/skip + // counts. + if test_is_toplevel(test) { + match result { + TestResult::Pass => self.pass_count += 1, + TestResult::Fail => self.fail_count += 1, + TestResult::Skip => self.skip_count += 1, + } + self.update_progress(); + } + } + + fn handle_event(&mut self, event: GoTestEvent) { + match event { + GoTestEvent::OtherAction => (), + GoTestEvent::Run { + test, + } => { + self.pb.println(format!("=== RUN \t{}", test_str(&test))); + } + GoTestEvent::Pass { + test, + } => { + self.handle_test_result(test_str(&test), TestResult::Pass); + } + GoTestEvent::Fail { + test, + } => { + self.handle_test_result(test_str(&test), TestResult::Fail); + } + GoTestEvent::Skip { + test, + } => { + self.handle_test_result(test_str(&test), TestResult::Skip); + } + } + } + + /// Processes a line of output from `test2json` + fn handle_line(&mut self, line: &str) { + match serde_json::from_str(line) { + Ok(event) => self.handle_event(event), + Err(e) => { + let label = + LabeledSpan::at_offset(e.column() - 1, "error here"); + let report = miette!(labels = vec![label], "{e}",) + .with_source_code(line.to_owned()) + .wrap_err( + "failed to parse go test2json event from complement \ + tests. Ignoring this event.", + ); + eprintln!("{report:?}"); + } + }; + } }