use std::{
collections::{hash_map::HashMap, HashSet},
env,
fs::read_to_string,
io::{self, Read, Write},
os::unix::process::ExitStatusExt,
path::{Path, PathBuf},
process::{self, Command, ExitStatus},
str,
sync::{Arc, Mutex},
thread::sleep,
time::{Duration, Instant},
};
use filedescriptor::{
poll, pollfd, FileDescriptor, IntoRawSocketDescriptor, POLLERR, POLLHUP, POLLIN,
};
use getopts::Options;
use num_cpus;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
use threadpool::ThreadPool;
use walkdir::WalkDir;
use crate::{fatal, fuzzy, parser::parse_tests};
const READBUF: usize = 1024 * 4;
const TIMEOUT: u64 = 60;
const INITIAL_WAIT_TIMEOUT: u64 = 10000;
const MAX_WAIT_TIMEOUT: u64 = 250_000_000;
pub struct LangTester<'a> {
test_dir: Option<&'a str>,
use_cmdline_args: bool,
test_file_filter: Option<Box<dyn Fn(&Path) -> bool>>,
cmdline_filters: Option<Vec<String>>,
inner: Arc<LangTesterPooler>,
}
struct LangTesterPooler {
test_threads: usize,
nocapture: bool,
test_extract: Option<Box<dyn Fn(&str) -> Option<String> + Send + Sync>>,
test_cmds: Option<Box<dyn Fn(&Path) -> Vec<(&str, Command)> + Send + Sync>>,
}
impl<'a> LangTester<'a> {
pub fn new() -> Self {
LangTester {
test_dir: None,
test_file_filter: None,
use_cmdline_args: true,
cmdline_filters: None,
inner: Arc::new(LangTesterPooler {
nocapture: false,
test_threads: num_cpus::get(),
test_extract: None,
test_cmds: None,
}),
}
}
pub fn test_dir(&'a mut self, test_dir: &'a str) -> &'a mut Self {
self.test_dir = Some(test_dir);
self
}
pub fn test_file_filter<F>(&'a mut self, test_file_filter: F) -> &'a mut Self
where
F: 'static + Fn(&Path) -> bool,
{
self.test_file_filter = Some(Box::new(test_file_filter));
self
}
pub fn test_extract<F>(&'a mut self, test_extract: F) -> &'a mut Self
where
F: 'static + Fn(&str) -> Option<String> + Send + Sync,
{
Arc::get_mut(&mut self.inner).unwrap().test_extract = Some(Box::new(test_extract));
self
}
pub fn test_cmds<F>(&'a mut self, test_cmds: F) -> &'a mut Self
where
F: 'static + Fn(&Path) -> Vec<(&str, Command)> + Send + Sync,
{
Arc::get_mut(&mut self.inner).unwrap().test_cmds = Some(Box::new(test_cmds));
self
}
pub fn use_cmdline_args(&'a mut self, use_cmdline_args: bool) -> &'a mut Self {
self.use_cmdline_args = use_cmdline_args;
self
}
fn validate(&self) {
if self.test_dir.is_none() {
fatal("test_dir must be specified.");
}
if self.inner.test_extract.is_none() {
fatal("test_extract must be specified.");
}
if self.inner.test_cmds.is_none() {
fatal("test_cmds must be specified.");
}
}
fn test_files(&self) -> (Vec<PathBuf>, usize) {
let mut num_filtered = 0;
let paths = WalkDir::new(self.test_dir.unwrap())
.into_iter()
.filter_map(|x| x.ok())
.filter(|x| x.file_type().is_file())
.filter(|x| match self.test_file_filter.as_ref() {
Some(f) => f(x.path()),
None => true,
})
.filter(|x| {
let x_path = x.path().to_str().unwrap();
match self.cmdline_filters.as_ref() {
Some(fs) => {
debug_assert!(self.use_cmdline_args);
for f in fs {
if x_path.contains(f) {
return true;
}
}
num_filtered += 1;
false
}
None => true,
}
})
.map(|x| x.into_path())
.collect();
(paths, num_filtered)
}
pub fn run(&mut self) {
self.validate();
if self.use_cmdline_args {
let args: Vec<String> = env::args().collect();
let matches = Options::new()
.optflag("h", "help", "")
.optflag(
"",
"nocapture",
"Pass command stderr/stdout through to the terminal",
)
.optopt(
"",
"test-threads",
"Number of threads used for running tests in parallel",
"n_threads",
)
.parse(&args[1..])
.unwrap_or_else(|_| usage());
if matches.opt_present("h") {
usage();
}
if matches.opt_present("nocapture") {
Arc::get_mut(&mut self.inner).unwrap().nocapture = true;
}
if let Some(s) = matches.opt_str("test-threads") {
let test_threads = s.parse::<usize>().unwrap_or_else(|_| usage());
if test_threads == 0 {
fatal("Must specify more than 0 threads.");
}
Arc::get_mut(&mut self.inner).unwrap().test_threads = test_threads;
}
if !matches.free.is_empty() {
self.cmdline_filters = Some(matches.free);
}
}
let (test_files, num_filtered) = self.test_files();
eprint!("\nrunning {} tests", test_files.len());
let test_files_len = test_files.len();
let (failures, num_ignored) = run_tests(test_files, Arc::clone(&self.inner));
self.pp_failures(&failures, test_files_len, num_ignored, num_filtered);
if !failures.is_empty() {
process::exit(1);
}
}
fn pp_failures(
&self,
failures: &[(String, TestFailure)],
test_files_len: usize,
num_ignored: usize,
num_filtered: usize,
) {
if !failures.is_empty() {
eprintln!("\n\nfailures:");
for (test_fname, test) in failures {
if let Some(ref status) = test.status {
eprintln!("\n---- lang_tests::{} status ----\n{}", test_fname, status);
}
if let Some(ref stderr) = test.stderr {
eprintln!(
"\n---- lang_tests::{} stderr ----\n{}\n",
test_fname, stderr
);
}
if let Some(ref stdout) = test.stdout {
eprintln!(
"\n---- lang_tests::{} stdout ----\n{}\n",
test_fname, stdout
);
}
}
eprintln!("\nfailures:");
for (test_fname, _) in failures {
eprint!(" lang_tests::{}", test_fname);
}
}
eprint!("\n\ntest result: ");
if failures.is_empty() {
write_with_colour("ok", Color::Green);
} else {
write_with_colour("FAILED", Color::Red);
}
eprintln!(
". {} passed; {} failed; {} ignored; 0 measured; {} filtered out\n",
test_files_len - failures.len(),
failures.len(),
num_ignored,
num_filtered
);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Status {
Success,
Error,
Signal,
Int(i32),
}
#[derive(Clone, Debug)]
pub(crate) struct TestCmd<'a> {
pub status: Status,
pub stderr: Vec<&'a str>,
pub stdout: Vec<&'a str>,
pub args: Vec<String>,
}
impl<'a> TestCmd<'a> {
pub fn default() -> Self {
Self {
status: Status::Success,
stderr: vec!["..."],
stdout: vec!["..."],
args: Vec::new(),
}
}
}
#[derive(Debug, PartialEq)]
struct TestFailure {
status: Option<String>,
stderr: Option<String>,
stdout: Option<String>,
}
fn write_with_colour(s: &str, colour: Color) {
let mut stderr = StandardStream::stderr(ColorChoice::Always);
stderr.set_color(ColorSpec::new().set_fg(Some(colour))).ok();
io::stderr().write_all(s.as_bytes()).ok();
stderr.reset().ok();
}
fn write_ignored(test_name: &str, message: &str, inner: Arc<LangTesterPooler>) {
let stderr = StandardStream::stderr(ColorChoice::Always);
let mut handle = stderr.lock();
if inner.test_threads > 1 {
handle
.write_all(&format!("\ntest lang_tests::{} ... ", test_name).as_bytes())
.ok();
}
handle
.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))
.ok();
handle.write_all(b"ignored").ok();
handle.reset().ok();
handle.write_all(format!(" ({})", message).as_bytes()).ok();
}
fn usage() -> ! {
eprintln!("Usage: [--test-threads=<n>] <filter1> [... <filtern>]");
process::exit(1);
}
fn check_names<'a>(cmd_pairs: &[(String, Command)], tests: &HashMap<String, TestCmd<'a>>) {
let cmd_names = cmd_pairs.iter().map(|x| &x.0).collect::<HashSet<_>>();
let test_names = tests.keys().map(|x| x).collect::<HashSet<_>>();
let diff = test_names
.difference(&cmd_names)
.map(|x| x.as_str())
.collect::<Vec<_>>();
if !diff.is_empty() {
fatal(&format!(
"Command name(s) '{}' in tests are not found in the actual commands.",
diff.join(", ")
));
}
}
fn run_tests(
test_files: Vec<PathBuf>,
inner: Arc<LangTesterPooler>,
) -> (Vec<(String, TestFailure)>, usize) {
let failures = Arc::new(Mutex::new(Vec::new()));
let mut num_ignored = 0;
let pool = ThreadPool::new(inner.test_threads);
for p in test_files {
let test_fname = p.file_stem().unwrap().to_str().unwrap().to_owned();
let failures = failures.clone();
let inner = inner.clone();
pool.execute(move || {
if inner.test_threads == 1 {
eprint!("\ntest lang_test::{} ... ", test_fname);
}
let all_str = read_to_string(p.as_path())
.unwrap_or_else(|_| fatal(&format!("Couldn't read {}", test_fname)));
let test_str = inner.test_extract.as_ref().unwrap()(&all_str).unwrap_or_else(|| {
fatal(&format!("Couldn't extract test string from {}", test_fname))
});
if test_str.is_empty() {
write_ignored(test_fname.as_str(), "test string is empty", inner);
num_ignored += 1;
return;
}
let tests = parse_tests(&test_str);
if !cfg!(unix)
&& tests
.values()
.find(|t| t.status == Status::Signal)
.is_some()
{
write_ignored(
test_fname.as_str(),
"signal termination not supported on this platform",
inner,
);
num_ignored += 1;
return;
}
let cmd_pairs = inner.test_cmds.as_ref().unwrap()(p.as_path())
.into_iter()
.map(|(test_name, cmd)| (test_name.to_lowercase(), cmd))
.collect::<Vec<_>>();
check_names(&cmd_pairs, &tests);
let mut failure = TestFailure {
status: None,
stderr: None,
stdout: None,
};
for (cmd_name, mut cmd) in cmd_pairs {
let default_test = TestCmd::default();
let test = tests.get(&cmd_name).unwrap_or(&default_test);
cmd.args(&test.args);
let (status, stderr, stdout) = run_cmd(inner.clone(), &test_fname, cmd);
let mut meant_to_error = false;
let pass_status = match test.status {
Status::Success => status.success(),
Status::Error => {
meant_to_error = true;
!status.success()
}
Status::Signal => status.signal().is_some(),
Status::Int(i) => status.code() == Some(i),
};
let pass_stderr = fuzzy::match_vec(&test.stderr, &stderr);
let pass_stdout = fuzzy::match_vec(&test.stdout, &stdout);
if !(pass_status && pass_stderr && pass_stdout) {
if !pass_status || failure.status.is_none() {
match test.status {
Status::Success | Status::Error => {
if status.success() {
failure.status = Some("Success".to_owned());
} else if status.code().is_none() {
failure.status = Some(
format!(
"Exited due to signal: {}",
status.signal().unwrap()
)
.to_owned(),
);
} else {
failure.status = Some("Error".to_owned());
}
}
Status::Signal => {
failure.status = Some("Exit was not due to signal".to_owned());
}
Status::Int(_) => {
failure.status = Some(
status.code().map(|x| x.to_string()).unwrap_or_else(|| {
format!(
"Exited due to signal: {}",
status.signal().unwrap()
)
.to_owned()
}),
)
}
}
}
if !pass_stderr || failure.stderr.is_none() {
failure.stderr = Some(stderr);
}
if !pass_stdout || failure.stdout.is_none() {
failure.stdout = Some(stdout);
}
break;
}
if !status.success() && meant_to_error {
break;
}
}
{
let stderr = StandardStream::stderr(ColorChoice::Always);
let mut handle = stderr.lock();
if inner.test_threads > 1 {
handle
.write_all(&format!("\ntest lang_tests::{} ... ", test_fname).as_bytes())
.ok();
}
if failure
!= (TestFailure {
status: None,
stderr: None,
stdout: None,
})
{
let mut failures = failures.lock().unwrap();
failures.push((test_fname, failure));
handle
.set_color(ColorSpec::new().set_fg(Some(Color::Red)))
.ok();
handle.write_all(b"FAILED").ok();
handle.reset().ok();
} else {
handle
.set_color(ColorSpec::new().set_fg(Some(Color::Green)))
.ok();
handle.write_all(b"ok").ok();
handle.reset().ok();
}
}
});
}
pool.join();
let failures = Mutex::into_inner(Arc::try_unwrap(failures).unwrap()).unwrap();
(failures, num_ignored)
}
fn run_cmd(
inner: Arc<LangTesterPooler>,
test_fname: &str,
mut cmd: Command,
) -> (ExitStatus, String, String) {
let mut child = cmd
.stderr(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stdin(process::Stdio::null())
.spawn()
.unwrap_or_else(|_| fatal(&format!("Couldn't run command {:?}.", cmd)));
let mut stderr = FileDescriptor::dup(child.stderr.as_ref().unwrap()).unwrap();
let mut stdout = FileDescriptor::dup(child.stdout.as_ref().unwrap()).unwrap();
let mut cap_stderr = String::new();
let mut cap_stdout = String::new();
let mut pollfds = [
pollfd {
fd: FileDescriptor::dup(&stderr)
.unwrap()
.into_socket_descriptor(),
events: POLLERR | POLLIN | POLLHUP,
revents: 0,
},
pollfd {
fd: FileDescriptor::dup(&stdout)
.unwrap()
.into_socket_descriptor(),
events: POLLERR | POLLIN | POLLHUP,
revents: 0,
},
];
let mut buf = [0; READBUF];
let start = Instant::now();
let mut last_warning = Instant::now();
let mut next_warning = last_warning
.checked_add(Duration::from_secs(TIMEOUT))
.unwrap();
loop {
let timeout = {
let t = Instant::now();
if t > next_warning {
Duration::from_secs(1)
} else {
next_warning.duration_since(t)
}
};
if poll(&mut pollfds, Some(timeout)).is_ok() {
if pollfds[0].revents & POLLIN == POLLIN {
while let Ok(i) = stderr.read(&mut buf) {
if i == 0 {
break;
}
let utf8 = str::from_utf8(&buf[..i]).unwrap_or_else(|_| {
fatal(&format!("Can't convert stderr from '{:?}' into UTF-8", cmd))
});
cap_stderr.push_str(&utf8);
if inner.nocapture {
eprint!("{}", utf8);
}
}
}
if pollfds[1].revents & POLLIN == POLLIN {
while let Ok(i) = stdout.read(&mut buf) {
if i == 0 {
break;
}
let utf8 = str::from_utf8(&buf[..i]).unwrap_or_else(|_| {
fatal(&format!("Can't convert stdout from '{:?}' into UTF-8", cmd))
});
cap_stdout.push_str(&utf8);
if inner.nocapture {
print!("{}", utf8);
}
}
}
if pollfds[0].revents & POLLHUP == POLLHUP && pollfds[1].revents & POLLHUP == POLLHUP {
break;
}
}
if Instant::now() >= next_warning {
let running_for = ((Instant::now() - start).as_secs() / TIMEOUT) * TIMEOUT;
if inner.test_threads == 1 {
eprint!("running for over {} seconds... ", running_for);
} else {
eprintln!(
"\nlang_tests::{} ... has been running for over {} seconds",
test_fname, running_for
);
}
last_warning = next_warning;
next_warning = last_warning
.checked_add(Duration::from_secs(TIMEOUT))
.unwrap();
}
}
let status = {
let mut wait_timeout = INITIAL_WAIT_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(s)) => break s,
Ok(None) => (),
Err(e) => fatal(&format!("{:?} did not exit correctly: {:?}", cmd, e)),
}
if Instant::now() >= next_warning {
let running_for = ((Instant::now() - start).as_secs() / TIMEOUT) * TIMEOUT;
if inner.test_threads == 1 {
eprint!("running for over {} seconds... ", running_for);
} else {
eprintln!(
"\nlang_tests::{} ... has been running for over {} seconds",
test_fname, running_for
);
}
last_warning = next_warning;
next_warning = last_warning
.checked_add(Duration::from_secs(TIMEOUT))
.unwrap();
}
sleep(Duration::from_nanos(wait_timeout));
wait_timeout *= 2;
if wait_timeout > MAX_WAIT_TIMEOUT {
wait_timeout = MAX_WAIT_TIMEOUT;
}
}
};
(status, cap_stderr, cap_stdout)
}