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.
This commit is contained in:
Benjamin Lee 2024-06-21 00:17:06 -07:00
parent ef6eb27b9b
commit e4e224f5dc
No known key found for this signature in database
GPG key ID: FB9624E2885D55A4
5 changed files with 277 additions and 8 deletions

57
Cargo.lock generated
View file

@ -453,6 +453,19 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" 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]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -609,6 +622,12 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "encode_unicode"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
[[package]] [[package]]
name = "enum-as-inner" name = "enum-as-inner"
version = "0.6.1" version = "0.6.1"
@ -1159,6 +1178,28 @@ dependencies = [
"serde", "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]] [[package]]
name = "ipconfig" name = "ipconfig"
version = "0.3.2" version = "0.3.2"
@ -1558,6 +1599,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.4" version = "0.36.4"
@ -1831,6 +1878,12 @@ dependencies = [
"miniz_oxide 0.7.4", "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]] [[package]]
name = "powerfmt" name = "powerfmt"
version = "0.2.0" version = "0.2.0"
@ -3812,7 +3865,11 @@ name = "xtask"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"indicatif",
"miette", "miette",
"serde",
"serde_json",
"strum",
"xshell", "xshell",
] ]

View file

@ -97,6 +97,7 @@ http-body-util = "0.1.2"
hyper = "1.4.1" hyper = "1.4.1"
hyper-util = { version = "0.1.8", features = ["client", "client-legacy", "service"] } hyper-util = { version = "0.1.8", features = ["client", "client-legacy", "service"] }
image = { version = "0.25.2", default-features = false, features = ["jpeg", "png", "gif"] } image = { version = "0.25.2", default-features = false, features = ["jpeg", "png", "gif"] }
indicatif = "0.17.8"
jsonwebtoken = "9.3.0" jsonwebtoken = "9.3.0"
lru-cache = "0.1.2" lru-cache = "0.1.2"
miette = { version = "7.2.0", features = ["fancy"] } 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"] } rusqlite = { version = "0.32.1", features = ["bundled"] }
rustls = { version = "0.23.13", default-features = false, features = ["ring", "log", "logging", "std", "tls12"] } rustls = { version = "0.23.13", default-features = false, features = ["ring", "log", "logging", "std", "tls12"] }
sd-notify = { version = "0.4.2" } 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_html_form = "0.2.6"
serde_json = { version = "1.0.128", features = ["raw_value"] } serde_json = { version = "1.0.128", features = ["raw_value"] }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"

View file

@ -8,6 +8,10 @@ rust-version.workspace = true
[dependencies] [dependencies]
clap.workspace = true clap.workspace = true
miette.workspace = true miette.workspace = true
indicatif.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
xshell.workspace = true xshell.workspace = true
[lints] [lints]

View file

@ -6,7 +6,10 @@ use xshell::{cmd, Shell};
mod docker; mod docker;
mod test2json; 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)] #[derive(clap::Args)]
pub(crate) struct 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( let docker_image = load_docker_image(&sh, &toplevel).wrap_err(
"failed to build and load complement-grapevine docker image", "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")?; .wrap_err("failed to run complement tests")?;
Ok(()) Ok(())
} }

View file

@ -2,18 +2,220 @@
//! //!
//! [test2json]: https://pkg.go.dev/cmd/test2json@go1.22.4 //! [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}; 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<u64> {
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 /// 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: handle SIG{INT,TERM}
// TODO: XTASK_PATH variable, so that we don't need to pollute devshell with // TODO: XTASK_PATH variable, so that we don't need to pollute devshell with
// go // 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_BASE_IMAGE", docker_image)
.env("COMPLEMENT_SPAWN_HS_TIMEOUT", "5") .env("COMPLEMENT_SPAWN_HS_TIMEOUT", "5")
.env("COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS", "1") .env("COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS", "1");
.run() eprintln!("$ {cmd}");
let child = Command::from(cmd)
.stdout(Stdio::piped())
.spawn()
.into_diagnostic() .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 <https://pkg.go.dev/cmd/test2json#hdr-Output_Format>
///
/// 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<String>,
},
Pass {
test: Option<String>,
},
Fail {
test: Option<String>,
},
Skip {
test: Option<String>,
},
#[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<String>) -> &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:?}");
}
};
}
} }