wdl-engine 0.13.1

Execution engine for Workflow Description Language (WDL) documents.
Documentation
//! Common logic for the `wdl-engine` integration tests.
//!
//! This is located in `common/mod.rs` rather than `common.rs` in order to avoid
//! `cargo test` treating this as an integration test target and needlessly
//! compiling a test executable from its source.

use std::collections::HashMap;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;

use anyhow::Context as _;
use anyhow::bail;
use futures::future::BoxFuture;
use libtest_mimic::Trial;
use pretty_assertions::StrComparison;
use wdl_analysis::Config as AnalysisConfig;
use wdl_engine::config::BackendConfig;
use wdl_engine::config::Config as EngineConfig;

/// The set of tests that should only use the Docker backend
const DOCKER_ONLY_TESTS: &[&str] = &[
    // Disabled for local backend due to paths coming from the download cache
    "url-symlink",
];

/// The set of configs that determine how a test is run.
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct TestConfig {
    /// The analysis configuration for the tests.
    pub analysis: AnalysisConfig,
    /// The engine configuration for the tests.
    pub engine: EngineConfig,
}

/// Find tests to run in the given directory.
pub fn find_tests(
    run_test: fn(&Path, TestConfig) -> BoxFuture<'_, Result<(), anyhow::Error>>,
    base_dir: &Path,
    runtime: &tokio::runtime::Handle,
) -> Result<Vec<Trial>, anyhow::Error> {
    let mut tests = vec![];
    for entry in base_dir.read_dir().unwrap() {
        let entry = entry.expect("failed to read directory");
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let test_name_base = path
            .file_stem()
            .map(std::ffi::OsStr::to_string_lossy)
            .unwrap()
            .into_owned();
        for (config_name, config) in resolve_configs(&path)
            .with_context(|| format!("getting configs for {test_name_base}"))?
        {
            let test_runtime = runtime.clone();
            let test_path = path.clone();
            tests.push(Trial::test(
                format!("{test_name_base}_{config_name}"),
                move || {
                    Ok(test_runtime
                        .block_on(run_test(test_path.as_path(), config))
                        .map_err(|e| format!("{e:?}"))?)
                },
            ));
        }
    }
    Ok(tests)
}

/// Gets the configurations to use for the test, merging in any
/// `config-override.toml` files that may be present in the test directory.
pub fn resolve_configs(path: &Path) -> Result<HashMap<String, TestConfig>, anyhow::Error> {
    let mut base_configs = base_configs()?;
    let config_override_path = path.join("config-override.toml");
    if config_override_path.exists() {
        for config in base_configs.values_mut() {
            let combined = config::Config::builder()
                .add_source(config::Config::try_from(config).unwrap())
                .add_source(config::File::from(config_override_path.as_path()).required(true))
                .build()?
                .try_deserialize()?;
            *config = combined;
        }
    }

    // Remove the local configuration if the test is marked as Docker-only
    if let Some(test) = path.file_name().and_then(OsStr::to_str)
        && DOCKER_ONLY_TESTS.contains(&test)
    {
        base_configs.remove("local");
    }

    Ok(base_configs)
}

/// Get the baseline configs for executing the tests.
///
/// These configs may be modified by merging with `config-override.toml` files
/// in individual test directories before execution.
///
/// If the `SPROCKET_TEST_ENGINE_CONFIG` environment variable is set, the file
/// it points to will be used as the sole base engine config. This is primarily
/// meant for testing in environments with idiosyncratic requirements, such as
/// an HPC.
///
/// Otherwise, a default set containing at least the default analysis config and
/// a local backend config will be used.
pub fn base_configs() -> Result<HashMap<String, TestConfig>, anyhow::Error> {
    if let Some(env_config) = env::var_os("SPROCKET_TEST_ENGINE_CONFIG") {
        let engine = toml::from_str(&std::fs::read_to_string(env_config)?)?;
        let config = TestConfig {
            engine,
            ..TestConfig::default()
        };
        return Ok(HashMap::from([("env_config".to_string(), config)]));
    }

    #[allow(unused_mut)]
    let mut configs = HashMap::from([(
        "local".to_string(),
        TestConfig {
            engine: EngineConfig {
                backends: [(
                    "default".to_string(),
                    BackendConfig::Local(Default::default()),
                )]
                .into(),
                ..Default::default()
            },
            ..TestConfig::default()
        },
    )]);

    // Currently we limit running the Docker backend to Linux as GitHub does not
    // have Docker installed on macOS hosted runners and the Windows hosted
    // runners are configured to use Windows containers
    #[cfg(not(docker_tests_disabled))]
    configs.insert(
        "docker".to_string(),
        TestConfig {
            engine: EngineConfig {
                backends: [(
                    "default".to_string(),
                    BackendConfig::Docker(Default::default()),
                )]
                .into(),
                ..Default::default()
            },
            ..TestConfig::default()
        },
    );

    Ok(configs)
}

/// Strips paths from the given string.
pub fn strip_paths(root: &Path, s: &str) -> String {
    #[cfg(windows)]
    {
        // First try it with a single slash
        let mut pattern = root.to_str().expect("path is not UTF-8").to_string();
        if !pattern.ends_with('\\') {
            pattern.push('\\');
        }

        // Next try with double slashes in case there were escaped backslashes
        let s = s.replace(&pattern, "");
        let pattern = pattern.replace('\\', "\\\\");
        s.replace(&pattern, "")
    }

    #[cfg(unix)]
    {
        let mut pattern = root.to_str().expect("path is not UTF-8").to_string();
        if !pattern.ends_with('/') {
            pattern.push('/');
        }

        s.replace(&pattern, "")
    }
}

/// Normalizes a result.
pub fn normalize(s: &str) -> String {
    // Normalize paths separation characters first
    s.replace("\\\\", "/")
        .replace("\\", "/")
        .replace("\r\n", "\n")
}

/// Compares a single result.
pub fn compare_result(path: &Path, result: &str) -> Result<(), anyhow::Error> {
    let result = normalize(result);
    if env::var_os("BLESS").is_some() {
        fs::write(path, &result).with_context(|| {
            format!(
                "failed to write result file `{path}`",
                path = path.display()
            )
        })?;
        return Ok(());
    }

    let expected = fs::read_to_string(path)
        .with_context(|| {
            format!(
                "failed to read result file `{path}`: expected contents to be `{result}`",
                path = path.display()
            )
        })?
        .replace("\r\n", "\n");

    if expected != result {
        bail!(
            "result from `{path}` is not as expected:\n{diff}",
            path = path.display(),
            diff = StrComparison::new(&expected, &result),
        );
    }

    Ok(())
}