mirror of
https://seed.radicle.dev/rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE.git
synced 2026-10-18 19:10:37 +08:00
tests: replace Python test-suite script with Rust integration test
This commit is contained in:
-452
@@ -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)
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user