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 <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-12-05 17:46:54 +01:00
parent ca69fdc4fa
commit e63f334890
No known key found for this signature in database
10 changed files with 325 additions and 206 deletions

175
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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
```

View File

@ -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
```

View File

@ -6,4 +6,6 @@ pub mod git;
pub mod project;
pub mod terminal;
#[cfg(test)]
mod testing;
#[cfg(test)]
mod tests;

245
radicle-cli/src/testing.rs Normal file
View File

@ -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<String>,
/// Test assertions to run.
assertions: Vec<Assertion>,
}
/// 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<String>,
/// 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<String, String>,
/// Tests to run.
tests: Vec<Test>,
/// 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<Path>) -> &mut Self {
self.cwd = path.as_ref().into();
self
}
pub fn env(&mut self, key: impl Into<String>, val: impl Into<String>) -> &mut Self {
self.env.insert(key.into(), val.into());
self
}
pub fn file(&mut self, path: impl AsRef<Path>) -> 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<Cow<'static, str>>,
) -> Result<&mut Self, Error> {
self.subs.insert(value, other)?;
Ok(self)
}
pub fn run(&self) -> Result<bool, io::Error> {
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();
}
}

View File

@ -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<Path>,
profile: Option<Profile>,
) -> Result<(), Box<dyn std::error::Error>> {
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();
}

View File

@ -254,12 +254,11 @@ pub fn commit<'a>(
parent: &'a git2::Commit,
target: &RefStr,
message: &str,
user: &str,
sig: &git2::Signature,
) -> Result<git2::Commit<'a>, 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)

View File

@ -964,7 +964,14 @@ 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")
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();
@ -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();

View File

@ -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<P: AsRef<Path>, G: Signer>(path: P, signer: &G) -> Result<Storage, rad::InitError> {
let path = path.as_ref();
@ -52,14 +55,19 @@ pub fn project<P: AsRef<Path>, G: Signer>(
/// Creates a regular repository at the given path with a couple of commits.
pub fn repository<P: AsRef<Path>>(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();