From e63f33489055962845c70976bd9a40d0d84c6b1c Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Mon, 5 Dec 2022 17:46:54 +0100 Subject: [PATCH] cli: Replace `trycmd` with our own test runner This will allow us to run commands other than `rad` and to have tests that switch between different users. Signed-off-by: Alexis Sellier --- Cargo.lock | 175 ++-------------------- radicle-cli/Cargo.toml | 4 +- radicle-cli/examples/rad-auth.md | 3 - radicle-cli/examples/rad-init.md | 6 +- radicle-cli/src/lib.rs | 2 + radicle-cli/src/testing.rs | 245 +++++++++++++++++++++++++++++++ radicle-cli/src/tests/cli.rs | 64 +++++--- radicle/src/git.rs | 5 +- radicle/src/storage/git.rs | 15 +- radicle/src/test/fixtures.rs | 12 +- 10 files changed, 325 insertions(+), 206 deletions(-) create mode 100644 radicle-cli/src/testing.rs diff --git a/Cargo.lock b/Cargo.lock index 9f245dd4..6118c0fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,16 +371,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "combine" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "concolor" version = "0.0.11" @@ -452,30 +442,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" -dependencies = [ - "cfg-if", - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a" -dependencies = [ - "autocfg", - "cfg-if", - "crossbeam-utils", - "memoffset", - "scopeguard", -] - [[package]] name = "crossbeam-utils" version = "0.8.14" @@ -1114,12 +1080,6 @@ dependencies = [ "url", ] -[[package]] -name = "glob" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" - [[package]] name = "group" version = "0.12.1" @@ -1260,22 +1220,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - -[[package]] -name = "humantime-serde" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" -dependencies = [ - "humantime", - "serde", -] - [[package]] name = "hyper" version = "0.14.23" @@ -1444,15 +1388,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.4" @@ -1625,15 +1560,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.16" @@ -1868,16 +1794,6 @@ dependencies = [ "syn", ] -[[package]] -name = "os_pipe" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a252f1f8c11e84b3ab59d7a488e48e4478a93937e027076638c49536204639" -dependencies = [ - "libc", - "windows-sys", -] - [[package]] name = "output_vt100" version = "0.1.3" @@ -2196,16 +2112,18 @@ dependencies = [ "json-color", "lexopt", "log", + "pretty_assertions", "radicle", "radicle-cob", "radicle-crypto", "serde", "serde_json", "serde_yaml", + "shlex", + "snapbox", "tempfile", "thiserror", "timeago", - "trycmd", "zeroize", ] @@ -2472,29 +2390,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "rayon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e060280438193c554f654141c9ea9417886713b7acd75974c85b18a69a88e0b" -dependencies = [ - "crossbeam-deque", - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac410af5d00ab6884528b4ab69d1e8e146e8d471201800fa1b4524126de6ad3" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-utils", - "num_cpus", -] - [[package]] name = "redox_syscall" version = "0.2.16" @@ -2657,12 +2552,6 @@ dependencies = [ "syn", ] -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - [[package]] name = "scratch" version = "1.0.2" @@ -2821,6 +2710,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "similar" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf" + [[package]] name = "siphasher" version = "0.3.10" @@ -2865,12 +2760,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbd7b250c7243273b5aec4ca366fced84ad716d110bb7baae4814678952ebde" dependencies = [ "concolor", - "libc", "normalize-line-endings", - "os_pipe", + "similar", "snapbox-macros", - "wait-timeout", - "windows-sys", "yansi", ] @@ -3241,28 +3133,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808b51e57d0ef8f71115d8f3a01e7d3750d01c79cac4b3eda910f4389fdf92fd" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1541ba70885967e662f69d31ab3aeca7b1aaecfcd58679590b893e9239c3646" -dependencies = [ - "combine", - "indexmap", - "itertools", - "serde", - "toml_datetime", -] - [[package]] name = "tower" version = "0.4.13" @@ -3392,22 +3262,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" -[[package]] -name = "trycmd" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e381af441e13a3635303d26769620a9454aef05ec3303711efc3f1dd785a33af" -dependencies = [ - "glob", - "humantime", - "humantime-serde", - "rayon", - "serde", - "shlex", - "snapbox", - "toml_edit", -] - [[package]] name = "typenum" version = "1.15.0" @@ -3488,15 +3342,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" -[[package]] -name = "wait-timeout" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" -dependencies = [ - "libc", -] - [[package]] name = "want" version = "0.3.0" diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index de4314d4..d39dbebb 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -39,6 +39,8 @@ version = "0" path = "../radicle-crypto" [dev-dependencies] -trycmd = { version = "0.14.4", default-features = false, features = ["color-auto"] } +pretty_assertions = { version = "1.3.0" } tempfile = { version = "3.3.0" } radicle = { path = "../radicle", features = ["test"] } +shlex = { version = "1.1.0" } +snapbox = { version = "0.4.3" } diff --git a/radicle-cli/examples/rad-auth.md b/radicle-cli/examples/rad-auth.md index 19661b6c..9593563e 100644 --- a/radicle-cli/examples/rad-auth.md +++ b/radicle-cli/examples/rad-auth.md @@ -1,4 +1,3 @@ - Initializing a new identity with `rad-auth`. The example below is run with `RAD_PASSPHRASE` set. @@ -13,7 +12,6 @@ ok Profile z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi created. Your radicle Node ID is z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi. This identifies your device. => To create a radicle project, run `rad init` from a git repository. - ``` You can get the above information at all times using the `self` command: @@ -26,5 +24,4 @@ Key (full) AAAAC3NzaC1lZDI1NTE5AAAAIHahWSBEpuT1ESZbynOmBNkLBSnR32Ar4woZqSV2Y Storage (git) [..]/storage Storage (keys) [..]/keys Node (socket) [..]/node/radicle.sock - ``` diff --git a/radicle-cli/examples/rad-init.md b/radicle-cli/examples/rad-init.md index c7be1d38..ad9653a3 100644 --- a/radicle-cli/examples/rad-init.md +++ b/radicle-cli/examples/rad-init.md @@ -15,19 +15,17 @@ ok Project acme created } -Your project id is [..]. You can show it any time by running: +Your project id is rad:z4EXAgDJk11uFThfVir5FivKhiAeK. You can show it any time by running: rad . To publish your project to the network, run: rad push - ``` Projects can be listed with the `ls` command: ``` $ rad ls -acme [..] [..] Acme's repository - +acme rad:z4EXAgDJk11uFThfVir5FivKhiAeK cdf76ce Acme's repository ``` diff --git a/radicle-cli/src/lib.rs b/radicle-cli/src/lib.rs index d27d6565..d23f7b23 100644 --- a/radicle-cli/src/lib.rs +++ b/radicle-cli/src/lib.rs @@ -6,4 +6,6 @@ pub mod git; pub mod project; pub mod terminal; #[cfg(test)] +mod testing; +#[cfg(test)] mod tests; diff --git a/radicle-cli/src/testing.rs b/radicle-cli/src/testing.rs new file mode 100644 index 00000000..368cf015 --- /dev/null +++ b/radicle-cli/src/testing.rs @@ -0,0 +1,245 @@ +#![allow(clippy::collapsible_else_if)] +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::{fs, io, mem}; + +use snapbox::cmd::Command; +use snapbox::{Assert, Substitutions}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("parsing failed")] + Parse, + #[error("i/o: {0}")] + Io(#[from] io::Error), + #[error("snapbox: {0}")] + Snapbox(#[from] snapbox::Error), +} + +/// A test which may contain multiple assertions. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Test { + /// Human-readable context around the test. Functions as documentation. + context: Vec, + /// Test assertions to run. + assertions: Vec, +} + +/// An assertion is a command to run with an expected output. +#[derive(Debug, PartialEq, Eq)] +pub struct Assertion { + /// Name of program to run, eg. `git`. + program: String, + /// Program arguments, eg. `["push"]`. + args: Vec, + /// Expected output (stdout or stderr). + expected: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct TestFormula { + /// Current working directory to run the test in. + cwd: PathBuf, + /// Environment to pass to the test. + env: HashMap, + /// Tests to run. + tests: Vec, + /// Output substitutions. + subs: Substitutions, +} + +impl TestFormula { + pub fn new() -> Self { + Self { + cwd: PathBuf::new(), + env: HashMap::new(), + tests: Vec::new(), + subs: Substitutions::new(), + } + } + + pub fn cwd(&mut self, path: impl AsRef) -> &mut Self { + self.cwd = path.as_ref().into(); + self + } + + pub fn env(&mut self, key: impl Into, val: impl Into) -> &mut Self { + self.env.insert(key.into(), val.into()); + self + } + + pub fn file(&mut self, path: impl AsRef) -> Result<&mut Self, Error> { + let contents = fs::read(path)?; + self.read(io::Cursor::new(contents)) + } + + pub fn read(&mut self, r: impl io::BufRead) -> Result<&mut Self, Error> { + let mut test = Test::default(); + let mut fenced = false; // Whether we're inside a fenced code block. + + for line in r.lines() { + let line = line?; + + if line.starts_with("```") { + if fenced { + // End existing code block. + self.tests.push(mem::take(&mut test)); + } + fenced = !fenced; + + continue; + } + + if fenced { + if let Some(line) = line.strip_prefix('$') { + let line = line.trim(); + let parts = shlex::split(line).ok_or(Error::Parse)?; + let (program, args) = parts.split_first().ok_or(Error::Parse)?; + + test.assertions.push(Assertion { + program: program.to_owned(), + args: args.to_owned(), + expected: String::new(), + }); + } else if let Some(test) = test.assertions.last_mut() { + test.expected.push_str(line.as_str()); + test.expected.push('\n'); + } else { + return Err(Error::Parse); + } + } else { + test.context.push(line); + } + } + Ok(self) + } + + #[allow(dead_code)] + pub fn substitute( + &mut self, + value: &'static str, + other: impl Into>, + ) -> Result<&mut Self, Error> { + self.subs.insert(value, other)?; + Ok(self) + } + + pub fn run(&self) -> Result { + let assert = Assert::new().substitutions(self.subs.clone()); + + for test in &self.tests { + for assertion in &test.assertions { + let program = if assertion.program == "rad" { + snapbox::cmd::cargo_bin("rad") + } else { + PathBuf::from(&assertion.program) + }; + + Command::new(program) + .envs(self.env.clone()) + .args(&assertion.args) + .with_assert(assert.clone()) + .assert() + .stdout_matches(&assertion.expected) + .success(); + } + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn test_parse() { + let input = r#" +Let's try to track @dave and @sean: +``` +$ rad track @dave +Tracking relationship established for @dave. +Nothing to do. + +$ rad track @sean +Tracking relationship established for @sean. +Nothing to do. +``` +Super, now let's move on to the next step. +``` +$ rad sync +``` +"# + .trim() + .as_bytes() + .to_owned(); + + let mut actual = TestFormula::new(); + actual + .read(io::BufReader::new(io::Cursor::new(input))) + .unwrap(); + + let expected = TestFormula { + cwd: PathBuf::new(), + env: HashMap::new(), + subs: Substitutions::new(), + tests: vec![ + Test { + context: vec![String::from("Let's try to track @dave and @sean:")], + assertions: vec![ + Assertion { + program: String::from("rad"), + args: vec![String::from("track"), String::from("@dave")], + expected: String::from( + "Tracking relationship established for @dave.\nNothing to do.\n\n", + ), + }, + Assertion { + program: String::from("rad"), + args: vec![String::from("track"), String::from("@sean")], + expected: String::from( + "Tracking relationship established for @sean.\nNothing to do.\n", + ), + }, + ], + }, + Test { + context: vec![String::from("Super, now let's move on to the next step.")], + assertions: vec![Assertion { + program: String::from("rad"), + args: vec![String::from("sync")], + expected: String::new(), + }], + }, + ], + }; + + assert_eq!(actual, expected); + } + + #[test] + fn test_run() { + let input = r#" +Running a simple command such as `head`: +``` +$ head -n 2 Cargo.toml +[package] +name = "radicle-cli" +``` +"# + .trim() + .as_bytes() + .to_owned(); + + let mut formula = TestFormula::new(); + formula + .cwd(env!("CARGO_MANIFEST_DIR")) + .read(io::BufReader::new(io::Cursor::new(input))) + .unwrap(); + formula.run().unwrap(); + } +} diff --git a/radicle-cli/src/tests/cli.rs b/radicle-cli/src/tests/cli.rs index 4b938993..7b5f8a1e 100644 --- a/radicle-cli/src/tests/cli.rs +++ b/radicle-cli/src/tests/cli.rs @@ -1,41 +1,57 @@ +use std::env; use std::path::Path; -use std::{env, time}; use radicle::profile::Profile; use radicle::test::fixtures; +use crate::testing::TestFormula; + +/// Run a CLI test file. +fn test( + path: impl AsRef, + profile: Option, +) -> Result<(), Box> { + let base = Path::new(env!("CARGO_MANIFEST_DIR")); + let tmp = tempfile::tempdir().unwrap(); + let home = if let Some(profile) = profile { + profile.home.as_path().to_path_buf() + } else { + tmp.path().to_path_buf() + }; + + TestFormula::new() + .env("RAD_PASSPHRASE", "radicle") + .env("RAD_HOME", home.to_string_lossy()) + .env("RAD_DEBUG", "1") + .file(base.join(path))? + .run()?; + + Ok(()) +} + +/// Create a new user profile. +fn profile(home: &Path) -> Profile { + // Set debug mode, to make test output more predictable. + env::set_var("RAD_DEBUG", "1"); + // Setup a new user. + Profile::init(home, "radicle").unwrap() +} + #[test] fn rad_auth() { - let base = Path::new(env!("CARGO_MANIFEST_DIR")); - let home = tempfile::tempdir().unwrap(); - - trycmd::TestCases::new() - .env("RAD_DEBUG", "1") - .env("RAD_PASSPHRASE", "radicle") - .env("RAD_HOME", home.path().to_string_lossy()) - .timeout(time::Duration::from_secs(6)) - .case(base.join("examples/rad-auth.md")) - .run(); + test("examples/rad-auth.md", None).unwrap(); } #[test] fn rad_init() { - let base = Path::new(env!("CARGO_MANIFEST_DIR")); let home = tempfile::tempdir().unwrap(); - let cwd = tempfile::tempdir().unwrap(); + let working = tempfile::tempdir().unwrap(); + let profile = profile(home.path()); // Setup a test repository. - fixtures::repository(cwd.path()); + fixtures::repository(working.path()); // Navigate to repository. - env::set_current_dir(cwd.path()).unwrap(); - // Setup a new user. - Profile::init(home.path(), "radicle").unwrap(); + env::set_current_dir(working.path()).unwrap(); - trycmd::TestCases::new() - .env("RAD_DEBUG", "1") - .env("RAD_PASSPHRASE", "radicle") - .env("RAD_HOME", home.path().to_string_lossy()) - .timeout(time::Duration::from_secs(6)) - .case(base.join("examples/rad-init.md")) - .run(); + test("examples/rad-init.md", Some(profile)).unwrap(); } diff --git a/radicle/src/git.rs b/radicle/src/git.rs index 6d56989f..1c611820 100644 --- a/radicle/src/git.rs +++ b/radicle/src/git.rs @@ -254,12 +254,11 @@ pub fn commit<'a>( parent: &'a git2::Commit, target: &RefStr, message: &str, - user: &str, + sig: &git2::Signature, ) -> Result, git2::Error> { - let sig = git2::Signature::now(user, "anonymous@radicle.xyz")?; let tree_id = repo.index()?.write_tree()?; let tree = repo.find_tree(tree_id)?; - let oid = repo.commit(Some(target.as_str()), &sig, &sig, message, &tree, &[parent])?; + let oid = repo.commit(Some(target.as_str()), sig, sig, message, &tree, &[parent])?; let commit = repo.find_commit(oid)?; Ok(commit) diff --git a/radicle/src/storage/git.rs b/radicle/src/storage/git.rs index b69c245b..77a6d305 100644 --- a/radicle/src/storage/git.rs +++ b/radicle/src/storage/git.rs @@ -964,9 +964,16 @@ mod tests { let alice_proj_storage = alice.repository(proj_id).unwrap(); let alice_head = proj_repo.find_commit(alice_head).unwrap(); - let alice_head = git::commit(&proj_repo, &alice_head, &refname, "Making changes", "Alice") - .unwrap() - .id(); + let alice_sig = git2::Signature::now("Alice", "alice@radicle.xyz").unwrap(); + let alice_head = git::commit( + &proj_repo, + &alice_head, + &refname, + "Making changes", + &alice_sig, + ) + .unwrap() + .id(); git::push(&proj_repo, "rad", [(&refname, &refname)]).unwrap(); alice_proj_storage.sign_refs(&alice_signer).unwrap(); alice_proj_storage.set_head().unwrap(); @@ -1140,7 +1147,7 @@ mod tests { &head, &git::RefString::try_from(format!("refs/remotes/{alice}/heads/master")).unwrap(), "Second commit", - &alice.to_string(), + &sig, ) .unwrap(); diff --git a/radicle/src/test/fixtures.rs b/radicle/src/test/fixtures.rs index d3bec330..b2dda1f6 100644 --- a/radicle/src/test/fixtures.rs +++ b/radicle/src/test/fixtures.rs @@ -8,6 +8,9 @@ use crate::storage::git::transport; use crate::storage::git::Storage; use crate::storage::refs::SignedRefs; +/// The birth of the radicle project, January 1st, 2018. +const RADICLE_EPOCH: i64 = 1514817556; + /// Create a new storage with a project. pub fn storage, G: Signer>(path: P, signer: &G) -> Result { let path = path.as_ref(); @@ -52,14 +55,19 @@ pub fn project, G: Signer>( /// Creates a regular repository at the given path with a couple of commits. pub fn repository>(path: P) -> (git2::Repository, git2::Oid) { let repo = git2::Repository::init(path).unwrap(); - let sig = git2::Signature::now("anonymous", "anonymous@radicle.xyz").unwrap(); + let sig = git2::Signature::new( + "anonymous", + "anonymous@radicle.xyz", + &git2::Time::new(RADICLE_EPOCH, 0), + ) + .unwrap(); let head = git::initial_commit(&repo, &sig).unwrap(); let oid = git::commit( &repo, &head, git::refname!("refs/heads/master").as_refstr(), "Second commit", - "anonymous", + &sig, ) .unwrap() .id();