blob: 9d649f1dcf0744e5d4f0aff80d2ccf32b20ff6e2 [file] [log] [blame]
// Copyright 2025 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::errors::TestRunError;
use crate::std_writer::StdWriter;
use crate::test_config::{OutputProcessor, TestConfig};
use std::borrow::Cow;
use std::path::PathBuf;
use std::process::{ExitStatus, Stdio};
use std::time::Instant;
use std::{fs, io};
use test_pilot_lib::invocation_log::CommandInvocationLog;
use test_pilot_lib::test_output::{
OutputDirectory, Summary, SummaryArtifact, is_fail_exit_status, outcome_from_exit_status,
};
use tokio::io::AsyncReadExt;
use tokio::process::{self, Command};
const STDIO_BUFFER_SIZE: usize = 2 * 1024;
const ENV_PATH: &str = "PATH";
const ARTIFACT_TYPE_STDOUT: &str = "stdout";
const ARTIFACT_TYPE_STDERR: &str = "stderr";
pub async fn run_test(
test_config: &TestConfig,
outdir: &OutputDirectory<'_>,
path_env_var_value: &str,
) -> Result<ExitStatus, TestRunError> {
let mut log = CommandInvocationLog::new();
let exit_status = run_test_inner(test_config, &outdir, path_env_var_value, &mut log).await?;
let _ = fs::write(outdir.execution_log(), serde_json::to_string_pretty(&log).unwrap());
Ok(exit_status)
}
async fn run_test_inner(
test_config: &TestConfig,
outdir: &OutputDirectory<'_>,
path_env_var_value: &str,
log: &mut CommandInvocationLog,
) -> Result<ExitStatus, TestRunError> {
let mut main_summary = Summary::default();
let mut cmd =
create_test_launch_command(test_config, &outdir.test_config(), path_env_var_value);
let exit_status = {
let mut stdout_writer = StdWriter::new(outdir.test_stdout(), Box::new(io::stdout()))?;
let mut stderr_writer = StdWriter::new(outdir.test_stderr(), Box::new(io::stderr()))?;
let start_time = Instant::now();
let exit_status = run_command(&mut cmd, &mut stdout_writer, &mut stderr_writer).await?;
log.record_test(
&cmd,
exit_status,
stdout_writer.path_if_wrote().and_then(|p| outdir.make_relative(&p)),
stderr_writer.path_if_wrote().and_then(|p| outdir.make_relative(&p)),
);
// Populate the main summary based on test run results.
main_summary.common.duration = start_time.elapsed().as_millis() as i64;
main_summary.common.outcome = outcome_from_exit_status(exit_status);
if let Some(stdout) = stdout_writer.path_if_wrote() {
main_summary.common.artifacts.insert(
outdir.make_relative(&stdout).expect("stdout file is in output directory"),
SummaryArtifact { artifact_type: String::from(ARTIFACT_TYPE_STDOUT) },
);
}
if let Some(stderr) = stderr_writer.path_if_wrote() {
main_summary.common.artifacts.insert(
outdir.make_relative(&stderr).expect("stdout file is in output directory"),
SummaryArtifact { artifact_type: String::from(ARTIFACT_TYPE_STDERR) },
);
}
if !exit_status.success() && is_fail_exit_status(exit_status) {
eprintln!(
"host test binary {0} returned bad exit status {1}",
test_config.host_test_binary.display(),
exit_status
);
eprintln!("see {0} for execution details", outdir.execution_log().display());
return Ok(exit_status);
}
// stdout_writer and stderr_writer are dropped here, closing and maybe deleting their
// respective output files.
exit_status
};
let _merged = main_summary.maybe_merge_file(&outdir.test_summary())?;
main_summary.write(&outdir.main_summary())?;
for output_processor in &test_config.output_processors {
if (exit_status.success() && !output_processor.use_on_success)
|| (!exit_status.success() && !output_processor.use_on_failure)
{
continue;
}
if !output_processor
.use_if_defined
.iter()
.all(|p| test_config.parameter_is_defined(p.as_str()))
{
continue;
}
let mut cmd = create_output_processor_command(
test_config,
&output_processor,
&outdir.test_config(),
path_env_var_value,
);
{
let mut stdout_writer = StdWriter::new(
outdir.postprocessor_stdout(&output_processor.binary),
Box::new(io::stdout()),
)?;
let mut stderr_writer = StdWriter::new(
outdir.postprocessor_stderr(&output_processor.binary),
Box::new(io::stderr()),
)?;
let exit_status = run_command(&mut cmd, &mut stdout_writer, &mut stderr_writer).await?;
log.record_postprocessor(
&cmd,
exit_status,
stdout_writer.path_if_wrote().and_then(|p| outdir.make_relative(&p)),
stderr_writer.path_if_wrote().and_then(|p| outdir.make_relative(&p)),
);
if main_summary
.maybe_merge_file(&outdir.postprocessor_summary(&output_processor.binary))?
{
main_summary.write(&outdir.main_summary())?;
}
if !exit_status.success() && is_fail_exit_status(exit_status) {
eprintln!(
"output processor {0:?} returned bad exit status {1}",
output_processor.binary.display(),
exit_status
);
eprintln!("see {0:?} for execution details", outdir.execution_log());
return Ok(exit_status);
}
// stdout_writer and stderr_writer are dropped here, closing and maybe deleting their
// respective output files.
}
}
Ok(exit_status)
}
/// Creates a `Command` that runs the test as specified in `test_config`.
fn create_test_launch_command(
test_config: &TestConfig,
test_config_path: &PathBuf,
path_env_var_value: &str,
) -> Command {
create_command(
&test_config.host_test_binary,
test_config.resolved_host_test_args(test_config_path),
path_env_var_value,
)
}
/// Creates a `Command` that runs an output processor as specified in `output_processor`.
fn create_output_processor_command(
test_config: &TestConfig,
output_processor: &OutputProcessor,
test_config_path: &PathBuf,
path_env_var_value: &str,
) -> Command {
create_command(
&output_processor.binary,
output_processor.resolved_args(test_config, test_config_path),
path_env_var_value,
)
}
fn create_command<'a>(
binary: &PathBuf,
args: Box<dyn Iterator<Item = Cow<'a, str>> + 'a>,
path_env_var_value: &str,
) -> Command {
let mut cmd = Command::new(binary);
for arg in args {
cmd.arg(arg.into_owned());
}
cmd.env_clear();
cmd.env(ENV_PATH, path_env_var_value);
if let Ok(current_dir) = std::env::current_dir() {
cmd.current_dir(current_dir);
}
cmd
}
/// Makes sure that the child process is killed and waited to remove zombie process on drop.
struct ChildProcess {
inner: process::Child,
}
impl Drop for ChildProcess {
fn drop(&mut self) {
let _ = self.inner.kill();
let _ = self.inner.wait();
}
}
impl From<process::Child> for ChildProcess {
fn from(inner: process::Child) -> Self {
ChildProcess { inner }
}
}
async fn run_command<W1: io::Write + Send, W2: io::Write + Send>(
command: &mut Command,
mut stdout_writer: W1,
mut stderr_writer: W2,
) -> Result<ExitStatus, TestRunError> {
let mut child: ChildProcess = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| TestRunError::Spawn {
path: command.as_std().get_program().into(),
source: e,
})?
.into();
let mut stdout = child.inner.stdout.take().unwrap();
let mut stderr = child.inner.stderr.take().unwrap();
let stdout_writer_handle = async move {
let mut buf = [0; STDIO_BUFFER_SIZE];
loop {
let n = stdout.read(&mut buf).await.map_err(TestRunError::StdoutRead)?;
if n > 0 {
stdout_writer.write_all(&buf[..n]).map_err(TestRunError::StdoutWrite)?;
} else {
break;
}
}
Ok::<(), TestRunError>(())
};
let stderr_writer_handle = async move {
let mut buf = [0; STDIO_BUFFER_SIZE];
loop {
let n: usize = stderr.read(&mut buf).await.map_err(TestRunError::StderrRead)?;
if n > 0 {
stderr_writer.write_all(&buf[..n]).map_err(TestRunError::StderrWrite)?;
} else {
break;
}
}
Ok::<(), TestRunError>(())
};
// TODO(b/294567408) : Support timeout.
// The futures might block depending on underlying primitives, so we need to run this with more
// than 1 thread to stream stdout and stderr in parallel. We will replace it with tokio when
// available,
let (stdout_status, stderr_status) = futures::join!(stdout_writer_handle, stderr_writer_handle);
stdout_status?;
stderr_status?;
Ok(child.inner.wait().await.expect("Command wasn't running"))
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use rand::Rng;
use rand::distr::Alphanumeric;
use serde_json::{Value, from_reader, json};
use std::collections::HashMap;
use std::io::Cursor;
use std::os::unix::process::ExitStatusExt;
use tempfile::tempdir;
const CURRENT_DIR: &str = "current_dir";
#[fuchsia::test]
async fn test_run_command_exit_code() {
let mut stdout_buf = Cursor::new(Vec::new());
let mut stderr_buf = Cursor::new(Vec::new());
let mut cmd = Command::new("false");
let status = run_command(&mut cmd, &mut stdout_buf, &mut stderr_buf).await.unwrap();
let stdout_output = String::from_utf8(stdout_buf.into_inner()).unwrap();
let stderr_output = String::from_utf8(stderr_buf.into_inner()).unwrap();
assert_eq!(stdout_output, format!(""));
assert_eq!(stderr_output, "");
assert_eq!(status.code(), Some(1));
}
#[fuchsia::test]
async fn test_run_command_stderr() {
let mut stdout_buf = Cursor::new(Vec::new());
let mut stderr_buf = Cursor::new(Vec::new());
let mut cmd = Command::new("ls");
cmd.arg("non-existent-file");
let status = run_command(&mut cmd, &mut stdout_buf, &mut stderr_buf).await.unwrap();
let stdout_output = String::from_utf8(stdout_buf.into_inner()).unwrap();
let stderr_output = String::from_utf8(stderr_buf.into_inner()).unwrap();
assert_eq!(stdout_output, format!(""));
assert!(stderr_output.contains("non-existent-file"), "{}", stderr_output);
assert!(!status.success(), "status: {}", status);
}
#[fuchsia::test]
async fn test_run_command_large_stdout() {
let mut stdout_buf = Cursor::new(Vec::new());
let mut stderr_buf = Cursor::new(Vec::new());
let mut cmd = Command::new("echo");
let s: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(STDIO_BUFFER_SIZE * 10 - 10)
.map(char::from)
.collect();
cmd.arg(s.clone());
let status = run_command(&mut cmd, &mut stdout_buf, &mut stderr_buf).await.unwrap();
let stdout_output = String::from_utf8(stdout_buf.into_inner()).unwrap();
let stderr_output = String::from_utf8(stderr_buf.into_inner()).unwrap();
let len = stdout_output.len();
// echo writes a new line at the end
assert_eq!(stdout_output.as_bytes()[len - 1], 10);
assert_eq!(stdout_output[..len - 1], s);
assert_eq!(stderr_output, "");
assert!(status.success(), "status: {}", status);
}
#[fuchsia::test]
async fn test_run_command_large_stderr() {
let mut stdout_buf = Cursor::new(Vec::new());
let mut stderr_buf = Cursor::new(Vec::new());
let s: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(STDIO_BUFFER_SIZE * 10 - 10)
.map(char::from)
.collect();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(format!("echo '{}' >&2", s));
let status = run_command(&mut cmd, &mut stdout_buf, &mut stderr_buf).await.unwrap();
let stdout_output = String::from_utf8(stdout_buf.into_inner()).unwrap();
let stderr_output = String::from_utf8(stderr_buf.into_inner()).unwrap();
let len = stderr_output.len();
// echo writes a new line at the end
assert_eq!(stderr_output.as_bytes()[len - 1], 10);
assert_eq!(stderr_output[..len - 1], s);
assert_eq!(stdout_output, "");
assert!(status.success(), "status: {}", status);
}
#[fuchsia::test]
async fn test_run_command_invalid_command() {
let mut stdout_buf = Cursor::new(Vec::new());
let mut stderr_buf = Cursor::new(Vec::new());
let mut cmd = Command::new("invalid-cmd");
let err = run_command(&mut cmd, &mut stdout_buf, &mut stderr_buf)
.await
.expect_err("should have failed");
assert_matches!(err, TestRunError::Spawn { path: _, source: _ });
}
#[fuchsia::test]
async fn test_run_test() {
let temp_dir = tempdir().expect("to create temporary directory");
let config = TestConfig {
host_test_binary: PathBuf::from("echo"),
host_test_args: vec![String::from("should_end_up_in_test_stdout")],
output_directory: temp_dir.path().to_path_buf(),
output_processors: vec![OutputProcessor {
binary: PathBuf::from("echo"),
args: vec![String::from("should_end_up_in_proc_stdout")],
use_on_success: true,
use_on_failure: false,
use_if_defined: vec![String::from("host_test_binary")],
}],
unknown: HashMap::new(),
};
let output_directory = OutputDirectory::new(&config.output_directory);
let exit_status = run_test(&config, &output_directory, "/usr/bin")
.await
.expect("run_test should succeed");
assert_eq!(ExitStatus::from_raw(0), exit_status);
assert_eq!(
b"should_end_up_in_test_stdout\n".to_vec(),
fs::read(output_directory.test_stdout()).expect("read from test stdout succeeds")
);
assert_eq!(
b"should_end_up_in_proc_stdout\n".to_vec(),
fs::read(output_directory.postprocessor_stdout(&PathBuf::from("echo")))
.expect("read from post-processor stdout succeeds")
);
let mut log_reader = std::io::BufReader::new(
fs::File::open(&output_directory.execution_log()).expect("can open invocation log"),
);
let mut log: Value = from_reader(&mut log_reader).expect("can parse invocation log");
normalize_current_dir(&mut log);
assert_eq!(
json!({
"postprocessors": {
"echo": {
"args": ["should_end_up_in_proc_stdout"],
"current_dir": "current_dir",
"envs": {"PATH": "/usr/bin"},
"exit_status": 0,
"program_path": "echo",
"stdout_artifact": "echo/stdout.txt"
}
},
"test": {
"args": ["should_end_up_in_test_stdout"],
"current_dir": "current_dir",
"envs": {"PATH": "/usr/bin"},
"exit_status": 0,
"program_path": "echo",
"stdout_artifact": "test/stdout.txt"
}
}),
log
);
temp_dir.close().expect("temp directory successfully closed");
}
#[fuchsia::test]
async fn test_run_test_output_processor_missing_def() {
let temp_dir = tempdir().expect("to create temporary directory");
let config = TestConfig {
host_test_binary: PathBuf::from("echo"),
host_test_args: vec![String::from("should_end_up_in_test_stdout")],
output_directory: temp_dir.path().to_path_buf(),
output_processors: vec![OutputProcessor {
binary: PathBuf::from("echo"),
args: vec![String::from("should_end_up_in_proc_stdout")],
use_on_success: true,
use_on_failure: false,
use_if_defined: vec![String::from("this_is_not_defined")],
}],
unknown: HashMap::new(),
};
let output_directory = OutputDirectory::new(&config.output_directory);
let exit_status = run_test(&config, &output_directory, "/usr/bin")
.await
.expect("run_test should succeed");
assert_eq!(ExitStatus::from_raw(0), exit_status);
assert_eq!(
b"should_end_up_in_test_stdout\n".to_vec(),
fs::read(output_directory.test_stdout()).expect("read from test stdout succeeds")
);
assert!(
!fs::exists(output_directory.postprocessor_stdout(&PathBuf::from("echo"))).unwrap()
);
let mut log_reader = std::io::BufReader::new(
fs::File::open(&output_directory.execution_log()).expect("can open invocation log"),
);
let mut log: Value = from_reader(&mut log_reader).expect("can parse invocation log");
normalize_current_dir(&mut log);
assert_eq!(
json!({
"test": {
"args": ["should_end_up_in_test_stdout"],
"current_dir": "current_dir",
"envs": {"PATH": "/usr/bin"},
"exit_status": 0,
"program_path": "echo",
"stdout_artifact": "test/stdout.txt"
}
}),
log
);
temp_dir.close().expect("temp directory successfully closed");
}
/// Replaces all "current_dir" property values with "current_dir" to normalize a log that
/// would otherwise contain absolute paths.
fn normalize_current_dir(value: &mut Value) {
if !value.is_object() {
return;
}
let map = value.as_object_mut().unwrap();
if map.contains_key(CURRENT_DIR) {
map.insert(String::from(CURRENT_DIR), Value::from(CURRENT_DIR));
return;
}
for (_, value) in map {
normalize_current_dir(value);
}
}
}