use std::{
fs::{read, read_to_string, File},
io::{self, Read},
iter::once,
path::{Path, PathBuf},
};
use log::{debug, trace};
use oicana_input::{
input::{
blob::{Blob, BlobInput},
json::JsonInput,
},
CompilationConfig, CompilationMode, TemplateInputs,
};
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use typst::foundations::Dict;
pub mod collect;
pub mod execution;
#[derive(Debug)]
pub struct Test {
pub inputs: TemplateInputs,
pub fuzzed_inputs: Option<FuzzJson>,
pub name: String,
pub snapshot: Snapshot,
pub collection: PathBuf,
pub descriptor: String,
}
#[derive(Debug)]
pub enum Snapshot {
None,
Missing(PathBuf, SnapshotMode),
Some(PathBuf, SnapshotMode),
}
#[derive(Debug, Clone, Copy)]
pub enum SnapshotMode {
Update,
Compare,
}
impl Test {
pub fn new(
template_test: TemplateTest,
collection_name: Option<String>,
path_components: &[String],
collection_path: &Path,
snapshot_mode: SnapshotMode,
root: &Path,
) -> Result<Self, PrepareTestError> {
let snapshot_path = match template_test.snapshot {
None => Some(root.join(format!(
"{}{}.png",
collection_name
.as_ref()
.map(|name| format!("{name}."))
.unwrap_or("".to_owned()),
template_test.name
))),
Some(SnapshotConfig::Path(path)) => Some(root.join(path)),
Some(SnapshotConfig::Disabled) => None,
};
let snapshot = match snapshot_path {
Some(path) => {
if path.is_file() {
Snapshot::Some(path, snapshot_mode)
} else {
Snapshot::Missing(path, snapshot_mode)
}
}
None => Snapshot::None,
};
let (inputs, fuzzed_inputs) =
Self::build_inputs(template_test.mode, template_test.inputs, root)?;
trace!("Collecting test {}", &template_test.name);
Ok(Test {
inputs,
fuzzed_inputs,
collection: collection_path.to_path_buf(),
descriptor: path_components
.iter()
.cloned()
.chain(collection_name)
.chain(once(template_test.name.clone()))
.collect::<Vec<String>>()
.join(" > "),
name: template_test.name,
snapshot,
})
}
fn build_inputs(
mode: CompilationMode,
input_values: Vec<InputValue>,
root: &Path,
) -> Result<(TemplateInputs, Option<FuzzJson>), PrepareTestError> {
let mut inputs = TemplateInputs::new();
let mut fuzzed_input = None;
inputs.with_config(CompilationConfig::new(mode));
for input in input_values {
match input {
InputValue::Json(json_input) => match json_input {
JsonInputValue {
key,
value: JsonTestValue::File { file },
} => {
let file_path = root.join(file);
let value = read_to_string(&file_path)
.map_err(|source| PrepareTestError::Io { file_path, source })?;
inputs.with_input(JsonInput::new(key, value));
}
JsonInputValue {
key,
value: JsonTestValue::Fuzz { samples },
} => {
if fuzzed_input.is_some() {
return Err(PrepareTestError::OnlyOneFuzzedInput);
}
let _ = fuzzed_input.insert(FuzzJson { samples, key });
}
},
InputValue::Blob(blob) => {
let file_path = root.join(blob.file);
let value = read(&file_path)
.map_err(|source| PrepareTestError::Io { file_path, source })?;
let mut blob_value: Blob = value.into();
blob_value.metadata = match blob.meta {
Some(meta) => Deserialize::deserialize(meta)?,
None => Dict::new(),
};
inputs.with_input(BlobInput::new(blob.key, blob_value));
}
}
}
Ok((inputs, fuzzed_input))
}
}
#[derive(Debug)]
pub struct FuzzJson {
samples: usize,
key: String,
}
#[derive(Debug, Error)]
pub enum PrepareTestError {
#[error("Failed to read file '{file_path}': {source}")]
Io {
file_path: PathBuf,
#[source]
source: io::Error,
},
#[error("Failed to find snapshot file at '{0}'")]
NoSnapshot(PathBuf),
#[error("Only one input can be fuzzed per test")]
OnlyOneFuzzedInput,
#[error("Failed to convert metadata to Typst dictionary '{0}'")]
FailedToConvertMetadata(#[from] toml::de::Error),
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TemplateTestCollection {
pub tests_version: u8,
pub name: Option<String>,
#[serde(default = "Vec::new", rename = "test")]
pub tests: Vec<TemplateTest>,
}
impl TemplateTestCollection {
fn read_from(path: &Path) -> Result<Self, TestCollectionError> {
let mut file = File::open(path)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
debug!("Parsing {:?} to template test collection.", path);
let mut collection = toml::de::from_str::<TemplateTestCollection>(&content)?;
if collection.name.is_none() {
let file_name = path
.file_name()
.map(|file_name| {
file_name
.to_string_lossy()
.strip_suffix(".tests.toml")
.unwrap_or("")
.trim()
.to_owned()
})
.unwrap_or_default();
if !file_name.is_empty() {
collection.name = Some(file_name);
}
}
Ok(collection)
}
}
#[derive(Debug, Error)]
pub enum TestCollectionError {
#[error("Failed to read file")]
IoError(#[from] io::Error),
#[error("Failed to parse test collection")]
ParsingError(#[from] toml::de::Error),
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct TemplateTest {
pub name: String,
#[serde(deserialize_with = "snapshot_config", default = "none")]
pub snapshot: Option<SnapshotConfig>,
#[serde(default = "Vec::new")]
pub inputs: Vec<InputValue>,
#[serde(default = "production")]
pub mode: CompilationMode,
}
fn none() -> Option<SnapshotConfig> {
None
}
fn snapshot_config<'de, D>(deserializer: D) -> Result<Option<SnapshotConfig>, D::Error>
where
D: Deserializer<'de>,
{
match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Bool(false) => Ok(Some(SnapshotConfig::Disabled)),
serde_json::Value::String(s) => Ok(Some(SnapshotConfig::Path(s))),
other => Err(serde::de::Error::invalid_type(
serde::de::Unexpected::Other(&format!("{:?}", other)),
&"a boolean false or a string",
)),
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged, deny_unknown_fields)]
pub enum SnapshotConfig {
Disabled,
Path(String),
}
fn production() -> CompilationMode {
CompilationMode::Production
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(tag = "type")]
pub enum InputValue {
#[serde(rename = "json")]
Json(JsonInputValue),
#[serde(rename = "blob")]
Blob(BlobInputValue),
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct JsonInputValue {
pub key: String,
#[serde(flatten)]
pub value: JsonTestValue,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[serde(untagged, deny_unknown_fields)]
pub enum JsonTestValue {
Fuzz {
samples: usize,
},
File {
file: String,
},
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct BlobInputValue {
pub key: String,
pub file: String,
pub meta: Option<toml::Value>,
}
#[cfg(test)]
mod tests {
use crate::TemplateTestCollection;
use oicana_input::CompilationMode;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn takes_name_from_file_content() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("bar.tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
name = "foo"
tests_version = 1
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.name, Some(String::from("foo")));
}
#[test]
fn takes_name_from_file_name() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("bar.tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
tests_version = 1
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.name, Some(String::from("bar")));
}
#[test]
fn no_name() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
tests_version = 1
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.name, None);
}
#[test]
fn mode() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
tests_version = 1
[[test]]
name = "test"
mode = "development"
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.tests[0].mode, CompilationMode::Development);
}
#[test]
fn default_mode() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
tests_version = 1
[[test]]
name = "test"
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.tests[0].mode, CompilationMode::Production);
}
#[test]
fn short_mode() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join("tests.toml");
let mut file = File::create(&path).unwrap();
write!(
&mut file,
r#"
tests_version = 1
[[test]]
name = "test"
mode = "dev"
"#
)
.unwrap();
let test_collection = TemplateTestCollection::read_from(&path)
.expect("Failed to read test collection from file");
assert_eq!(test_collection.tests[0].mode, CompilationMode::Development);
}
}