Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: soroban-test crate #437

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
807 changes: 796 additions & 11 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"cmd/soroban-cli",
"cmd/crates/*",
"cmd/soroban-cli/tests/fixtures/test-wasms/*",
"cmd/soroban-rpc/lib/preflight",
]
Expand Down
33 changes: 33 additions & 0 deletions cmd/crates/soroban-test/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
name = "soroban-test"
description = "Soroban Test Framework"
homepage = "https://github.com/stellar/soroban-test"
repository = "https://github.com/stellar/soroban-test"
authors = ["Stellar Development Foundation <[email protected]>"]
license = "Apache-2.0"
readme = "README.md"
version = "0.6.0"
edition = "2021"
rust-version = "1.67"
autobins = false


[lib]
crate-type = ["rlib", "cdylib"]


[dependencies]
soroban-env-host = { workspace = true, features = ["vm", "serde", "hostfn_log_fmt_values"] }
soroban-spec = { workspace = true }
soroban-token-spec = { workspace = true }
soroban-ledger-snapshot = { workspace = true }
stellar-strkey = { workspace = true }
soroban-sdk = { workspace = true }
thiserror = "1.0.31"
sep5 = { workspace = true}
sha2 = "0.10.6"
cargo = "0.68.0"
assert_cmd = "2.0.4"
assert_fs = "1.0.7"
predicates = "2.1.5"
fs_extra = "1.3.0"
222 changes: 222 additions & 0 deletions cmd/crates/soroban-test/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
use std::{ffi::OsString, fmt::Display, path::Path};

use assert_cmd::{assert::Assert, Command};
use assert_fs::{fixture::FixtureError, prelude::PathChild, TempDir};
use fs_extra::dir::CopyOptions;
pub use wasm::Wasm;

mod wasm;

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Failed to create temporary directory")]
TempDir(FixtureError),

#[error(transparent)]
FsError(#[from] fs_extra::error::Error),
}

/// The primary interface to your tests. Creates an isolated process with its own temporary directory, storing all config and output there.
///
/// # Example:
///
/// use soroban_test::{TestEnv, Wasm};
/// const WASM: &Wasm = &Wasm::Release("my_contract");
///
/// #[test]
/// fn invoke_and_read() {
/// TestEnv::with_default(|e| {
/// e.new_cmd("contract")
/// .arg("invoke")
/// .arg("--wasm")
/// .arg(&WASM.path())
/// .args(["--fn", "some_fn"])
/// .assert()
/// .stderr("");
/// });
/// }
pub struct TestEnv {
pub temp_dir: TempDir,
}

impl Default for TestEnv {
fn default() -> Self {
Self::new().unwrap()
}
}

impl TestEnv {
/// Initialize a TestEnv with default settings. Takes a closure to execute within this TestEnv.
///
/// For now, this is the primary interface to create and use a TestEnv. In the future, TestEnv may provide an alternate, more customizable method of initialization.
///
/// # Example
///
/// In a test function:
///
/// TestEnv::with_default(|e| {
/// println!("{:#?}", e.new_cmd("version").ok());
/// });
pub fn with_default<F: FnOnce(&TestEnv)>(f: F) {
let test_env = TestEnv::default();
f(&test_env)
}

/// Initialize a TestEnv with default settings and return a Result with this TestEnv or an error. You probably want `with_default` instead, which makes use of `new` internally.
pub fn new() -> Result<TestEnv, Error> {
TempDir::new()
.map_err(Error::TempDir)
.map(|temp_dir| TestEnv { temp_dir })
}

/// Start building a new `soroban` command, skipping the repetitive `soroban` starting word and setting the current directory to the one for this TestEnv.
///
/// # Example
///
/// TestEnv::with_default(|e| {
/// println!("{:#?}", e.new_cmd("version").ok());
/// });
///
/// Note that you don't need `e.new_cmd("soroban").arg("version")`.
pub fn new_cmd(&self, name: &str) -> Command {
let mut this = Command::cargo_bin("soroban").unwrap_or_else(|_| Command::new("soroban"));
this.arg(name);
this.current_dir(&self.temp_dir);
this
}

/// Get the location of the temporary directory created by this TestEnv.
pub fn dir(&self) -> &TempDir {
&self.temp_dir
}

/// Generate new identity for testing. Names the identity `test`. Uses a hard-coded all-zero seed.
pub fn gen_test_identity(&self) {
self.new_cmd("config")
.arg("identity")
.arg("generate")
.arg("--seed")
.arg("0000000000000000")
.arg("test")
.assert()
.success();
}

/// Return a public key of the identity named `test`, which needs to first be created with `gen_test_identity`. The `test` identity is stored as a seed, which can be used to generate multiple public keys. The specific key generated depends on the `hd_path` supplied. Specifying the same `hd_path` will always generate the same key.
///
/// The phrase "HD path" comes from the larger world of crypto wallets: https://www.ledger.com/academy/crypto/what-are-hierarchical-deterministic-hd-wallets
pub fn test_address(&self, hd_path: usize) -> String {
self.new_cmd("config")
.args("identity address test --hd-path".split(' '))
.arg(format!("{hd_path}"))
.assert()
.stdout_as_str()
}

/// Fork TestEnv, return a Result with the new TestEnv or an error. Might be useful to create multiple tests that use the same setup.
pub fn fork(&self) -> Result<TestEnv, Error> {
let this = TestEnv::new()?;
self.save(&this.temp_dir)?;
Ok(this)
}

/// Save the current state of the TestEnv to the given directory.
pub fn save(&self, dst: &Path) -> Result<(), Error> {
fs_extra::dir::copy(&self.temp_dir, dst, &CopyOptions::new())?;
Ok(())
}
}

pub fn temp_ledger_file() -> OsString {
TempDir::new()
.unwrap()
.child("ledger.json")
.as_os_str()
.into()
}

/// Import this trait into your file to extend `assert_cmd` with extra utility functions.
///
/// `assert_cmd` is the CLI command builder powering soroban_test.
///
/// You don't need to do anything else with `AssertExt` other than import it; it magically extends the command builder with `stdout_as_str` and other helpers.
///
/// # Example:
///
/// use soroban_test::{AssertExt, TestEnv, Wasm};
///
/// const WASM: &Wasm = &Wasm::Release("my_contract");
///
/// #[test]
/// fn invoke() {
/// TestEnv::with_default(|e| {
/// let stdout = e
/// .new_cmd("contract")
/// .arg("install")
/// .arg("--wasm")
/// .arg(&WASM.path())
/// .assert()
/// .stdout_as_str();
/// println!("{stdout}");
/// }
/// }
///
/// Note that you need the `.assert()`, which is where `assert_cmd` executes the command and returns a struct to make assertions on.
pub trait AssertExt {
/// If the command emits to STDOUT, this will return its output as a `&str`, with leading and trailing whitespace trimmed.
fn stdout_as_str(&self) -> String;

/// If the command emits to STDERR, this will return its output as a `&str`, with leading and trailing whitespace trimmed.
fn stderr_as_str(&self) -> String;
}

impl AssertExt for Assert {
fn stdout_as_str(&self) -> String {
String::from_utf8(self.get_output().stdout.clone())
.expect("failed to make str")
.trim()
.to_owned()
}
fn stderr_as_str(&self) -> String {
String::from_utf8(self.get_output().stderr.clone())
.expect("failed to make str")
.trim()
.to_owned()
}
}

/// Import this trait into your file to extend `assert_cmd` with extra utility functions.
///
/// `assert_cmd` is the CLI command builder powering soroban_test.
///
/// You don't need to do anything else with `CommandExt` other than import it; it magically extends the command builder with `json_arg` and other helpers.
///
/// # Example:
///
/// use serde_json::json;
/// use soroban_test::{AssertExt, TestEnv};
///
/// #[test]
/// fn invoke() {
/// TestEnv::with_default(|e| {
/// e.new_cmd("contract")
/// .arg("invoke")
/// .args(["--id", "0"])
/// .json_arg(json!({"ease": true, "pain": false}));
/// }
/// }
pub trait CommandExt {
/// Pass json (constructed with the serde_json::json macro or similar) as a segment to your command.
fn json_arg<A>(&mut self, j: A) -> &mut Self
where
A: Display;
}

impl CommandExt for Command {
fn json_arg<A>(&mut self, j: A) -> &mut Self
where
A: Display,
{
self.arg(OsString::from(j.to_string()))
}
}
80 changes: 80 additions & 0 deletions cmd/crates/soroban-test/src/wasm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use std::{fs, path::PathBuf};

use sha2::{Digest, Sha256};
use soroban_env_host::xdr::{self, InstallContractCodeArgs, WriteXdr};

#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Xdr(#[from] xdr::Error),
}

/// Easily include a built Wasm artifact from your project's `target` folder
///
/// # Example
///
/// If your workspace includes a member with name "my-contract" (that is, with `name = "my-contract"` in its Cargo.toml), or if this is the name of your root project, then in your test you can use:
///
/// use soroban_test::{TestEnv, Wasm};
/// const WASM: &Wasm = &Wasm::Release("my_contract");
///
/// #[test]
/// fn invoke_and_read() {
/// TestEnv::with_default(|e| {
/// let output = e.new_cmd("contract")
/// .arg("invoke")
/// .arg("--wasm")
/// .arg(&WASM.path())
/// .ok();
/// })
/// }
pub enum Wasm<'a> {
/// Takes a filename. Look inside `target/wasm32-unknown-unknown/release` for a Wasm file with the given name.
Release(&'a str),

/// Takes a `profile` and a filename. Look inside `target/wasm32-unknown-unknown/{profile}` for a given Wasm file with the given name.
///
/// # Example
///
/// const WASM: &Wasm = &Wasm::Custom("debug", "my_contract");
Custom(&'a str, &'a str),
}

fn find_target_dir() -> Option<PathBuf> {
let path = std::env::current_dir().unwrap();
for parent in path.ancestors().skip(1) {
let path = parent.join("target");
if path.is_dir() {
return Some(path);
}
}
None
}

impl Wasm<'_> {
/// The file path to the specified Wasm file
pub fn path(&self) -> PathBuf {
let path = find_target_dir().unwrap().join("wasm32-unknown-unknown");
let mut path = match self {
Wasm::Release(name) => path.join("release").join(name),
Wasm::Custom(profile, name) => path.join(profile).join(name),
};
path.set_extension("wasm");
assert!(path.is_file(), "File not found: {}. run 'make build-test-wasms' to generate .wasm files before running this test", path.display());
std::env::current_dir().unwrap().join(path)
}

/// The bytes of the specified Wasm file
pub fn bytes(&self) -> Vec<u8> {
fs::read(self.path()).unwrap()
}

/// The derived hash of the specified Wasm file which will be used to refer to a contract "installed" in the Soroban blockchain (that is, to contract bytes that have been uploaded, and which zero or more contract instances may refer to for their behavior).
pub fn hash(&self) -> Result<xdr::Hash, Error> {
let args_xdr = InstallContractCodeArgs {
code: self.bytes().try_into()?,
}
.to_xdr()?;
Ok(xdr::Hash(Sha256::digest(args_xdr).into()))
}
}
1 change: 1 addition & 0 deletions cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ crate-git-revision = "0.0.4"
assert_cmd = "2.0.4"
assert_fs = "1.0.7"
predicates = "2.1.5"
soroban-test = { path = "../crates/soroban-test"}
Loading