maelstrom-pytest 0.14.0

Python Test Runner for Maelstrom.
use anyhow::{bail, Result};
use indicatif::InMemoryTerm;
use maelstrom_container::local_registry;
use maelstrom_pytest::{cli::ExtraCommandLineOptions, Config, LoggerBuilder};
use maelstrom_test_runner::ui::{SimpleUi, UiKind};
use maelstrom_util::{
    config::common::{CacheSize, ClusterCommunicationStrategy, InlineLimit, LogLevel, Slots},
    fs::Fs,
    process::ExitCode,
    root::RootBuf,
};
use std::process::{Command, Stdio};
use tempfile::tempdir;

fn sh(script: &str, description: &str) -> Result<()> {
    let mut cmd = Command::new("/bin/sh");
    cmd.args(["-c", script])
        .stderr(Stdio::piped())
        .stdout(Stdio::null());
    let mut child = cmd.spawn()?;

    let mut stderr = child.stderr.take().unwrap();
    let stderr_handle = std::thread::spawn(move || -> String {
        let mut stderr_buffer = vec![];
        let _ = std::io::copy(&mut stderr, &mut stderr_buffer);
        String::from_utf8_lossy(&stderr_buffer).into()
    });

    let exit_status = child.wait()?;
    if exit_status.success() {
        Ok(())
    } else {
        let stderr_str = stderr_handle.join().unwrap();
        bail!("{description} failed with: {exit_status}, and stderr = {stderr_str}")
    }
}

fn maybe_install_pytest() {
    if sh("python -c 'import pytest'", "").is_err() {
        sh("pip install pytest==8.1.1", "pip install").unwrap()
    }
}

fn do_maelstrom_pytest_test(
    source_contents: &str,
    extra_options: ExtraCommandLineOptions,
    terminal_size: (u16, u16),
) -> (String, ExitCode) {
    maybe_install_pytest();

    let fs = Fs::new();
    let temp_dir = tempdir().unwrap();

    let project_dir = temp_dir.path().join("project");
    fs.create_dir(&project_dir).unwrap();
    fs.write(project_dir.join("test_foo.py"), source_contents)
        .unwrap();

    fs.write(project_dir.join("test-requirements.txt"), "pytest==8.1.1")
        .unwrap();

    let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let log = maelstrom_util::log::test_logger();
    let container_address =
        local_registry::LocalRegistry::run_sync(manifest_dir.join("src"), log.clone()).unwrap();

    fs.write(
        project_dir.join("maelstrom-pytest.toml"),
        format!(
            indoc::indoc! {r#"
                [[directives]]
                image.name = "docker://{container_address}/python"
                image.use = ["layers", "environment"]
                added_layers = [
                    {{ glob = "**.py" }},
                    {{ stubs = ["/tmp/", "/proc/", "/sys/", "/dev/null", "/.pytest_cache/"] }}
                ]
                mounts = [
                    {{ type = "tmp", mount_point = "/tmp" }},
                    {{ type = "tmp", mount_point = "/.pytest_cache" }},
                    {{ type = "proc", mount_point = "/proc" }},
                    {{ type = "devices", devices = ["null"] }},
                ]
            "#},
            container_address = container_address
        ),
    )
    .unwrap();

    let term = InMemoryTerm::new(terminal_size.0, terminal_size.1);

    let exit_code = maelstrom_test_runner::main_for_test::<maelstrom_pytest::TestRunner>(
        env!("CARGO_BIN_EXE_maelstrom-pytest"),
        ["--client-process"],
        Config {
            parent: maelstrom_test_runner::config::Config {
                client: maelstrom_client::config::Config {
                    broker: None,
                    container_image_depot_root: RootBuf::new(temp_dir.path().join("container")),
                    cache_size: CacheSize::default(),
                    inline_limit: InlineLimit::default(),
                    slots: Slots::default(),
                    accept_invalid_remote_container_tls_certs: true.into(),
                    cluster_communication_strategy: ClusterCommunicationStrategy::Tcp,
                    github_actions_token: None,
                    github_actions_url: None,
                },
                log_level: LogLevel::Debug,
                timeout: None,
                ui: UiKind::Simple,
                repeat: Default::default(),
                stop_after: None,
            },
            pytest: Default::default(),
        },
        extra_options,
        |_| Ok(((), RootBuf::new(project_dir.to_owned()))),
        LoggerBuilder::GivenLogger(log),
        |_, is_listing, stdout_tty| {
            Ok(Box::new(SimpleUi::new(
                is_listing,
                stdout_tty,
                term.clone(),
            )))
        },
    )
    .unwrap();

    (term.contents(), exit_code)
}

fn do_maelstrom_pytest_test_success(
    source_contents: &str,
    extra_options: ExtraCommandLineOptions,
    terminal_size: (u16, u16),
) -> String {
    let (contents, exit_code) =
        do_maelstrom_pytest_test(source_contents, extra_options, terminal_size);
    assert_eq!(exit_code, ExitCode::SUCCESS);
    contents
}

#[test]
fn test_simple_success() {
    let contents = do_maelstrom_pytest_test_success(
        &indoc::indoc! {"
            def test_noop():
                pass
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["all".into()],
                ..Default::default()
            },
            list: false,
        },
        (50, 50),
    );
    assert!(
        contents.contains("test_foo.py::test_noop.................OK"),
        "{contents}"
    );
    assert!(
        contents.ends_with(
            "\
            ================== Test Summary ==================\n\
            Successful Tests:         1\n\
            Failed Tests    :         0\
        "
        ),
        "{contents}"
    );
}

#[test]
fn test_simple_failure() {
    let (contents, exit_code) = do_maelstrom_pytest_test(
        &indoc::indoc! {"
            def test_error():
                raise Exception('test error')
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["all".into()],
                ..Default::default()
            },
            list: false,
        },
        (50, 50),
    );
    assert_eq!(exit_code, ExitCode::from(1));

    let first_line = contents.split("\n").next().unwrap();
    let rest = &contents[first_line.len() + 1..];

    assert!(
        first_line.starts_with("test_foo.py::test_error..............FAIL"),
        "{contents}"
    );
    assert_eq!(
        rest,
        indoc::indoc! {"

                def test_error():
            >       raise Exception('test error')
            E       Exception: test error

            test_foo.py:2: Exception

            ================== Test Summary ==================
            Successful Tests:         0
            Failed Tests    :         1
                test_foo.py::test_error: failure\
        "},
        "{contents}"
    );
}

#[test]
fn test_collection_failure() {
    let (contents, exit_code) = do_maelstrom_pytest_test(
        &indoc::indoc! {"
            raise Exception('import error')
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["all".into()],
                ..Default::default()
            },
            list: false,
        },
        (100, 100),
    );
    assert_ne!(exit_code, ExitCode::SUCCESS);

    assert!(
        contents.contains(indoc::indoc! {"
            ==================================== ERRORS ====================================
            _________________________ ERROR collecting test_foo.py _________________________
            test_foo.py:1: in <module>
                raise Exception('import error')
            E   Exception: import error
            =========================== short test summary info ============================
            ERROR test_foo.py - Exception: import error
            !!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
        "}),
        "{contents}"
    );
}

#[test]
fn test_listing_all() {
    let contents = do_maelstrom_pytest_test_success(
        &indoc::indoc! {"
            def test_foo():
                pass

            def test_bar():
                pass
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["all".into()],
                ..Default::default()
            },
            list: true,
        },
        (50, 50),
    );

    assert_eq!(
        contents,
        indoc::indoc! {"
            test_foo.py::test_foo
            test_foo.py::test_bar\
        "},
        "{contents}"
    );
}

#[test]
fn test_listing_node_id() {
    let contents = do_maelstrom_pytest_test_success(
        &indoc::indoc! {"
            def test_foo():
                pass

            def test_bar():
                pass
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["node_id.equals(test_foo.py::test_foo)".into()],
                ..Default::default()
            },
            list: true,
        },
        (50, 50),
    );

    assert_eq!(
        contents,
        indoc::indoc! {"
            test_foo.py::test_foo\
        "},
        "{contents}"
    );
}

#[test]
fn test_listing_marker() {
    let contents = do_maelstrom_pytest_test_success(
        &indoc::indoc! {"
            import pytest

            @pytest.mark.baz
            def test_foo():
                pass

            def test_bar():
                pass
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["markers.contains(baz)".into()],
                ..Default::default()
            },
            list: true,
        },
        (50, 50),
    );

    assert_eq!(
        contents,
        indoc::indoc! {"
            test_foo.py::test_foo\
        "},
        "{contents}"
    );
}

#[test]
fn test_ignore() {
    let contents = do_maelstrom_pytest_test_success(
        &indoc::indoc! {"
            import pytest

            @pytest.mark.skip(reason='just because')
            def test_foo():
                pass

            @pytest.mark.skipif(True, reason='just because')
            def test_bar():
                pass

            @pytest.mark.skipif(False, reason='just because')
            def test_baz():
                pass
        "},
        ExtraCommandLineOptions {
            parent: maelstrom_test_runner::config::ExtraCommandLineOptions {
                include: vec!["all".into()],
                ..Default::default()
            },
            list: false,
        },
        (50, 50),
    );

    assert!(
        contents.contains("test_foo.py::test_foo.............IGNORED"),
        "{contents}"
    );
    assert!(
        contents.contains("test_foo.py::test_bar.............IGNORED"),
        "{contents}"
    );
    assert!(
        contents.contains("test_foo.py::test_baz..................OK"),
        "{contents}"
    );

    assert!(
        contents.contains(
            "\
            ================== Test Summary ==================\n\
            Successful Tests:         1\n\
            Failed Tests    :         0\n\
            Ignored Tests   :         2\
        "
        ),
        "{contents}"
    );
}