Skip to content

Commit

Permalink
tests(feat): Add unit testing framework, basic test infra.
Browse files Browse the repository at this point in the history
  • Loading branch information
c272 committed May 7, 2024
1 parent 76773c8 commit c0ec581
Show file tree
Hide file tree
Showing 11 changed files with 520 additions and 5 deletions.
16 changes: 11 additions & 5 deletions crates/compiler/src/commands/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(debug_assertions)]
use colored::Colorize;
use miette::Result;
use std::fs;

Expand All @@ -8,20 +10,24 @@ use crate::{
linker::Linker,
};

/// Builds the provided COBOL file.
/// Executes the given build command, building all passed files.
pub(crate) fn run_build(args: BuildCommand) -> Result<()> {
// Create a build configuration from the passed arguments.
let cfg = BuildConfig::try_from(args)?;

// Load contents of passed file.
// Load contents of passed file, attempt build.
let txt = fs::read_to_string(&cfg.input_file).expect("Failed to load source file from disk.");
build_file(&txt, &cfg)
}

/// Builds the provided source COBOL file, producing an output executable.
pub(crate) fn build_file(source: &str, cfg: &BuildConfig) -> Result<()> {
// Perform a parse pass.
let parser = Parser::new(cfg.input_file.to_str().unwrap(), &txt);
let parser = Parser::new(cfg.input_file.to_str().unwrap(), source);
let ast = parser.parse()?;
#[cfg(debug_assertions)]
if cfg.output_ast {
println!("info(ast): {:#?}", ast);
println!("{}{:#?}", "info(ast): ".blue(), ast);
}

// Translate the AST into Cranelift IR.
Expand All @@ -38,4 +44,4 @@ pub(crate) fn run_build(args: BuildCommand) -> Result<()> {
linker.link()?;

Ok(())
}
}
5 changes: 5 additions & 0 deletions crates/compiler/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ mod build;

// Limited re-exports of command modules.
pub(crate) use build::run_build as build;

// Exports for unit testing.
#[doc(hidden)]
#[allow(unused_imports)]
pub(crate) use build::build_file;
5 changes: 5 additions & 0 deletions crates/compiler/src/compiler/parser/divs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ pub(crate) struct IdentDiv<'src> {
impl<'src> Parser<'src> {
/// Parses an identification division from COBOL tokens.
pub(super) fn ident_div(&mut self) -> Result<IdentDiv<'src>> {
// Consume any leading whitespace.
if self.peek() == tok![eol] {
self.next()?;
}

// Parse header.
self.consume_vec(&[tok![ident_div], tok![.], tok![eol]])?;

Expand Down
3 changes: 3 additions & 0 deletions crates/compiler/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ mod compiler;
mod config;
mod linker;

#[cfg(test)]
mod tests;

fn main() -> miette::Result<()> {
let cli = cli::Cli::parse();
match cli.command() {
Expand Down
234 changes: 234 additions & 0 deletions crates/compiler/src/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
use std::{
io::Write,
path::PathBuf,
process::{Command, Stdio},
str::FromStr,
};

use crate::config::BuildConfig;

/// Helper for executing common compiler conformance tests
/// within the unit testing framework.
pub struct CommonTestRunner {
/// The name of this test.
name: &'static str,

/// The input to pass to the compiler as a source file.
input: &'static str,

/// The expected output type.
expected: ExpectedOutput,
}

/// Represents a single expected output from a common compiler
/// conformance test.
pub enum ExpectedOutput {
/// Expect nothing, run the test unconditionally.
/// Only fails on a compiler panic.
None,

/// Compile fails with output.
CompileFailure {
/// An expected reason.
reason: Option<&'static str>,
},

/// The compilation succeeds, no output tested.
CompilePass,

/// The compilation succeeds, the output binary produces
/// some well known output.
ProgramOutput {
input: Option<&'static str>,
expected: &'static str,
},
}

impl CommonTestRunner {
/// Creates a new common test runner.
pub fn new(name: &'static str) -> Self {
CommonTestRunner {
name,
input: "",
expected: ExpectedOutput::None,
}
}

/// Sets the input source file as the given static text.
pub fn source(mut self, source: &'static str) -> Self {
self.input = source;
self
}

/// Modifies the current test runner to expect a compile failure, with an optional
/// reason provided.
pub fn expect_fail(mut self, reason: Option<&'static str>) -> Self {
self.expected = ExpectedOutput::CompileFailure { reason };
self
}

/// Modifies the current test runner to expect a compile failure with no output testing.
pub fn expect_pass(mut self) -> Self {
self.expected = ExpectedOutput::CompilePass;
self
}

/// Modifies the current test runner to expect a compile pass, with the output program
/// producing the given output.
pub fn expect_output(mut self, output: &'static str) -> Self {
self.expected = ExpectedOutput::ProgramOutput {
input: None,
expected: output,
};
self
}

/// Modifies the current test runner to expect a compile pass, with the output program
/// producing the given output when given the following input on stdin.
pub fn expect_output_with_input(mut self, input: &'static str, output: &'static str) -> Self {
self.expected = ExpectedOutput::ProgramOutput {
input: Some(input),
expected: output,
};
self
}

/// Executes this test runner. Panics on test failure.
pub fn run(self) {
// Create a build configuration based on our inputs.
// The input file here is mocked and not a real path, but that shouldn't matter.
let out_dir = PathBuf::from_str("target").unwrap();
let mut out_file = out_dir.clone();
out_file.push(format!("{}.out", self.name));
let build_cfg = BuildConfig {
input_file: PathBuf::from_str(&self.name).unwrap(),
out_dir: out_dir.clone(),
out_file,
gen_security_features: true,
opt_level: "none".into(),
output_ast: false,
output_ir_regex: None,
};

// Make sure our output directory exists.
if !out_dir.exists() {
std::fs::create_dir(out_dir.clone())
.expect("Failed to create output directory for test binary.");
}

// Run our build!
let build_result = crate::commands::build_file(&self.input, &build_cfg);

// Check the build output matches our expected output.
match self.expected {
// No testing required.
ExpectedOutput::None => return,

// Test that we failed a compile.
ExpectedOutput::CompileFailure { reason } => {
if !build_result.is_err() {
panic!(
"Test {} expected build to fail, but build succeeded.",
self.name
);
}
if let Some(reason) = reason {
let build_output = build_result.unwrap_err().to_string();
if !build_output.contains(reason) {
panic!("Test {} expected build to fail with reason '{}', but the build did not fail with that reason. Compiler output: {}", self.name, reason, build_output);
}
}
}

// Test that our compile succeeded.
ExpectedOutput::CompilePass => {
if let Err(e) = build_result {
panic!(
"Test {} expected to pass, but failed with error: {}",
self.name, e
);
}
}

// Test that our compile succeeded, and additionally produces some output.
ExpectedOutput::ProgramOutput { input, expected } => {
if let Err(e) = build_result {
panic!(
"Test {} expected to pass, but failed with error: {}",
self.name, e
);
}
Self::test_output(&self.name, input, expected);
}
}

// Remove any remaining object files in the test output directory.
for path in std::fs::read_dir("target").unwrap() {
let path = path.unwrap().path();
if path.to_str().unwrap() == format!("{}.o", self.name)
|| path.to_str().unwrap() == format!("{}.out", self.name)
{
let _ = std::fs::remove_file(path);
}
}
}

/// Tests the output of a single common test runner, assuming that output is placed at
/// `./target/{test_name}.out`. Panics on failure.
fn test_output(test_name: &str, input: Option<&str>, expected: &str) {
let mut out_bin = PathBuf::from_str("target").unwrap();
out_bin.push(format!("{}.out", test_name));

// Execute with/without `stdin` and get output.
let output = if let Some(input) = input {
run_bin_stdin(&out_bin, input)
} else {
run_bin_nostdin(&out_bin)
};

// Check if output matches expected.
if output != expected {
panic!("Failure for test '{}' output conformancy:\n=== Expected ===\n{}\n=== Found ===\n{}", test_name, expected, output);
}
}
}

/// Executes the given binary, returning the output that the command created with no input.
/// Panics on failure to execute.
fn run_bin_nostdin(bin: &PathBuf) -> String {
let mut cmd = Command::new(bin.to_str().unwrap());
String::from_utf8(
cmd.output()
.expect(&format!(
"Failed to execute test binary: {}",
bin.to_str().unwrap()
))
.stdout,
)
.unwrap()
}

/// Executes the given binary, passing the provided input via. `stdin`.
/// Returns the output generated by the given program on `stdout`. Panics on failure.
fn run_bin_stdin(bin: &PathBuf, input: &str) -> String {
let mut cmd = Command::new(bin.to_str().unwrap());
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
let input_bytes = input.as_bytes();

let mut child = cmd.spawn().expect(&format!(
"Failed to spawn child test process: {}",
bin.to_str().unwrap()
));
if let Some(mut child_stdin) = child.stdin.take() {
child_stdin.write_all(input_bytes).unwrap();
}
let stdout = child
.wait_with_output()
.expect(&format!(
"Failed to execute child test process: {}",
bin.to_str().unwrap()
))
.stdout;
String::from_utf8(stdout).unwrap()
}
Loading

0 comments on commit c0ec581

Please sign in to comment.