Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
tests: replace Python test-suite script with Rust integration test
Lars Wirzenius committed 2 years ago
commit 26396fa7d37478203b6132eb4bbec6268e469bb0
parent b98bd3c
2 files changed +668 -452
deleted test-suite
@@ -1,452 +0,0 @@
-
#!/usr/bin/python3
-

-
import argparse
-
import json
-
import logging
-
import os
-
import shutil
-
import subprocess
-
import sys
-
import tempfile
-
import yaml
-

-

-
class Suite:
-
    def __init__(self, tmp):
-
        self.tmp = tmp
-
        self.rad = Rad(os.path.join(tmp, "dot-radicle"))
-
        self.rad.auth()
-

-
        self.config = Config(tmp)
-
        self.config.write()
-

-
    def assert_triggered(self, msg):
-
        assert msg["response"] == "triggered"
-

-
    def assert_success(self, msg):
-
        assert msg == {"response": "finished", "result": "success"}
-

-
    def assert_failure(self, msg):
-
        assert msg["response"] == "finished"
-
        assert msg["result"] == "failure"
-

-
    def assert_error(self, msg):
-
        assert msg["response"] == "finished"
-
        res = msg["result"]
-
        assert isinstance(res, dict)
-
        assert "error" in res
-

-
    def run_all_test_cases(self, tests):
-
        methods = self._test_methods()
-
        chosen = self._chosen(methods, tests)
-
        for meth in methods:
-
            if meth in chosen:
-
                info(f"test case {meth}")
-
                getattr(self, meth)()
-
            else:
-
                info(f"skipping test {meth}")
-

-
    def _test_methods(self):
-
        return [meth for meth in dir(self) if meth.startswith("test_")]
-

-
    def _chosen(self, methods, tests):
-
        if tests:
-
            chosen = set()
-
            for test in tests:
-
                for meth in methods:
-
                    if test in meth:
-
                        chosen.add(meth)
-
        else:
-
            chosen = set(methods)
-
        return chosen
-

-
    def _create_git_repo(self, repo_name):
-
        git = Git(os.path.join(self.tmp, repo_name))
-
        git.init()
-
        git.write("README.md", "README")
-
        git.commit("first commit")
-
        return git
-

-
    def _create_valid_native_yaml(self, git, shell):
-
        native = NativeYaml(shell)
-
        git.write(".radicle/native.yaml", native.yaml())
-
        git.commit("add native.yaml")
-

-
    def _get_repo_info(self, git):
-
        commit = git.head()
-
        self.rad.init(git)
-
        rid = self.rad.rid(git)
-
        return rid, commit
-

-
    def _setup(self, repo_name, shell):
-
        git = self._create_git_repo(repo_name)
-
        self._create_valid_native_yaml(git, shell)
-
        return self._get_repo_info(git)
-

-
    def _create_ci(self):
-
        return NativeCI(self.rad, self.config)
-

-
    def _create_valid_trigger(self, rid, commit):
-
        return Trigger(rid, commit)
-

-
    def _run_ci(self, trigger, ci, rid, commit):
-
        exit, resps, stderr = ci.run(trigger)
-
        debug(f"exit: {exit}")
-
        debug(f"responses: {resps}")
-
        return exit, resps, stderr
-

-
    def _test_case(self, repo_name, shell):
-
        rid, commit = self._setup(repo_name, shell)
-
        ci = self._create_ci()
-
        trigger = self._create_valid_trigger(rid, commit)
-
        return self._run_ci(trigger, ci, rid, commit)
-

-
    def test_happy_path(self):
-
        exit, resps, stderr = self._test_case("happy-path", "echo hello, world")
-
        assert exit == 0
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_success(resps[1])
-

-
    def test_without_config_env_var(self):
-
        git = self._create_git_repo("no-config")
-
        self._create_valid_native_yaml(git, "echo hello world")
-
        rid, commit = self._get_repo_info(git)
-
        trigger = Trigger(rid, commit)
-
        ci = self._create_ci()
-
        ci.without_config()
-
        exit, resps, stderr = ci.run(trigger)
-
        debug(f"exit: {exit}")
-
        debug(f"responses: {resps}")
-
        assert exit != 0
-
        assert len(resps) == 0
-
        assert "RADICLE_NATIVE_CI" in stderr
-

-
    def test_config_missing(self):
-
        git = self._create_git_repo("config-missing")
-
        self._create_valid_native_yaml(git, "echo hello world")
-
        rid, commit = self._get_repo_info(git)
-
        trigger = Trigger(rid, commit)
-
        cfg2 = Config("no-config-file")
-
        ci = NativeCI(self.rad, cfg2)
-
        exit, resps, stderr = ci.run(trigger)
-
        debug(f"exit: {exit}")
-
        debug(f"responses: {resps}")
-
        assert exit != 0
-
        assert len(resps) == 0
-
        assert "no-config-file" in stderr
-

-
    def test_command_fails(self):
-
        exit, resps, stderr = self._test_case("cmd-fails", "false")
-
        assert exit == 1
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_failure(resps[1])
-

-
    def test_repository_does_not_exist(self):
-
        rid = "rad:z3aaaaaaaaaaaaaaaaaaaaaaaaaaa"
-
        commit = "8d947e182b096ec009e1c9eda9e6a67f5eef83d9"
-

-
        ci = self._create_ci()
-
        trigger = self._create_valid_trigger(rid, commit)
-
        exit, resps, stderr = self._run_ci(trigger, ci, rid, commit)
-

-
        assert exit == 2
-

-
    def test_commit_does_not_exist(self):
-
        git = self._create_git_repo("commit-missing")
-
        self._create_valid_native_yaml(git, "echo hello world")
-
        rid, _commit = self._get_repo_info(git)
-
        commit = "0000000000000000000000000000000000000000"
-
        trigger = Trigger(rid, commit)
-
        ci = self._create_ci()
-
        exit, resps, stderr = ci.run(trigger)
-

-
        assert exit == 1
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_failure(resps[1])
-

-
    def test_no_message(self):
-
        git = self._create_git_repo("no-message")
-
        self._create_valid_native_yaml(git, "echo hello world")
-
        ci = self._create_ci()
-
        exit, resps, stderr = ci.run_without_request()
-

-
        assert exit != 0
-
        assert len(resps) == 0
-

-
    def test_malformed_trigger(self):
-
        git = self._create_git_repo("malformed-trigger")
-
        self._create_valid_native_yaml(git, "echo hello world")
-
        rid, _commit = self._get_repo_info(git)
-
        trigger = MalformedTrigger()
-
        ci = self._create_ci()
-
        exit, resps, stderr = ci.run(trigger)
-

-
        assert exit != 0
-
        assert len(resps) == 0
-

-
    def test_native_yaml_has_no_shell(self):
-
        exit, resps, stderr = self._test_case("no-shell", None)
-
        assert exit == 1
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_failure(resps[1])
-

-
    def test_native_yaml_shell_is_not_string(self):
-
        exit, resps, stderr = self._test_case("shell-not-string", {"foo": "bar"})
-
        assert exit == 1
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_failure(resps[1])
-

-
    def test_command_takes_too_long(self):
-
        git = self._create_git_repo("command-takes-too-long")
-
        self._create_valid_native_yaml(git, "sleep 5")
-
        rid, commit = self._get_repo_info(git)
-
        trigger = Trigger(rid, commit)
-
        ci = self._create_ci()
-
        exit, resps, stderr = ci.run(trigger)
-
        assert exit == 1
-
        assert len(resps) == 2
-
        self.assert_triggered(resps[0])
-
        self.assert_failure(resps[1])
-
        assert "124" in stderr
-

-

-
class Git:
-
    def __init__(self, path):
-
        self.path = path
-

-
    def filename(self, relative):
-
        return os.path.join(self.path, "./" + relative)
-

-
    def write(self, relative, data):
-
        write(self.filename(relative), data)
-

-
    def _git(self, argv):
-
        return run(argv, cwd=self.path).stdout
-

-
    def init(self):
-
        assert not os.path.exists(self.path)
-
        run(["git", "init", self.path])
-
        debug(f"created git repository at {self.path}")
-

-
    def commit(self, msg):
-
        run(["git", "add", "."], cwd=self.path)
-
        run(["git", "commit", "-m", msg], cwd=self.path)
-

-
    def head(self):
-
        return self._git(["git", "rev-parse", "HEAD"]).strip()
-

-
    def ls_files(self):
-
        out = self._git(["git", "ls-files"])
-
        debug(f"ls-files:\n{out}")
-

-

-
class Rad:
-
    PASSPHRASE = "xyzzy"
-

-
    def __init__(self, rad_home):
-
        assert rad_home is not None
-
        self.rad_home = rad_home
-

-
    def _rad(self, argv, env=None, cwd=None):
-
        if env is None:
-
            env = {}
-
        env["RAD_HOME"] = self.rad_home
-
        env["RAD_PASSPHRASE"] = self.PASSPHRASE
-
        return run(argv, env=env, cwd=cwd).stdout
-

-
    def auth(self):
-
        self._rad(["rad", "auth", "--alias=test-node"])
-
        debug(f"created Radicle node at {self.rad_home}")
-

-
    def init(self, git):
-
        name = os.path.basename(git.path)
-
        self._rad(
-
            [
-
                "rad",
-
                "init",
-
                f"--name={name}",
-
                f"--description=test-repo",
-
                f"--public",
-
                f"--no-confirm",
-
            ],
-
            cwd=git.path,
-
        )
-
        debug(f"added git repository {git.path} to node")
-

-
    def rid(self, git):
-
        return self._rad(["rad", "."], cwd=git.path).strip()
-

-

-
class Config:
-
    def __init__(self, tmp):
-
        self.path = os.path.join(tmp, "config.yaml")
-
        self.dict = {
-
            "state": os.path.join(tmp, "state"),
-
            "log": os.path.join(tmp, "node-log.txt"),
-
            "timeout": 2,
-
        }
-

-
    def write(self):
-
        write(self.path, self.yaml())
-

-
    def yaml(self):
-
        return yaml.safe_dump(self.dict, indent=4)
-

-

-
class NativeYaml:
-
    def __init__(self, shell):
-
        self.dict = {}
-
        if shell is not None:
-
            self.dict["shell"] = shell
-

-
    def yaml(self):
-
        return yaml.safe_dump(self.dict, indent=4)
-

-

-
class Trigger:
-
    def __init__(self, rid, commit):
-
        self.rid = rid
-
        self.commit = commit
-

-
    def json(self):
-
        return json.dumps(
-
            {"request": "trigger", "repo": self.rid, "commit": self.commit}
-
        )
-

-

-
class MalformedTrigger:
-
    def json(self):
-
        return json.dumps({"request": "trigger"})
-

-

-
class NativeCI:
-
    def __init__(self, rad, config):
-
        self.rad_home = rad.rad_home
-
        self.config = config.path
-
        self.env = {
-
            "RAD_HOME": self.rad_home,
-
            "RADICLE_NATIVE_CI": self.config,
-
            "RADICLE_NATIVE_CI_LOG": "debug",
-
        }
-
        self.timeout = 1
-

-
    def without_config(self):
-
        del self.env["RADICLE_NATIVE_CI"]
-

-
    def run(self, request):
-
        p = run(
-
            [
-
                "cargo",
-
                "run",
-
                "-q",
-
            ],
-
            input=request.json(),
-
            env=dict(self.env),
-
            may_fail=True,
-
        )
-
        resps = [json.loads(line.strip()) for line in p.stdout.splitlines()]
-
        return p.returncode, resps, p.stderr
-

-
    def run_without_request(self):
-
        p = run(
-
            [
-
                "cargo",
-
                "run",
-
                "-q",
-
            ],
-
            stdin=subprocess.DEVNULL,
-
            env=dict(self.env),
-
            may_fail=True,
-
        )
-
        resps = [json.loads(line.strip()) for line in p.stdout.splitlines()]
-
        return p.returncode, resps, p.stderr
-

-

-
def debug(msg):
-
    logging.debug(msg)
-

-

-
def info(msg):
-
    logging.info(msg)
-

-

-
def die(msg, exc_info=None):
-
    logging.critical(msg, exc_info=exc_info)
-
    sys.exit(1)
-

-

-
def run(argv, env=None, cwd=None, stdin=None, input=None, may_fail=False):
-
    debug(f"run {argv} with env={env}, cwd={cwd} input={input!r}")
-
    if env is not None:
-
        for name, value in os.environ.items():
-
            if name not in env:
-
                env[name] = value
-
    p = subprocess.run(
-
        argv, capture_output=True, env=env, cwd=cwd, stdin=stdin, input=input, text=True
-
    )
-

-
    debug(f"stdout:\n{indent(p.stdout)}")
-
    debug(f"stderr:\n{indent(p.stderr)}")
-
    if p.returncode != 0 and not may_fail:
-
        die(f"command failed: {argv}")
-

-
    return p
-

-

-
def indent(s):
-
    return "".join([f"    {line}\n" for line in s.splitlines()])
-

-

-
def write(filename, data):
-
    dirname = os.path.dirname(filename)
-
    if not os.path.exists(dirname):
-
        os.makedirs(dirname)
-
    with open(filename, "w") as f:
-
        f.write(data)
-

-

-
def main(tmp, tests):
-
    suite = Suite(tmp)
-
    suite.run_all_test_cases(tests)
-

-

-
p = argparse.ArgumentParser()
-
p.add_argument("--verbose", action="store_true")
-
p.add_argument("--test", action="append", nargs="?", dest="tests")
-
args = p.parse_args()
-

-
if args.verbose:
-
    level = logging.DEBUG
-
    print(args)
-
else:
-
    level = logging.INFO
-

-

-
logging.basicConfig(
-
    level=level,
-
    stream=sys.stderr,
-
    format="%(levelname)s %(message)s",
-
    datefmt="%Y-%m-%d %H:%M:%S",
-
)
-

-
tmp = tempfile.mkdtemp()
-
debug(f"created temporary directory {tmp}")
-

-
try:
-
    main(tmp, args.tests)
-
except AssertionError as e:
-
    debug(f"removing temporary directory {tmp}")
-
    shutil.rmtree(tmp)
-
    die(f"ERROR: assertion failed: {e}", exc_info=True)
-
except Exception as e:
-
    debug(f"removing temporary directory {tmp}")
-
    shutil.rmtree(tmp)
-
    die(f"ERROR: uncaught exception {e}", exc_info=True)
-
else:
-
    debug(f"removing temporary directory {tmp}")
-
    shutil.rmtree(tmp)
added tests/integration.rs
@@ -0,0 +1,668 @@
+
//! 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::{git::Oid, identity::Did, prelude::RepoId};
+
use radicle_ci_broker::msg::{
+
    Author, EventCommonFields, EventType, Patch, PatchEvent, Repository, Request, Response,
+
    RunResult, State,
+
};
+
use tempfile::{tempdir, TempDir};
+

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

+
// Default timeout for CI runs. This is deliberately short to keep the
+
// time to run tests short.
+
const TIMEOUT: usize = 3;
+

+
// 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);
+
    }
+

+
    // Assert that the adapter sent a message with a run ID.
+
    fn assert_got_run_id(&self) {
+
        let msgs = self.messages().unwrap();
+
        assert!(!msgs.is_empty());
+
        assert!(matches!(msgs[0], Response::Triggered { .. }));
+
    }
+

+
    // 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");
+
        let adapter = Command::new("cargo")
+
            .args(["run", "-q"])
+
            .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.
+
    Valid(PathBuf),
+
}
+

+
// 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_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_yaml::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"),
+
                timeout: Some(TIMEOUT),
+
            };
+
            let config = serde_yaml::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::new();
+
        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) => {
+
                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)?;
+
        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 {
+
            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: "updated".into(),
+
            patch: Patch {
+
                id: Oid::try_from("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::try_from("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 the commands its running take too long?
+
#[test]
+
fn command_takes_too_long() -> TestResult {
+
    let result = TestCaseBuilder::new()?
+
        .with_config()
+
        .with_request_for_nonexistent_commit()
+
        .with_shell(&format!("sleep {}", TIMEOUT + 2))
+
        .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(())
+
}