diff --git a/test-suite b/test-suite deleted file mode 100755 index aca851d..0000000 --- a/test-suite +++ /dev/null @@ -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) diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..4cd6bf8 --- /dev/null +++ b/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, serde_json::Error> { + self.stdout + .lines() + .map(serde_json::from_str::) + .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, + git: Git, + rad: Rad, + request: TriggerKind, +} + +impl TestCase { + fn run(&self) -> Result { + 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, + request: Option, + shell: String, +} + +impl TestCaseBuilder { + fn new() -> Result { + // 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 { + 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 { + 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 { + 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 { + 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 { + std::fs::create_dir(home)?; + Ok(Self { home: home.into() }) + } + + fn home(&self) -> &Path { + &self.home + } + + fn _rad(&self, args: &[&str], cwd: &Path) -> Result { + 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 { + 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(()) +}