diff --git a/Cargo.toml b/Cargo.toml index ae8dd429d..8d248db68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -275,5 +275,7 @@ license = "Apache-2.0" edition = "2021" [workspace.dependencies] -serde = { version = "1", features = ["serde_derive"] } +async-io = "2.3.1" +gui-test = { path = "it/gui-test" } mint = "0.5.6" +serde = { version = "1", features = ["serde_derive"] } diff --git a/it/gui-test/Cargo.toml b/it/gui-test/Cargo.toml index c83072fc2..ed7533634 100644 --- a/it/gui-test/Cargo.toml +++ b/it/gui-test/Cargo.toml @@ -8,8 +8,10 @@ edition.workspace = true [dependencies] async-executor = "1.8.0" -async-io = "2.3.1" +async-io.workspace = true async-lock = "3.3.0" async-process = "2.1.0" inventory = "0.3.15" +owo-colors = "4.0.0" serde = { workspace = true, features = ["derive"] } +serde_json = "1.0.114" diff --git a/it/gui-test/src/lib.rs b/it/gui-test/src/lib.rs index 4bcc956cd..880b98354 100644 --- a/it/gui-test/src/lib.rs +++ b/it/gui-test/src/lib.rs @@ -1,5 +1,9 @@ //! A testing framework that can be run remotely. +pub mod stream; +pub mod remote; +pub mod user; + use serde::{Deserialize, Serialize}; use std::env; @@ -17,7 +21,7 @@ pub use inventory as __inventory; /// Replacement for the `main` function. #[macro_export] macro_rules! main { - ($harness:expr) => { + ($handler:expr) => { fn main() { $crate::__entry($harness) } @@ -122,10 +126,7 @@ impl Harness { } // Send the "test started" event to the handler. - self.handler.handle_test(TestEvent { - runner: self.name.clone(), - ty: TestEventType::TestStarted { name: name.into() }, - }); + self.send_event(TestEventType::TestStarted { name: name.into() }); // Return the handle. Testing { @@ -156,10 +157,7 @@ impl Harness { } // Send the "group started" event to the handler. - self.handler.handle_test(TestEvent { - ty: TestEventType::GroupStarted { name: name.into() }, - runner: self.name.clone(), - }); + self.send_event(TestEventType::GroupStarted { name: name.into() }); // Return the handle. Grouping { harness: self } @@ -180,10 +178,7 @@ impl Harness { _ => {} } - self.handler.handle_test(TestEvent { - runner: self.name.clone(), - ty: TestEventType::TestEnded { result: reason }, - }); + self.send_event(TestEventType::TestEnded { result: reason }); let count = match mem::replace(&mut self.state, State::Default) { State::InTest { past_groups } => past_groups, @@ -198,10 +193,7 @@ impl Harness { /// End the current group. fn end_group(&mut self) { - self.handler.handle_test(TestEvent { - runner: self.name.clone(), - ty: TestEventType::GroupEnded, - }); + self.send_event(TestEventType::GroupEnded); let count = match mem::replace(&mut self.state, State::Default) { State::InGroups(groups) => groups, @@ -213,6 +205,26 @@ impl Harness { Some(groups) => State::InGroups(groups), }; } + + /// Send a test event of the provided type. + fn send_event(&mut self, ty: TestEventType) { + let event = TestEvent { + runner: self.name.clone(), + ty, + }; + + self.handler.handle_test(event); + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.send_event(TestEventType::Complete { + total: self.test_count, + fail: self.test_fails, + pass: self.test_passed, + }); + } } /// An in-progress test. @@ -279,14 +291,13 @@ pub trait TestHandler { } /// An event produced by the test harness. -#[non_exhaustive] #[derive(Debug, Serialize, Deserialize)] pub struct TestEvent { /// The name of the runner associated with the event. - runner: String, + pub runner: String, /// The type of the event. - ty: TestEventType, + pub ty: TestEventType, } /// The type of the event. @@ -294,7 +305,16 @@ pub struct TestEvent { #[derive(Debug, Serialize, Deserialize)] pub enum TestEventType { /// The tests are complete and the harness can be disconnected. - Complete, + Complete { + /// Total number of tests. + total: usize, + + /// Total number of passing tests. + pass: usize, + + /// Total number of failed tests. + fail: usize, + }, /// A test has started. TestStarted { name: String }, diff --git a/it/gui-test/src/remote.rs b/it/gui-test/src/remote.rs new file mode 100644 index 000000000..37154ce55 --- /dev/null +++ b/it/gui-test/src/remote.rs @@ -0,0 +1,30 @@ +//! Create a test handler that can be run remotely. + +use crate::TestHandler; +use crate::stream::WriteHandler; +use crate::user::UserHandler; + +use std::env; +use std::net::TcpStream; + +/// Create a test handler adjusted for the current environment. +pub fn handler() -> Box { + // If GUI_TEST_UNIX_STREAM is enabled, use that as a Unix stream. + #[cfg(unix)] + if let Some(stream_path) = env::var_os("GUI_TEST_UNIX_STREAM") + .filter(|s| !s.is_empty()) { + let stream = std::os::unix::net::UnixStream::connect(stream_path).expect("unable to connect to gui-test handler"); + return Box::new(WriteHandler::new(stream)); + } + + // If GUI_TEST_TCP_STREAM is enabled, use that as a TCP stream. + if let Some(tcp_ip) = env::var("GUI_TEST_TCP_STREAM") + .ok() + .filter(|s| !s.is_empty()) { + let stream = TcpStream::connect(tcp_ip).unwrap(); + return Box::new(WriteHandler::new(stream)); + } + + // By default, use the user handler. + Box::new(UserHandler::new()) +} diff --git a/it/gui-test/src/stream.rs b/it/gui-test/src/stream.rs new file mode 100644 index 000000000..18bf64d3f --- /dev/null +++ b/it/gui-test/src/stream.rs @@ -0,0 +1,34 @@ +//! Write events to an output stream. +//! +//! The format is as follows: +//! - First 8 bytes: big-endian length of payload. +//! - Next {len} bytes: JSON payload to deserialize from. + +use crate::{TestEvent, TestHandler}; +use std::io::Write; + +/// A wrapper around a writer that sends data down a stream. +pub struct WriteHandler { + /// The inner writer. + writer: W, +} + +impl WriteHandler { + /// Create a new write handler. + pub fn new(writer: W) -> Self { + Self { + writer + } + } +} + +impl TestHandler for WriteHandler { + fn handle_test(&mut self, event: TestEvent) { + let payload = serde_json::to_vec(&event).unwrap(); + let length = u64::to_be_bytes(payload.len() as u64); + + // Write the payload to the stream. + self.writer.write_all(&length).unwrap(); + self.writer.write_all(&payload).unwrap(); + } +} diff --git a/it/gui-test/src/user.rs b/it/gui-test/src/user.rs new file mode 100644 index 000000000..203441937 --- /dev/null +++ b/it/gui-test/src/user.rs @@ -0,0 +1,164 @@ +//! User-facing reporter. + +use crate::{TestEvent, TestEventType, TestHandler, TestResult}; +use owo_colors::OwoColorize; + +use std::collections::BTreeMap; +use std::io::{self, prelude::*}; + +const TABSIZE: usize = 2; + +/// User-facing reporter. +/// +/// This reporter dumps events to the console in a user-readable format. +pub struct UserHandler { + /// Current indent. + indent: usize, + + /// The test set we're currently displaying. + current_start: Option, + + /// Test name we are running, if any. + test_name: Option, + + /// Cached events. + cache: BTreeMap>, + + /// Failures we had. + failures: Vec<(String, String)>, +} + +impl UserHandler { + /// Create a new handler. + pub fn new() -> Self { + Self { + indent: 0, + current_start: None, + test_name: None, + cache: BTreeMap::new(), + failures: vec![], + } + } + + /// Process the provided events. + fn process_events(&mut self, events: impl IntoIterator) { + for event in events { + // Tell if this is an end event. + let mut ender = matches!(event.ty, TestEventType::Complete { .. }); + + // If there is no test name set, run the current one. + match self.current_start.as_ref() { + None => { + let TestEvent { runner, ty } = event; + self.current_start = Some(runner); + self.dump_events(Some(ty)); + } + + Some(test_name) => { + // If there is a test name set and it's ours, post it immediately. + if test_name == &event.runner { + self.dump_events(Some(event.ty)); + } else { + // Add it to the back of another one of the events. + self.cache + .entry(test_name.clone()) + .or_default() + .push(event.ty); + } + } + } + + // If this is the end, dump other events. + while ender { + assert!(self.current_start.take().is_some()); + + // Pick one set. + if let Some(entry) = self.cache.first_entry() { + let (test_name, entries) = entry.remove_entry(); + self.current_start = Some(test_name); + + // Dump events and look for a conclusion. + ender = false; + self.dump_events(entries.into_iter().inspect(|ty| { + ender |= matches!(ty, TestEventType::Complete { .. }); + })); + } + } + } + } + + /// Dump the provided events to the console. + fn dump_events(&mut self, events: impl IntoIterator) { + let mut stdout = io::stdout().lock(); + + for event in events { + // Write the indent. + for _ in 0..(self.indent * TABSIZE) { + stdout.write_all(b" ").unwrap(); + } + + match event { + TestEventType::GroupStarted { name } => { + assert!(self.test_name.is_none()); + + // Write the group name and bump the indent. + writeln!(stdout, "{}", name.yellow().italic()).unwrap(); + + // Add to the indent. + self.indent += 1; + } + + TestEventType::GroupEnded => { + assert!(self.test_name.is_none()); + + // Drop the indent. + self.indent = self.indent.checked_sub(1).unwrap(); + } + + TestEventType::TestStarted { name } => { + assert!(self.test_name.is_none()); + + // Write the line. + write!(stdout, "{} ", name.white().italic()).unwrap(); + self.test_name = Some(name); + } + + TestEventType::TestEnded { result } => { + let test_name = self.test_name.take().unwrap(); + + // Write the result. + match result { + TestResult::Passed => { + writeln!(stdout, "{}", "ok".green().bold()).unwrap(); + } + + TestResult::Failed(failure) => { + self.failures.push((test_name, failure)); + writeln!(stdout, "{}", "FAIL".red().bold()).unwrap(); + } + + TestResult::Skipped => { + writeln!(stdout, "{}", "skipped".yellow().bold()).unwrap(); + } + } + } + + _ => { + // Completion. + } + } + } + } +} + +impl TestHandler for UserHandler { + fn handle_test(&mut self, event: TestEvent) { + self.process_events(Some(event)); + } +} + +impl Drop for UserHandler { + fn drop(&mut self) { + assert!(self.cache.is_empty()); + } +}