soroban_test/
lib.rs

1//! **Soroban Test** - Test framework for invoking Soroban externally.
2//!
3//! Currently soroban provides a mock test environment for writing unit tets.
4//!
5//! However, it does not provide a way to run tests against a local sandbox or rpc endpoint.
6//!
7//! ## Overview
8//!
9//! - `TestEnv` is a test environment for running tests isolated from each other.
10//! - `TestEnv::with_default` invokes a closure, which is passed a reference to a random `TestEnv`.
11//! - `TestEnv::new_assert_cmd` creates an `assert_cmd::Command` for a given subcommand and sets the current
12//!    directory to be the same as `TestEnv`.
13//! - `TestEnv::cmd` is a generic function which parses a command from a string.
14//!    Note, however, that it uses `shlex` to tokenize the string. This can cause issues
15//!    for commands which contain strings with `"`s. For example, `{"hello": "world"}` becomes
16//!    `{hello:world}`. For that reason it's recommended to use `TestEnv::cmd_arr` instead.
17//! - `TestEnv::cmd_arr` is a generic function which takes an array of `&str` which is passed directly to clap.
18//!    This is the preferred way since it ensures no string parsing footguns.
19//! - `TestEnv::invoke` a convenience function for using the invoke command.
20//!
21#![allow(
22    clippy::missing_errors_doc,
23    clippy::must_use_candidate,
24    clippy::missing_panics_doc
25)]
26use std::{ffi::OsString, fmt::Display, path::Path};
27
28use assert_cmd::{assert::Assert, Command};
29use assert_fs::{fixture::FixtureError, prelude::PathChild, TempDir};
30use fs_extra::dir::CopyOptions;
31
32use soroban_cli::{
33    commands::{contract::invoke, global, keys, NetworkRunnable},
34    config::{self, network},
35    CommandParser,
36};
37
38mod wasm;
39
40pub use wasm::Wasm;
41
42pub const TEST_ACCOUNT: &str = "test";
43
44pub const LOCAL_NETWORK_PASSPHRASE: &str = "Standalone Network ; February 2017";
45
46#[derive(thiserror::Error, Debug)]
47pub enum Error {
48    #[error(transparent)]
49    TempDir(#[from] FixtureError),
50
51    #[error(transparent)]
52    FsError(#[from] fs_extra::error::Error),
53
54    #[error(transparent)]
55    Invoke(#[from] invoke::Error),
56}
57
58/// A `TestEnv` is a contained process for a specific test, with its own ENV and
59/// its own `TempDir` where it will save test-specific configuration.
60pub struct TestEnv {
61    pub temp_dir: TempDir,
62    pub network: network::Network,
63}
64
65impl Default for TestEnv {
66    fn default() -> Self {
67        let temp_dir = TempDir::new().unwrap();
68        Self {
69            temp_dir,
70            network: network::Network {
71                rpc_url: "http://localhost:8889/soroban/rpc".to_string(),
72                network_passphrase: LOCAL_NETWORK_PASSPHRASE.to_string(),
73                rpc_headers: [].to_vec(),
74            },
75        }
76    }
77}
78
79impl TestEnv {
80    /// Execute a closure which is passed a reference to the `TestEnv`.
81    /// `TempDir` implements the `Drop` trait ensuring that the temporary directory
82    /// it creates is deleted when the `TestEnv` is dropped. This pattern ensures
83    /// that the `TestEnv` cannot be dropped by the closure. For this reason, it's
84    /// recommended to use `TempDir::with_default` instead of `new` or `default`.
85    ///
86    /// ```rust,no_run
87    /// use soroban_test::TestEnv;
88    /// TestEnv::with_default(|env| {
89    ///     env.new_assert_cmd("contract").args(&["invoke", "--id", "1", "--", "hello", "--world=world"]).assert().success();
90    /// });
91    /// ```
92    ///
93    pub fn with_default<F: FnOnce(&TestEnv)>(f: F) {
94        let test_env = TestEnv::default();
95        f(&test_env);
96    }
97
98    pub fn with_default_network<F: FnOnce(&TestEnv)>(f: F) {
99        let test_env = TestEnv::new();
100        f(&test_env);
101    }
102
103    pub fn with_port(host_port: u16) -> TestEnv {
104        Self::with_rpc_url(&format!("http://localhost:{host_port}/soroban/rpc"))
105    }
106
107    pub fn with_rpc_url(rpc_url: &str) -> TestEnv {
108        let mut env = TestEnv::default();
109        env.network.rpc_url = rpc_url.to_string();
110        if let Ok(network_passphrase) = std::env::var("STELLAR_NETWORK_PASSPHRASE") {
111            env.network.network_passphrase = network_passphrase;
112        };
113        env.generate_account("test", None).assert().success();
114        env
115    }
116
117    pub fn with_rpc_provider(rpc_url: &str, rpc_headers: Vec<(String, String)>) -> TestEnv {
118        let mut env = TestEnv::default();
119        env.network.rpc_url = rpc_url.to_string();
120        env.network.rpc_headers = rpc_headers;
121        if let Ok(network_passphrase) = std::env::var("STELLAR_NETWORK_PASSPHRASE") {
122            env.network.network_passphrase = network_passphrase;
123        };
124        env.generate_account("test", None).assert().success();
125        env
126    }
127
128    pub fn new() -> TestEnv {
129        if let Ok(rpc_url) = std::env::var("SOROBAN_RPC_URL") {
130            return Self::with_rpc_url(&rpc_url);
131        }
132        if let Ok(rpc_url) = std::env::var("STELLAR_RPC_URL") {
133            return Self::with_rpc_url(&rpc_url);
134        }
135        let host_port = std::env::var("SOROBAN_PORT")
136            .as_deref()
137            .ok()
138            .and_then(|n| n.parse().ok())
139            .unwrap_or(8000);
140        Self::with_port(host_port)
141    }
142    /// Create a new `assert_cmd::Command` for a given subcommand and set's the current directory
143    /// to be the internal `temp_dir`.
144    pub fn new_assert_cmd(&self, subcommand: &str) -> Command {
145        let mut cmd: Command = self.bin();
146
147        cmd.arg(subcommand)
148            .env("SOROBAN_ACCOUNT", TEST_ACCOUNT)
149            .env("SOROBAN_RPC_URL", &self.network.rpc_url)
150            .env("SOROBAN_NETWORK_PASSPHRASE", LOCAL_NETWORK_PASSPHRASE)
151            .env("XDG_CONFIG_HOME", self.temp_dir.join("config").as_os_str())
152            .env("XDG_DATA_HOME", self.temp_dir.join("data").as_os_str())
153            .current_dir(&self.temp_dir);
154
155        if !self.network.rpc_headers.is_empty() {
156            cmd.env(
157                "STELLAR_RPC_HEADERS",
158                format!(
159                    "{}:{}",
160                    &self.network.rpc_headers[0].0, &self.network.rpc_headers[0].1
161                ),
162            );
163        }
164
165        cmd
166    }
167
168    pub fn bin(&self) -> Command {
169        Command::cargo_bin("soroban").unwrap_or_else(|_| Command::new("soroban"))
170    }
171
172    pub fn generate_account(&self, account: &str, seed: Option<String>) -> Command {
173        let mut cmd = self.new_assert_cmd("keys");
174        cmd.arg("generate").arg(account);
175        if let Some(seed) = seed {
176            cmd.arg(format!("--seed={seed}"));
177        }
178        cmd
179    }
180
181    pub fn fund_account(&self, account: &str) -> Assert {
182        self.new_assert_cmd("keys")
183            .arg("fund")
184            .arg(account)
185            .assert()
186    }
187
188    /// Parses a `&str` into a command and sets the pwd to be the same as the current `TestEnv`.
189    /// Uses shlex under the hood and thus has issues parsing strings with embedded `"`s.
190    /// Thus `TestEnv::cmd_arr` is recommended to instead.
191    pub fn cmd<T: CommandParser<T>>(&self, args: &str) -> T {
192        Self::cmd_with_pwd(args, self.dir())
193    }
194
195    /// Same as `TestEnv::cmd` but sets the pwd can be used instead of the current `TestEnv`.
196    pub fn cmd_with_pwd<T: CommandParser<T>>(args: &str, pwd: &Path) -> T {
197        let args = format!("--config-dir={pwd:?} {args}");
198        T::parse(&args).unwrap()
199    }
200
201    /// Same as `TestEnv::cmd_arr` but sets the pwd can be used instead of the current `TestEnv`.
202    pub fn cmd_arr_with_pwd<T: CommandParser<T>>(args: &[&str], pwd: &Path) -> T {
203        let mut cmds = vec!["--config-dir", pwd.to_str().unwrap()];
204        cmds.extend_from_slice(args);
205        T::parse_arg_vec(&cmds).unwrap()
206    }
207
208    /// Parse a command using an array of `&str`s, which passes the strings directly to clap
209    /// avoiding some issues `cmd` has with shlex. Use the current `TestEnv` pwd.
210    pub fn cmd_arr<T: CommandParser<T>>(&self, args: &[&str]) -> T {
211        Self::cmd_arr_with_pwd(args, self.dir())
212    }
213
214    /// A convenience method for using the invoke command.
215    pub async fn invoke_with_test<I: AsRef<str>>(
216        &self,
217        command_str: &[I],
218    ) -> Result<String, invoke::Error> {
219        self.invoke_with(command_str, "test").await
220    }
221
222    /// A convenience method for using the invoke command.
223    pub async fn invoke_with<I: AsRef<str>>(
224        &self,
225        command_str: &[I],
226        source: &str,
227    ) -> Result<String, invoke::Error> {
228        let cmd = self.cmd_with_config::<I, invoke::Cmd>(command_str, None);
229        self.run_cmd_with(cmd, source)
230            .await
231            .map(|tx| tx.into_result().unwrap())
232    }
233
234    /// A convenience method for using the invoke command.
235    pub fn cmd_with_config<I: AsRef<str>, T: CommandParser<T> + NetworkRunnable>(
236        &self,
237        command_str: &[I],
238        source_account: Option<&str>,
239    ) -> T {
240        let source = source_account.unwrap_or("test");
241        let source_str = format!("--source-account={source}");
242        let mut arg = vec![
243            "--network=local",
244            "--rpc-url=http",
245            "--network-passphrase=AA",
246            source_str.as_str(),
247        ];
248        let input = command_str
249            .iter()
250            .map(AsRef::as_ref)
251            .filter(|s| !s.is_empty())
252            .collect::<Vec<_>>();
253        arg.extend(input);
254        T::parse_arg_vec(&arg).unwrap()
255    }
256
257    pub fn clone_config(&self, account: &str) -> config::Args {
258        let config_dir = Some(self.dir().to_path_buf());
259        config::Args {
260            network: network::Args {
261                rpc_url: Some(self.network.rpc_url.clone()),
262                rpc_headers: [].to_vec(),
263                network_passphrase: Some(LOCAL_NETWORK_PASSPHRASE.to_string()),
264                network: None,
265            },
266            source_account: account.parse().unwrap(),
267            locator: config::locator::Args {
268                global: false,
269                config_dir,
270            },
271            hd_path: None,
272        }
273    }
274
275    /// Invoke an already parsed invoke command
276    pub async fn run_cmd_with<T: NetworkRunnable>(
277        &self,
278        cmd: T,
279        account: &str,
280    ) -> Result<T::Result, T::Error> {
281        let config = self.clone_config(account);
282        cmd.run_against_rpc_server(
283            Some(&global::Args {
284                locator: config.locator.clone(),
285                filter_logs: Vec::default(),
286                quiet: false,
287                verbose: false,
288                very_verbose: false,
289                list: false,
290                no_cache: false,
291            }),
292            Some(&config),
293        )
294        .await
295    }
296
297    /// Reference to current directory of the `TestEnv`.
298    pub fn dir(&self) -> &TempDir {
299        &self.temp_dir
300    }
301
302    /// Returns the public key corresponding to the test keys's `hd_path`
303    pub async fn test_address(&self, hd_path: usize) -> String {
304        self.cmd::<keys::public_key::Cmd>(&format!("--hd-path={hd_path}"))
305            .public_key()
306            .await
307            .unwrap()
308            .to_string()
309    }
310
311    /// Returns the private key corresponding to the test keys's `hd_path`
312    pub fn test_show(&self, hd_path: usize) -> String {
313        self.cmd::<keys::secret::Cmd>(&format!("--hd-path={hd_path}"))
314            .private_key()
315            .unwrap()
316            .to_string()
317    }
318
319    /// Copy the contents of the current `TestEnv` to another `TestEnv`
320    pub fn fork(&self) -> Result<TestEnv, Error> {
321        let this = TestEnv::new();
322        self.save(&this.temp_dir)?;
323        Ok(this)
324    }
325
326    /// Save the current state of the `TestEnv` to the given directory.
327    pub fn save(&self, dst: &Path) -> Result<(), Error> {
328        fs_extra::dir::copy(&self.temp_dir, dst, &CopyOptions::new())?;
329        Ok(())
330    }
331
332    pub fn client(&self) -> soroban_rpc::Client {
333        self.network.rpc_client().unwrap()
334    }
335
336    #[cfg(feature = "emulator-tests")]
337    pub async fn speculos_container(
338        ledger_device_model: &str,
339    ) -> testcontainers::ContainerAsync<stellar_ledger::emulator_test_support::speculos::Speculos>
340    {
341        use stellar_ledger::emulator_test_support::{
342            enable_hash_signing, get_container, wait_for_emulator_start_text,
343        };
344        let container = get_container(ledger_device_model).await;
345        let ui_host_port: u16 = container.get_host_port_ipv4(5000).await.unwrap();
346        wait_for_emulator_start_text(ui_host_port).await;
347        enable_hash_signing(ui_host_port).await;
348        container
349    }
350}
351
352pub fn temp_ledger_file() -> OsString {
353    TempDir::new()
354        .unwrap()
355        .child("ledger.json")
356        .as_os_str()
357        .into()
358}
359
360pub trait AssertExt {
361    fn stdout_as_str(&self) -> String;
362    fn stderr_as_str(&self) -> String;
363}
364
365impl AssertExt for Assert {
366    fn stdout_as_str(&self) -> String {
367        String::from_utf8(self.get_output().stdout.clone())
368            .expect("failed to make str")
369            .trim()
370            .to_owned()
371    }
372    fn stderr_as_str(&self) -> String {
373        String::from_utf8(self.get_output().stderr.clone())
374            .expect("failed to make str")
375            .trim()
376            .to_owned()
377    }
378}
379pub trait CommandExt {
380    fn json_arg<A>(&mut self, j: A) -> &mut Self
381    where
382        A: Display;
383}
384
385impl CommandExt for Command {
386    fn json_arg<A>(&mut self, j: A) -> &mut Self
387    where
388        A: Display,
389    {
390        self.arg(OsString::from(j.to_string()))
391    }
392}