mirror of
https://github.com/roc-lang/roc.git
synced 2025-09-29 14:54:47 +00:00
538 lines
16 KiB
Rust
538 lines
16 KiB
Rust
extern crate bumpalo;
|
|
extern crate roc_collections;
|
|
extern crate roc_load;
|
|
extern crate roc_module;
|
|
extern crate tempfile;
|
|
|
|
use regex::Regex;
|
|
use roc_command_utils::{cargo, pretty_command_string, root_dir};
|
|
use roc_reporting::report::ANSI_STYLE_CODES;
|
|
use serde::Deserialize;
|
|
use serde_xml_rs::from_str;
|
|
use std::env;
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::io::Write;
|
|
use std::ops::Deref;
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, ExitStatus, Stdio};
|
|
use std::sync::Mutex;
|
|
use tempfile::NamedTempFile;
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref COMMON_STDERR: [ExpectedString; 4] = [
|
|
"🔨 Building host ...\n".into(),
|
|
"ld: warning: -undefined dynamic_lookup may not work with chained fixups".into(),
|
|
"warning: ignoring debug info with an invalid version (0) in app\r\n".into(),
|
|
ExpectedString::new_fuzzy(r"runtime: .*ms\n"),
|
|
];
|
|
}
|
|
|
|
// Since glue is always compiling the same plugin, it can not be run in parallel.
|
|
// That would lead to a race condition in writing the output shared library.
|
|
// Thus, all calls to glue in a test are made sequential.
|
|
// TODO: In the future, look into compiling the shared library once and then caching it.
|
|
static GLUE_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
#[derive(Debug)]
|
|
pub struct Out {
|
|
pub cmd_str: OsString, // command with all its arguments, for easy debugging
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub status: ExitStatus,
|
|
pub run: Run,
|
|
pub valgrind_xml: Option<NamedTempFile>,
|
|
}
|
|
|
|
impl std::fmt::Display for Out {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(
|
|
f,
|
|
"Command: {}\n\nExit Code: {}\nStdout:\n{}\n\nStderr:\n{}",
|
|
self.cmd_str.to_str().unwrap(),
|
|
self.status,
|
|
self.stdout,
|
|
self.stderr
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Out {
|
|
/// Assert that the command succeeded and that there are no unexpected errors in the stderr.
|
|
pub fn assert_clean_success(&self) {
|
|
self.assert_success_with_no_unexpected_errors(COMMON_STDERR.as_slice());
|
|
}
|
|
|
|
/// Assert that the command succeeded and that there are no unexpected errors in the stderr.
|
|
/// This DOES NOT normalise the output, use assert_stdout_ends_with for that.
|
|
pub fn assert_success_with_no_unexpected_errors(&self, expected_errors: &[ExpectedString]) {
|
|
assert!(self.status.success(), "Command failed\n\n{self}");
|
|
assert!(
|
|
!has_unexpected_error(&self.stderr, expected_errors),
|
|
"Unexpected error:\n{}",
|
|
self.stderr
|
|
);
|
|
}
|
|
|
|
/// Normalise the output for comparison in tests by replacing with a placeholder
|
|
fn normalize_for_tests(input: &str) -> String {
|
|
// normalise from windows line endings to unix line endings
|
|
let without_clrf = input.replace("\r\n", "\n");
|
|
|
|
// remove ANSI color codes
|
|
let ansi_regex = Regex::new(r"\x1b\[[0-9;]*[mGKH]").expect("Invalid ANSI regex pattern");
|
|
let without_ansi = ansi_regex.replace_all(without_clrf.as_str(), "");
|
|
|
|
// replace timings with a placeholder
|
|
let regex = Regex::new(r" in (\d+) ms\.").expect("Invalid regex pattern");
|
|
let replacement = " in <ignored for test> ms.";
|
|
let without_timings = regex.replace_all(&without_ansi, replacement);
|
|
|
|
// replace file paths with a placeholder
|
|
let filepath_regex =
|
|
Regex::new(r"\[([^:]+):(\d+)\]").expect("Invalid filepath regex pattern");
|
|
let filepath_replacement = "[<ignored for tests>:$2]";
|
|
let without_filepaths = filepath_regex.replace_all(&without_timings, filepath_replacement);
|
|
|
|
// replace error summary timings
|
|
let error_summary_regex =
|
|
Regex::new(r"(\d+) error(?:s)? and (\d+) warning(?:s)? found in \d+ ms")
|
|
.expect("Invalid error summary regex pattern");
|
|
let error_summary_replacement = "$1 error and $2 warning found in <ignored for test> ms";
|
|
error_summary_regex
|
|
.replace_all(&without_filepaths, error_summary_replacement)
|
|
.to_string()
|
|
}
|
|
|
|
/// Assert that the stdout ends with the expected string
|
|
/// This normalises the output for comparison in tests such as replacing timings
|
|
/// with a placeholder, or stripping ANSI colors
|
|
pub fn assert_stdout_and_stderr_ends_with(&self, expected: &str) {
|
|
let normalised_output = format!(
|
|
"{}{}",
|
|
Out::normalize_for_tests(&self.stdout),
|
|
Out::normalize_for_tests(&self.stderr)
|
|
);
|
|
|
|
assert!(
|
|
normalised_output.ends_with(expected),
|
|
"\n{}EXPECTED stdout and stderr after normalizing:\n----------------\n{}{}\n{}ACTUAL stdout and stderr after normalizing:\n----------------\n{}{}{}\n----------------\n{}",
|
|
ANSI_STYLE_CODES.cyan,
|
|
ANSI_STYLE_CODES.reset,
|
|
expected,
|
|
ANSI_STYLE_CODES.cyan,
|
|
ANSI_STYLE_CODES.reset,
|
|
normalised_output,
|
|
ANSI_STYLE_CODES.cyan,
|
|
ANSI_STYLE_CODES.reset,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A builder for running a command.
|
|
///
|
|
/// Unlike `std::process::Command`, this builder is clonable and provides convenience methods for
|
|
/// constructing Commands for the configured run and instrumenting the configured command with valgrind.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Run {
|
|
args: Vec<OsString>,
|
|
env: Vec<(String, String)>,
|
|
stdin_vals: Vec<&'static str>,
|
|
cwd: Option<OsString>,
|
|
}
|
|
|
|
impl Run {
|
|
pub fn new<S>(exe: S) -> Self
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let exe: OsString = exe.as_ref().into();
|
|
Self {
|
|
args: vec![exe],
|
|
stdin_vals: vec![],
|
|
env: vec![],
|
|
cwd: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_roc() -> Self {
|
|
Self::new(path_to_roc_binary())
|
|
}
|
|
|
|
pub fn command(&self) -> Command {
|
|
let mut cmd = Command::new(&self.args[0]);
|
|
for arg in self.args[1..].iter() {
|
|
cmd.arg(arg);
|
|
}
|
|
for (k, v) in self.env.iter() {
|
|
cmd.env(k, v);
|
|
}
|
|
cmd
|
|
}
|
|
|
|
pub fn valgrind_command(&self) -> (Command, NamedTempFile) {
|
|
let mut cmd = Command::new("valgrind");
|
|
let named_tempfile =
|
|
NamedTempFile::new().expect("Unable to create tempfile for valgrind results");
|
|
|
|
cmd.arg("--tool=memcheck");
|
|
cmd.arg("--xml=yes");
|
|
cmd.arg(format!("--xml-file={}", named_tempfile.path().display()));
|
|
|
|
// If you are having valgrind issues on MacOS, you may need to suppress some
|
|
// of the errors. Read more here: https://github.com/roc-lang/roc/issues/746
|
|
if let Some(suppressions_file_os_str) = env::var_os("VALGRIND_SUPPRESSIONS") {
|
|
match suppressions_file_os_str.to_str() {
|
|
None => {
|
|
panic!("Could not determine suppression file location from OsStr");
|
|
}
|
|
Some(suppressions_file) => {
|
|
let mut buf = String::new();
|
|
|
|
buf.push_str("--suppressions=");
|
|
buf.push_str(suppressions_file);
|
|
|
|
cmd.arg(buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
for arg in self.args.iter() {
|
|
cmd.arg(arg);
|
|
}
|
|
|
|
(cmd, named_tempfile)
|
|
}
|
|
|
|
pub fn arg<S>(mut self, arg: S) -> Self
|
|
where
|
|
S: Into<OsString>,
|
|
{
|
|
self.args.push(arg.into());
|
|
self
|
|
}
|
|
|
|
pub fn add_args<I, S>(mut self, args: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<OsStr>,
|
|
{
|
|
for arg in args {
|
|
self = self.arg(&arg);
|
|
}
|
|
self
|
|
}
|
|
|
|
pub fn get_args(&self) -> impl Iterator<Item = &OsStr> {
|
|
self.args.iter().map(Deref::deref)
|
|
}
|
|
|
|
pub fn get_env(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
self.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
|
}
|
|
|
|
pub fn with_stdin_vals<I>(mut self, stdin_vals: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = &'static str>,
|
|
{
|
|
self.stdin_vals.extend(stdin_vals);
|
|
self
|
|
}
|
|
|
|
pub fn cwd<S>(mut self, arg: S) -> Self
|
|
where
|
|
S: Into<OsString>,
|
|
{
|
|
self.cwd = Some(arg.into());
|
|
self
|
|
}
|
|
|
|
pub fn with_env<'a, I>(&mut self, env: I) -> &mut Self
|
|
where
|
|
I: IntoIterator<Item = (&'a str, &'a str)>,
|
|
{
|
|
for (k, v) in env {
|
|
self.env.push((k.into(), v.into()));
|
|
}
|
|
self
|
|
}
|
|
|
|
fn run_with_command(self, mut cmd: Command) -> Out {
|
|
let cmd_str = pretty_command_string(&cmd);
|
|
|
|
let command = cmd
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
if let Some(cwd) = &self.cwd {
|
|
command.current_dir(cwd);
|
|
}
|
|
|
|
let mut roc_cmd_child = command.spawn().unwrap_or_else(|err| {
|
|
panic!("Failed to execute command\n\n {cmd_str:?}\n\nwith error:\n\n {err}",)
|
|
});
|
|
|
|
let stdin = roc_cmd_child.stdin.as_mut().expect("Failed to open stdin");
|
|
|
|
for stdin_str in self.stdin_vals.iter() {
|
|
stdin
|
|
.write_all(stdin_str.as_bytes())
|
|
.unwrap_or_else(|err| {
|
|
panic!("Failed to write to stdin for command\n\n {cmd_str:?}\n\nwith error:\n\n {err}")
|
|
});
|
|
}
|
|
let roc_cmd_output = roc_cmd_child.wait_with_output().unwrap_or_else(|err| {
|
|
panic!("Failed to get output for command\n\n {cmd_str:?}\n\nwith error:\n\n {err}")
|
|
});
|
|
|
|
Out {
|
|
cmd_str,
|
|
stdout: String::from_utf8(roc_cmd_output.stdout).unwrap(),
|
|
stderr: String::from_utf8(roc_cmd_output.stderr).unwrap(),
|
|
status: roc_cmd_output.status,
|
|
valgrind_xml: None,
|
|
run: self,
|
|
}
|
|
}
|
|
|
|
pub fn run(self) -> Out {
|
|
let command = self.command();
|
|
self.run_with_command(command)
|
|
}
|
|
|
|
pub fn run_glue(self) -> Out {
|
|
let _guard = GLUE_LOCK.lock().unwrap();
|
|
self.run()
|
|
}
|
|
|
|
pub fn run_with_valgrind(self) -> Out {
|
|
let (valgrind_cmd, valgrind_xml) = self.valgrind_command();
|
|
let mut out = self.run_with_command(valgrind_cmd);
|
|
out.valgrind_xml = Some(valgrind_xml);
|
|
out
|
|
}
|
|
}
|
|
|
|
pub fn has_unexpected_error(stderr: &str, expected_errors: &[ExpectedString]) -> bool {
|
|
let mut stderr_stripped = String::from(stderr);
|
|
for expected_error in expected_errors {
|
|
stderr_stripped = expected_error.replacen(&stderr_stripped, "", 1);
|
|
}
|
|
!stderr_stripped.trim().is_empty()
|
|
}
|
|
|
|
pub enum ExpectedString {
|
|
Exact(String),
|
|
Fuzzy(regex::Regex),
|
|
}
|
|
|
|
impl ExpectedString {
|
|
pub fn new(s: &str) -> ExpectedString {
|
|
ExpectedString::Exact(s.to_string())
|
|
}
|
|
|
|
pub fn new_fuzzy(s: &str) -> ExpectedString {
|
|
let r = regex::Regex::new(s).unwrap();
|
|
ExpectedString::Fuzzy(r)
|
|
}
|
|
|
|
pub fn found_in(&self, haystack: &str) -> bool {
|
|
match self {
|
|
ExpectedString::Exact(sub) => haystack.contains(sub),
|
|
ExpectedString::Fuzzy(regex) => regex.is_match(haystack),
|
|
}
|
|
}
|
|
|
|
pub fn replacen(&self, haystack: &str, replacement: &str, n: usize) -> String {
|
|
match self {
|
|
ExpectedString::Exact(sub) => haystack.replacen(sub, replacement, n),
|
|
ExpectedString::Fuzzy(regex) => regex.replacen(haystack, n, replacement).to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&str> for ExpectedString {
|
|
fn from(s: &str) -> Self {
|
|
ExpectedString::new(s)
|
|
}
|
|
}
|
|
|
|
impl From<regex::Regex> for ExpectedString {
|
|
fn from(r: regex::Regex) -> Self {
|
|
Self::Fuzzy(r)
|
|
}
|
|
}
|
|
|
|
pub fn path_to_roc_binary() -> PathBuf {
|
|
path_to_binary(if cfg!(windows) { "roc.exe" } else { "roc" })
|
|
}
|
|
|
|
pub fn path_to_binary(binary_name: &str) -> PathBuf {
|
|
// Adapted from https://github.com/volta-cli/volta/blob/cefdf7436a15af3ce3a38b8fe53bb0cfdb37d3dd/tests/acceptance/support/sandbox.rs#L680
|
|
// by the Volta Contributors - license information can be found in
|
|
// the LEGAL_DETAILS file in the root directory of this distribution.
|
|
//
|
|
// Thank you, Volta contributors!
|
|
let mut path = env::var_os("CARGO_BIN_PATH")
|
|
.map(PathBuf::from)
|
|
.or_else(|| {
|
|
env::current_exe().ok().map(|mut path| {
|
|
path.pop();
|
|
if path.ends_with("deps") {
|
|
path.pop();
|
|
}
|
|
path
|
|
})
|
|
})
|
|
.unwrap_or_else(|| panic!("CARGO_BIN_PATH wasn't set, and couldn't be inferred from context. Can't run CLI tests."));
|
|
|
|
path.push(binary_name);
|
|
|
|
path
|
|
}
|
|
|
|
// If we don't already have a /target/release/roc, build it!
|
|
pub fn build_roc_bin_cached() -> PathBuf {
|
|
let roc_binary_path = path_to_roc_binary();
|
|
|
|
if !roc_binary_path.exists() {
|
|
build_roc_bin(&[]);
|
|
}
|
|
|
|
roc_binary_path
|
|
}
|
|
|
|
pub fn build_roc_bin(extra_args: &[&str]) -> PathBuf {
|
|
let roc_binary_path = path_to_roc_binary();
|
|
|
|
// Remove the /target/release/roc part
|
|
let root_project_dir = roc_binary_path
|
|
.parent()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap();
|
|
|
|
// cargo build --bin roc
|
|
// (with --release iff the test is being built with --release)
|
|
let mut args = if cfg!(debug_assertions) {
|
|
vec!["build", "--bin", "roc"]
|
|
} else {
|
|
vec!["build", "--release", "--bin", "roc"]
|
|
};
|
|
|
|
args.extend(extra_args);
|
|
|
|
let mut cargo_cmd = cargo();
|
|
|
|
cargo_cmd.current_dir(root_project_dir).args(&args);
|
|
|
|
let cargo_cmd_str = format!("{cargo_cmd:?}");
|
|
|
|
let cargo_output = cargo_cmd.output().unwrap();
|
|
|
|
if !cargo_output.status.success() {
|
|
panic!(
|
|
"The following cargo command failed:\n\n {}\n\n stdout was:\n\n {}\n\n stderr was:\n\n {}\n",
|
|
cargo_cmd_str,
|
|
String::from_utf8(cargo_output.stdout).unwrap(),
|
|
String::from_utf8(cargo_output.stderr).unwrap()
|
|
);
|
|
}
|
|
|
|
roc_binary_path
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ValgrindOutput {
|
|
#[serde(rename = "$value")]
|
|
pub fields: Vec<ValgrindField>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[allow(dead_code)] // Some fields are unused but this allows for easy deserialization of the xml.
|
|
enum ValgrindField {
|
|
ProtocolVersion(isize),
|
|
ProtocolTool(String),
|
|
Preamble(ValgrindDummyStruct),
|
|
Pid(isize),
|
|
PPid(isize),
|
|
Tool(String),
|
|
Args(ValgrindDummyStruct),
|
|
Error(ValgrindError),
|
|
Status(ValgrindDummyStruct),
|
|
Stack(ValgrindDummyStruct),
|
|
#[serde(rename = "fatal_signal")]
|
|
FatalSignal(ValgrindDummyStruct),
|
|
ErrorCounts(ValgrindDummyStruct),
|
|
SuppCounts(ValgrindDummyStruct),
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ValgrindDummyStruct {}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct ValgrindError {
|
|
pub kind: String,
|
|
#[serde(default)]
|
|
pub what: Option<String>,
|
|
#[serde(default)]
|
|
pub xwhat: Option<ValgrindErrorXWhat>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
pub struct ValgrindErrorXWhat {
|
|
pub text: String,
|
|
#[serde(default)]
|
|
pub leakedbytes: Option<isize>,
|
|
#[serde(default)]
|
|
pub leakedblocks: Option<isize>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn extract_valgrind_errors(xml: &str) -> Result<Vec<ValgrindError>, serde_xml_rs::Error> {
|
|
let parsed_xml: ValgrindOutput = from_str(xml)?;
|
|
let answer = parsed_xml
|
|
.fields
|
|
.iter()
|
|
.filter_map(|field| match field {
|
|
ValgrindField::Error(err) => Some(err.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
Ok(answer)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn dir_from_root(dir_name: &str) -> PathBuf {
|
|
let mut path = root_dir();
|
|
|
|
path.extend(dir_name.split('/')); // Make slashes cross-target
|
|
|
|
path
|
|
}
|
|
|
|
pub fn file_from_root(dir_name: &str, file_name: &str) -> PathBuf {
|
|
let mut path = dir_from_root(dir_name);
|
|
|
|
path.push(file_name);
|
|
|
|
path
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn known_bad_file(file_name: &str) -> PathBuf {
|
|
let mut path = root_dir();
|
|
|
|
// Descend into cli/tests/known_bad/{file_name}
|
|
path.push("crates");
|
|
path.push("cli");
|
|
path.push("tests");
|
|
path.push("known_bad");
|
|
path.push(file_name);
|
|
|
|
path
|
|
}
|