Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
radicle-native-ci tests integration.rs
//! Integration tests for radicle-native-ci.
//!
//! Most of this module is helpers to make the actual tests easier to
//! express.
//!
//! These test the `radicle-ci-binary` by running it with different
//! inputs, and verifying the output is as expected.
//!
//! The binary is run with `cargo run -q`.
//!
//! Each can set up a configuration file for the adapter, a Radicle
//! node, and a git repository with a `.radicle/native.yaml` that runs
//! the appropriate shell command for the test case. Depending on the
//! test case, some or all of the previous may or may not happen.
//!
//! Each test case is represented by [`TestCase`], and those are built
//! with [`TestCaseBuilder`]. The builder pattern makes it easy to
//! vary what happens in each test case in a reasonably clear fashion.

use std::{
    collections::HashMap,
    fmt,
    fs::File,
    io::Write,
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use radicle::{identity::Did, prelude::RepoId};
use radicle_ci_broker::{
    ergo::Oid,
    msg::{
        Author, EventCommonFields, EventType, Patch, PatchAction, PatchEvent, Repository, Request,
        Response, RunId, RunResult, State,
    },
};
use tempfile::{tempdir, TempDir};

use radicle_native_ci::{config::Config, runspec::RunSpec};

// The result of running the adapter.
#[allow(dead_code)]
struct AdapterResult {
    exit: i32,
    stdout: String,
    stderr: String,
}

impl AdapterResult {
    // The messages that the adapter outputs, if parsing them is
    // possible.
    fn messages(&self) -> Result<Vec<Response>, serde_json::Error> {
        self.stdout
            .lines()
            .map(serde_json::from_str::<Response>)
            .collect()
    }

    // Assert that adapter stderr contains the wanted sub-string.
    fn assert_stderr_contains(&self, wanted: &str) {
        assert!(self.stderr.contains(wanted));
    }

    // Assert the adapter terminated with a specific exit code.
    fn assert_got_exit(&self, wanted: i32) {
        assert_eq!(self.exit, wanted);
    }

    // Return run id from adapter, if one was returned.
    fn run_id(&self) -> Option<RunId> {
        let msgs = self.messages().unwrap();
        assert!(!msgs.is_empty());
        match &msgs[0] {
            Response::Triggered { run_id, .. } => Some(run_id.clone()),
            _ => None,
        }
    }

    // Assert that the adapter sent a message with a run ID.
    fn assert_got_run_id(&self) {
        assert!(self.run_id().is_some());
    }

    // Assert that the adapter sent a message the expected info URL.
    fn assert_url_is(&self, expected: &str) {
        let msgs = self.messages().unwrap();
        assert!(!msgs.is_empty());
        match &msgs[0] {
            Response::Triggered {
                run_id: _,
                info_url: Some(url),
            } => {
                assert_eq!(url, expected);
            }
            _ => panic!("unexpected message {:#?}", msgs[0]),
        }
    }

    // Assert that the adapter wrote a message indicating the CI run
    // succeeded.
    fn assert_got_success(&self) {
        let msgs = self.messages().unwrap();
        assert!(msgs.len() == 2);
        assert!(matches!(
            msgs[1],
            Response::Finished {
                result: RunResult::Success
            }
        ));
    }

    // Assert that the adapter wrote a message indicating the CI run
    // failed.
    fn assert_got_failure(&self) {
        let msgs = self.messages().unwrap();
        assert!(msgs.len() == 2);
        assert!(matches!(
            msgs[1],
            Response::Finished {
                result: RunResult::Failure
            }
        ));
    }
}

// Format an [`AdapterResult`] in a more readable way.
impl fmt::Debug for AdapterResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&format!(
            "AdapterResult:\n  exit: {}\n  stdout:\n{}  stderr:\n{}",
            self.exit,
            indent(&self.stdout),
            indent(&self.stderr),
        ))
    }
}

// Indent every line in a string.
fn indent(s: &str) -> String {
    let mut out = String::new();
    for line in s.lines() {
        out.push_str(&format!("    {line:?}\n"));
    }
    out
}

// All the information needed to run the adapter for a specific test
// case. Constructed by [`TestCaseBuilder::build`].
struct TestCase {
    tmp: TempDir,
    envs: HashMap<String, String>,
    git: Git,
    rad: Rad,
    request: TriggerKind,
}

impl TestCase {
    fn run(&self) -> Result<AdapterResult, std::io::Error> {
        println!("run adapter");

        let tmp = self.tmp.path().to_path_buf();

        // Create a request and write it to a file, if requested. The
        // file is always created so that it can be the adapter's
        // stdin.
        let req_filename = tmp.join("request.json");
        let mut req_file = File::create(&req_filename)?;
        match self.request {
            TriggerKind::Empty => {}
            TriggerKind::Trigger => {
                let commit = self.git.head()?;
                self.rad.init(self.git.path())?;
                let rid = self.rad.rid(self.git.path())?;
                let req = trigger(rid, commit);
                let mut line = serde_json::to_string(&req).unwrap();
                line.push('\n');
                req_file.write_all(line.as_bytes())?;
            }
            TriggerKind::MissingRepo => {
                let commit = Oid::from_str("54aacc96197a48b79fcc260f94312d824f5e0a34").unwrap();
                let rid = RepoId::from_str("rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE").unwrap();
                let req = trigger(rid, commit);
                let mut line = serde_json::to_string(&req).unwrap();
                line.push('\n');
                req_file.write_all(line.as_bytes())?;
            }
            TriggerKind::MissingCommit => {
                let commit = Oid::from_str("54aacc96197a48b79fcc260f94312d824f5e0a34").unwrap();
                self.rad.init(self.git.path())?;
                let rid = self.rad.rid(self.git.path())?;
                let req = trigger(rid, commit);
                let mut line = serde_json::to_string(&req).unwrap();
                line.push('\n');
                req_file.write_all(line.as_bytes())?;
            }
        }

        println!("run adapter with cargo run");

        println!("self.envs start");
        for (name, value) in self.envs.iter() {
            println!("  {name}={value:?}");
        }
        println!("self.envs end");

        let adapter = Command::new("cargo")
            .args(["run", "-q"])
            .env_clear()
            .envs(self.envs.iter())
            .stdin(File::open(&req_filename)?)
            .output()?;
        let result = AdapterResult {
            exit: adapter.status.code().unwrap(),
            stdout: String::from_utf8_lossy(&adapter.stdout).into(),
            stderr: String::from_utf8_lossy(&adapter.stderr).into(),
        };
        println!("{result:#?}");
        Ok(result)
    }
}

// What kind of config should the adapter be provided?
enum ConfigKind {
    // No config: do not set RADICLE_NATIVE_CI in the environment.
    UnsetEnvVar,

    // No config: file does not exist.
    DoesNotExist(PathBuf),

    // No config: file is empty.
    EmptyConfig(PathBuf),

    // Valid config, without info base URL.
    Valid(PathBuf),

    // Valid, but with info base URL.
    ValidWithUrl(PathBuf, String),
}

// What kind of trigger message should we give the adapter?
enum TriggerKind {
    // Broken: an empty input.
    Empty,

    // Broken: the repository referred to doesn't exist.
    MissingRepo,

    // Broken: the commit referred to doesn't exist in the repo.
    MissingCommit,

    // Valid Trigger message.
    Trigger,
}

// Build a new [`TestCase`].
//
// Before `build` is called, a config and a request must be chosen.
// The shell command to run is optional, and defaults to an empty
// command.
struct TestCaseBuilder {
    tmp: TempDir,
    config: Option<ConfigKind>,
    request: Option<TriggerKind>,
    shell: String,
}

impl TestCaseBuilder {
    fn new() -> Result<Self, std::io::Error> {
        // Create a temporary directory for all files for this test
        // case. When the test case is dropped, the directory and all
        // its contents are deleted.
        let tmp = tempdir()?;

        Ok(Self {
            tmp,
            config: None,
            request: None,
            shell: "".into(),
        })
    }

    fn without_config(mut self) -> Self {
        self.config = Some(ConfigKind::UnsetEnvVar);
        self
    }

    fn with_empty_config(mut self) -> Self {
        self.config = Some(ConfigKind::EmptyConfig(PathBuf::from("/dev/null")));
        self
    }

    fn with_nonexistent_config(mut self) -> Self {
        self.config = Some(ConfigKind::DoesNotExist(
            self.tmp.path().join("does-not-exist.yaml"),
        ));
        self
    }

    fn with_config(mut self) -> Self {
        self.config = Some(ConfigKind::Valid(self.tmp.path().join("config.yaml")));
        self
    }

    fn with_config_with_url(mut self, url: &str) -> Self {
        self.config = Some(ConfigKind::ValidWithUrl(
            self.tmp.path().join("config.yaml"),
            url.into(),
        ));
        self
    }

    fn with_empty_request(mut self) -> Self {
        self.request = Some(TriggerKind::Empty);
        self
    }

    fn with_request_for_nonexistent_repo(mut self) -> Self {
        self.request = Some(TriggerKind::MissingRepo);
        self
    }

    fn with_request_for_nonexistent_commit(mut self) -> Self {
        self.request = Some(TriggerKind::MissingCommit);
        self
    }

    fn with_request(mut self) -> Self {
        self.request = Some(TriggerKind::Trigger);
        self
    }

    fn with_shell(mut self, shell: &str) -> Self {
        self.shell = shell.into();
        self
    }

    fn build(self) -> Result<TestCase, std::io::Error> {
        let tmp = self.tmp.path();

        // Create a Radicle node.
        let rad = Rad::new(&tmp.join("dot-radicle"))?;
        rad.auth()?;

        // Create a git repository.
        let path = tmp.join("git");
        let git = Git::new(&path)?;
        git.init()?;
        git.write("README.md", "Test README")?;

        // Add .radicle/native.yaml.
        let runspec = RunSpec { shell: self.shell };
        std::fs::create_dir(git.path().join(".radicle"))?;
        git.write(
            ".radicle/native.yaml",
            &serde_norway::to_string(&runspec).unwrap(),
        )?;

        // Commit everything.
        git.commit("first commit")?;

        // Create a native CI adapter config.
        assert!(self.config.is_some());
        let config = self.config.unwrap();
        if let ConfigKind::Valid(filename) = &config {
            let config = Config {
                state: tmp.join("state"),
                log: tmp.join("log.html"),
                base_url: None,
            };
            let config = serde_norway::to_string(&config).unwrap();
            std::fs::write(filename, config)?;
        } else if let ConfigKind::ValidWithUrl(filename, url) = &config {
            let config = Config {
                state: tmp.join("state"),
                log: tmp.join("log.html"),
                base_url: Some(url.into()),
            };
            let config = serde_norway::to_string(&config).unwrap();
            std::fs::write(filename, config)?;
        }

        // Set up the requested environment with RAD_HOME and
        // RAD_PASSPHRASE set to the right values.
        let mut envs: HashMap<String, String> = std::env::vars()
            .filter(|(k, _)| k != "RADICLE_NATIVE_CI" && !k.starts_with("RAD_"))
            .collect();
        assert!(!envs.contains_key("RADICLE_NATIVE_CI"));
        envs.insert("RAD_HOME".into(), format!("{}", rad.home().display()));
        envs.insert("RAD_PASSPHRASE".into(), Rad::PASSPHRASE.into());

        // Set RADICLE_NATIVE_CI if it should be set.
        match config {
            ConfigKind::UnsetEnvVar => (),
            ConfigKind::DoesNotExist(filename)
            | ConfigKind::EmptyConfig(filename)
            | ConfigKind::Valid(filename)
            | ConfigKind::ValidWithUrl(filename, _) => {
                envs.insert("RADICLE_NATIVE_CI".into(), filename.display().to_string());
            }
        }

        assert!(self.request.is_some());
        Ok(TestCase {
            tmp: self.tmp,
            envs,
            git,
            rad,
            request: self.request.unwrap(),
        })
    }
}

// Manage a git repository for test purposes.
struct Git {
    path: PathBuf,
}

impl Git {
    fn new(path: &Path) -> Result<Self, std::io::Error> {
        println!("create git repository at {}", path.display());
        std::fs::create_dir(path)?;
        Ok(Self { path: path.into() })
    }

    fn path(&self) -> &Path {
        &self.path
    }

    fn write(&self, relative: &str, content: &str) -> Result<(), std::io::Error> {
        println!("write to file {relative} content {content:?}");
        std::fs::write(self.path.join(relative), content)?;
        Ok(())
    }

    fn _git(&self, args: &[&str]) -> Result<String, std::io::Error> {
        println!("run git {args:?} in {}", self.path.display());

        let output = Command::new("git")
            .current_dir(&self.path)
            .args(args)
            .output()?;

        let exit = output.status.code().unwrap();
        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        println!("  exit: {exit}");
        println!("  stdout: {stdout:?}");
        println!("  stderr: {stderr:?}");

        assert_eq!(exit, 0);
        Ok(stdout)
    }

    fn init(&self) -> Result<(), std::io::Error> {
        self._git(&["init", "."])?;
        Ok(())
    }

    fn commit(&self, msg: &str) -> Result<(), std::io::Error> {
        self._git(&["add", "."])?;
        self._git(&["commit", "-m", msg])?;
        Ok(())
    }

    fn head(&self) -> Result<Oid, std::io::Error> {
        let commit = self._git(&["rev-parse", "HEAD"])?;
        let commit = Oid::from_str(commit.trim()).unwrap();
        Ok(commit)
    }
}

// Manage a Radicle node with `rad` for test purposes. This sets up a
// new node for the test, it does not use the default node on the host.
struct Rad {
    home: PathBuf,
}

impl Rad {
    const PASSPHRASE: &'static str = "xyzzy";

    fn new(home: &Path) -> Result<Self, std::io::Error> {
        std::fs::create_dir(home)?;
        Ok(Self { home: home.into() })
    }

    fn home(&self) -> &Path {
        &self.home
    }

    fn _rad(&self, args: &[&str], cwd: &Path) -> Result<String, std::io::Error> {
        println!("run rad {args:?} in {}", cwd.display());

        let output = Command::new("rad")
            .current_dir(cwd)
            .args(args)
            .env("RAD_HOME", self.home.display().to_string())
            .env("RAD_PASSPHRASE", Self::PASSPHRASE)
            .output()?;

        let exit = output.status.code().unwrap();
        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        println!("  exit: {exit}");
        println!("  stdout: {stdout:?}");
        println!("  stderr: {stderr:?}");

        assert_eq!(exit, 0);
        Ok(stdout)
    }

    fn auth(&self) -> Result<(), std::io::Error> {
        self._rad(&["auth", "--alias=test-node"], &self.home)?;

        // FIXME: this is a temporary workaround for rad on the system
        // using a newer heartwood than what radicle-native-ci is
        // using, and there being a config entry that has changed.
        // This hack should be removed once the native CI adapter is
        // using a newer heartwood.
        let filename = self.home.join("config.json");
        let config = std::fs::read(&filename).unwrap();
        let config = String::from_utf8_lossy(&config);
        let config = config.replace(r#""relay": "auto","#, r#""relay": false,"#);
        std::fs::write(&filename, config.as_bytes()).unwrap();

        Ok(())
    }

    fn init(&self, git_dir: &Path) -> Result<(), std::io::Error> {
        let name = format!("{}", Path::new(git_dir.file_stem().unwrap()).display());
        self._rad(
            &[
                "init",
                "--name",
                &name,
                "--description=test repo",
                "--public",
                "--no-confirm",
            ],
            git_dir,
        )?;
        Ok(())
    }

    fn rid(&self, git_dir: &Path) -> Result<RepoId, std::io::Error> {
        let rid = self._rad(&["."], git_dir)?;
        let rid = RepoId::from_str(rid.trim()).unwrap();
        Ok(rid)
    }
}

// Construct a trigger message for test purposes.
fn trigger(repo_id: RepoId, commit: Oid) -> Request {
    Request::Trigger {
        common: EventCommonFields {
            version: 1,
            event_type: EventType::Push,
            repository: Repository {
                id: repo_id,
                name: "test-repo".into(),
                description: "test repo".into(),
                private: false,
                default_branch: "main".into(),
                delegates: vec![],
            },
        },
        push: None,
        patch: Some(PatchEvent {
            action: PatchAction::Updated,
            patch: Patch {
                id: Oid::from_str("ff3099ba5de28d954c41d0b5a84316f943794ea4").unwrap(),
                author: Author {
                    id: Did::decode("did:key:z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV")
                        .unwrap(),
                    alias: None,
                },
                title: "title".into(),
                state: State {
                    status: "status".into(),
                    conflicts: vec![],
                },
                before: commit,
                after: commit,
                commits: vec![commit],
                target: Oid::from_str("244130556b47dbc83323ad8e0c2b53c491e6b925").unwrap(),
                labels: vec![],
                assignees: vec![],
                revisions: vec![],
            },
        }),
    }
}

type TestResult = Result<(), std::io::Error>;

// What does the adapter do if the RADICLE_NATIVE_CI environment
// variable is not set?
#[test]
fn no_config_env_var() -> TestResult {
    let result = TestCaseBuilder::new()?
        .without_config()
        .with_empty_request()
        .build()?
        .run()?;
    result.assert_got_exit(2);
    result.assert_stderr_contains("RADICLE_NATIVE_CI");
    Ok(())
}

// What does the adapter do if its configuration file does not exist?
#[test]
fn config_does_not_exist() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_nonexistent_config()
        .with_empty_request()
        .build()?
        .run()?;
    result.assert_got_exit(2);
    result.assert_stderr_contains("failed to read configuration file");
    result.assert_stderr_contains("No such file or directory");
    Ok(())
}

// What does the adapter do if its configuration file is empty?
#[test]
fn empty_config() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_empty_config()
        .with_empty_request()
        .build()?
        .run()?;

    result.assert_got_exit(2);
    result.assert_stderr_contains("YAML");
    Ok(())
}

// What does the adapter do if its stdin is empty and does not have a
// request message?
#[test]
fn empty_request() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config()
        .with_empty_request()
        .build()?
        .run()?;

    result.assert_got_exit(2);
    result.assert_stderr_contains("read request from stdin");
    result.assert_stderr_contains("JSON");
    Ok(())
}

// What does the adapter do if CI runs a command that fails?
#[test]
fn command_fails() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config()
        .with_request()
        .with_shell("false")
        .build()?
        .run()?;

    result.assert_got_exit(1);
    result.assert_got_run_id();
    result.assert_got_failure();
    Ok(())
}

// What does the adapter do if it's triggered to run for a repository
// that doesn't exist?
#[test]
fn repo_doesnt_exist() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config()
        .with_request_for_nonexistent_repo()
        .with_shell("false")
        .build()?
        .run()?;

    result.assert_got_exit(1);
    result.assert_got_run_id();
    result.assert_got_failure();
    Ok(())
}

// What does the adapter do if it's triggered to run for a commit that
// does not exist in the repository it's running on?
#[test]
fn commit_doesnt_exist() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config()
        .with_request_for_nonexistent_commit()
        .with_shell("false")
        .build()?
        .run()?;

    result.assert_got_exit(1);
    result.assert_got_run_id();
    result.assert_got_failure();
    Ok(())
}

// What does the adapter do if everything goes well?
#[test]
fn happy_path() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config()
        .with_request()
        .with_shell("echo hello, world")
        .build()?
        .run()?;

    result.assert_got_exit(0);
    result.assert_got_run_id();
    result.assert_got_success();
    Ok(())
}

// Does the adapter construct a URL to the build log?
#[test]
fn happy_path_with_log_url() -> TestResult {
    let result = TestCaseBuilder::new()?
        .with_config_with_url("https://ci.radicle.liw.fi") // note lack of trailing slash
        .with_request()
        .with_shell("echo hello, world")
        .build()?
        .run()?;

    result.assert_got_exit(0);
    result.assert_got_run_id();
    result.assert_got_success();

    let run_id = result.run_id().unwrap();
    let expected = format!("https://ci.radicle.liw.fi/{run_id}/log.html");
    result.assert_url_is(&expected);
    Ok(())
}