//! 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, PatchAction, PatchEvent, Repository, Request, Response, RunId, RunResult, State, }; use tempfile::{tempdir, TempDir}; use radicle_native_ci::{config::Config, runspec::RunSpec}; // The result of running the adapter. #[allow(dead_code)] struct AdapterResult { exit: i32, stdout: String, stderr: String, } impl AdapterResult { // The messages that the adapter outputs, if parsing them is // possible. fn messages(&self) -> Result, 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); } // Return run id from adapter, if one was returned. fn run_id(&self) -> Option { let msgs = self.messages().unwrap(); assert!(!msgs.is_empty()); match &msgs[0] { Response::Triggered { run_id, .. } => Some(run_id.clone()), _ => None, } } // Assert that the adapter sent a message with a run ID. fn assert_got_run_id(&self) { assert!(self.run_id().is_some()); } // Assert that the adapter sent a message the expected info URL. fn assert_url_is(&self, expected: &str) { let msgs = self.messages().unwrap(); assert!(!msgs.is_empty()); match &msgs[0] { Response::Triggered { run_id: _, info_url: Some(url), } => { assert_eq!(url, expected); } _ => panic!("unexpected message {:#?}", msgs[0]), } } // Assert that the adapter wrote a message indicating the CI run // succeeded. fn assert_got_success(&self) { let msgs = self.messages().unwrap(); assert!(msgs.len() == 2); assert!(matches!( msgs[1], Response::Finished { result: RunResult::Success } )); } // Assert that the adapter wrote a message indicating the CI run // failed. fn assert_got_failure(&self) { let msgs = self.messages().unwrap(); assert!(msgs.len() == 2); assert!(matches!( msgs[1], Response::Finished { result: RunResult::Failure } )); } } // Format an [`AdapterResult`] in a more readable way. impl fmt::Debug for AdapterResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&format!( "AdapterResult:\n exit: {}\n stdout:\n{} stderr:\n{}", self.exit, indent(&self.stdout), indent(&self.stderr), )) } } // Indent every line in a string. fn indent(s: &str) -> String { let mut out = String::new(); for line in s.lines() { out.push_str(&format!(" {line:?}\n")); } out } // All the information needed to run the adapter for a specific test // case. Constructed by [`TestCaseBuilder::build`]. struct TestCase { tmp: TempDir, envs: HashMap, 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"); println!("self.envs start"); for (name, value) in self.envs.iter() { println!(" {name}={value:?}"); } println!("self.envs end"); let adapter = Command::new("cargo") .args(["run", "-q"]) .env_clear() .envs(self.envs.iter()) .stdin(File::open(&req_filename)?) .output()?; let result = AdapterResult { exit: adapter.status.code().unwrap(), stdout: String::from_utf8_lossy(&adapter.stdout).into(), stderr: String::from_utf8_lossy(&adapter.stderr).into(), }; println!("{result:#?}"); Ok(result) } } // What kind of config should the adapter be provided? enum ConfigKind { // No config: do not set RADICLE_NATIVE_CI in the environment. UnsetEnvVar, // No config: file does not exist. DoesNotExist(PathBuf), // No config: file is empty. EmptyConfig(PathBuf), // Valid config, without info base URL. Valid(PathBuf), // Valid, but with info base URL. ValidWithUrl(PathBuf, String), } // What kind of trigger message should we give the adapter? enum TriggerKind { // Broken: an empty input. Empty, // Broken: the repository referred to doesn't exist. MissingRepo, // Broken: the commit referred to doesn't exist in the repo. MissingCommit, // Valid Trigger message. Trigger, } // Build a new [`TestCase`]. // // Before `build` is called, a config and a request must be chosen. // The shell command to run is optional, and defaults to an empty // command. struct TestCaseBuilder { tmp: TempDir, config: Option, 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_config_with_url(mut self, url: &str) -> Self { self.config = Some(ConfigKind::ValidWithUrl( self.tmp.path().join("config.yaml"), url.into(), )); self } fn with_empty_request(mut self) -> Self { self.request = Some(TriggerKind::Empty); self } fn with_request_for_nonexistent_repo(mut self) -> Self { self.request = Some(TriggerKind::MissingRepo); self } fn with_request_for_nonexistent_commit(mut self) -> Self { self.request = Some(TriggerKind::MissingCommit); self } fn with_request(mut self) -> Self { self.request = Some(TriggerKind::Trigger); self } fn with_shell(mut self, shell: &str) -> Self { self.shell = shell.into(); self } fn build(self) -> Result { 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_yml::to_string(&runspec).unwrap(), )?; // Commit everything. git.commit("first commit")?; // Create a native CI adapter config. assert!(self.config.is_some()); let config = self.config.unwrap(); if let ConfigKind::Valid(filename) = &config { let config = Config { state: tmp.join("state"), log: tmp.join("log.html"), base_url: None, }; let config = serde_yml::to_string(&config).unwrap(); std::fs::write(filename, config)?; } else if let ConfigKind::ValidWithUrl(filename, url) = &config { let config = Config { state: tmp.join("state"), log: tmp.join("log.html"), base_url: Some(url.into()), }; let config = serde_yml::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 = std::env::vars() .filter(|(k, _)| k != "RADICLE_NATIVE_CI" && !k.starts_with("RAD_")) .collect(); assert!(!envs.contains_key("RADICLE_NATIVE_CI")); envs.insert("RAD_HOME".into(), format!("{}", rad.home().display())); envs.insert("RAD_PASSPHRASE".into(), Rad::PASSPHRASE.into()); // Set RADICLE_NATIVE_CI if it should be set. match config { ConfigKind::UnsetEnvVar => (), ConfigKind::DoesNotExist(filename) | ConfigKind::EmptyConfig(filename) | ConfigKind::Valid(filename) | ConfigKind::ValidWithUrl(filename, _) => { envs.insert("RADICLE_NATIVE_CI".into(), filename.display().to_string()); } } assert!(self.request.is_some()); Ok(TestCase { tmp: self.tmp, envs, git, rad, request: self.request.unwrap(), }) } } // Manage a git repository for test purposes. struct Git { path: PathBuf, } impl Git { fn new(path: &Path) -> Result { 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)?; // FIXME: this is a temporary workaround for rad on the system // using a newer heartwood than what radicle-native-ci is // using, and there being a config entry that has changed. // This hack should be removed once the native CI adapter is // using a newer heartwood. let filename = self.home.join("config.json"); let config = std::fs::read(&filename).unwrap(); let config = String::from_utf8_lossy(&config); let config = config.replace(r#""relay": "auto","#, r#""relay": false,"#); std::fs::write(&filename, config.as_bytes()).unwrap(); Ok(()) } fn init(&self, git_dir: &Path) -> Result<(), std::io::Error> { let name = format!("{}", Path::new(git_dir.file_stem().unwrap()).display()); self._rad( &[ "init", "--name", &name, "--description=test repo", "--public", "--no-confirm", ], git_dir, )?; Ok(()) } fn rid(&self, git_dir: &Path) -> Result { let rid = self._rad(&["."], git_dir)?; let rid = RepoId::from_str(rid.trim()).unwrap(); Ok(rid) } } // Construct a trigger message for test purposes. fn trigger(repo_id: RepoId, commit: Oid) -> Request { Request::Trigger { common: EventCommonFields { version: 1, event_type: EventType::Push, repository: Repository { id: repo_id, name: "test-repo".into(), description: "test repo".into(), private: false, default_branch: "main".into(), delegates: vec![], }, }, push: None, patch: Some(PatchEvent { action: PatchAction::Updated, patch: Patch { id: Oid::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 everything goes well? #[test] fn happy_path() -> TestResult { let result = TestCaseBuilder::new()? .with_config() .with_request() .with_shell("echo hello, world") .build()? .run()?; result.assert_got_exit(0); result.assert_got_run_id(); result.assert_got_success(); Ok(()) } // Does the adapter construct a URL to the build log? #[test] fn happy_path_with_log_url() -> TestResult { let result = TestCaseBuilder::new()? .with_config_with_url("https://ci.radicle.liw.fi") // note lack of trailing slash .with_request() .with_shell("echo hello, world") .build()? .run()?; result.assert_got_exit(0); result.assert_got_run_id(); result.assert_got_success(); let run_id = result.run_id().unwrap(); let expected = format!("https://ci.radicle.liw.fi/{}/log.html", run_id); result.assert_url_is(&expected); Ok(()) }