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

View file

@ -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]

View file

@ -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(())
}

View file

@ -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<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
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 <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:?}");
}
};
}
}