diff --git a/Cargo.toml b/Cargo.toml index ab7d803..c3d6d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "plugin", "plugin_macros", "conf", + "testing", ] resolver = "2" diff --git a/testing/Cargo.toml b/testing/Cargo.toml new file mode 100644 index 0000000..42be093 --- /dev/null +++ b/testing/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "testing" +version = "0.1.0" +edition = "2021" + +[dependencies] +clightningrpc = "0.3.0-beta.6" +bitcoincore-rpc = "0.17.0" +log = "0.4.19" +tempfile = "3.6.0" +port-selector = "0.1.6" +anyhow = "1.0.71" +tokio = { version = "1.22.0", features = ["process", "time", "fs"] } diff --git a/testing/src/btc.rs b/testing/src/btc.rs new file mode 100644 index 0000000..ed194d3 --- /dev/null +++ b/testing/src/btc.rs @@ -0,0 +1,108 @@ +//! Bitcoin Testing framework. +use bitcoincore_rpc::{Auth, Client, RpcApi}; +use port::Port; +use port_selector as port; +use tempfile::TempDir; + +pub mod macros { + #[macro_export] + macro_rules! bitcoind { + ($dir:expr, $port:expr, $opt_args:expr) => { + async { + use std::process::Stdio; + + use log; + use tokio::process::Command; + + let opt_args = format!($opt_args); + let args = opt_args.trim(); + let args_tok: Vec<&str> = args.split(" ").collect(); + log::debug!("additional args: {:?}", args_tok); + let mut command = Command::new("bitcoind"); + command + .args(&args_tok) + .arg(format!("-port={}", $port + 1)) + .arg(format!("-rpcport={}", $port)) + .arg(format!("-datadir={}", $dir.path().to_str().unwrap())) + .stdout(Stdio::null()) + .spawn() + } + .await + }; + ($dir:expr, $port:expr) => { + $crate::bitcoind!($dir, $port, "") + }; + } + + pub use bitcoind; +} + +pub struct BtcNode { + inner: Client, + pub user: String, + pub pass: String, + pub port: Port, + root_path: TempDir, + process: Vec, +} + +impl Drop for BtcNode { + fn drop(&mut self) { + for process in self.process.iter() { + let Some(child) = process.id() else { + continue; + }; + let Ok(mut kill) = std::process::Command::new("kill") + .args(["-s", "SIGKILL", &child.to_string()]) + .spawn() else { + continue; + }; + let _ = kill.wait(); + } + + let result = std::fs::remove_dir_all(self.root_path.path()); + log::debug!(target: "btc", "clean up function {:?}", result); + } +} + +impl BtcNode { + pub async fn tmp() -> anyhow::Result { + let dir = tempfile::tempdir()?; + let user = "crab".to_owned(); + let pass = "crab".to_owned(); + let port = port::random_free_port().unwrap(); + let process = macros::bitcoind!( + dir, + port, + "-server -regtest -rpcuser={user} -rpcpassword={pass}" + )?; + let rpc = Client::new( + &format!("http://localhost:{port}"), + Auth::UserPass(user.clone(), pass.clone()), + )?; + let bg_process = vec![process]; + Ok(Self { + inner: rpc, + root_path: dir, + user, + pass, + port, + process: bg_process, + }) + } + + pub fn rpc(&self) -> &Client { + &self.inner + } + + pub async fn stop(&mut self) -> anyhow::Result<()> { + log::info!("stop bitcoin node"); + self.inner.stop()?; + for process in self.process.iter_mut() { + process.kill().await?; + let _ = process.wait().await?; + log::debug!("process killed"); + } + Ok(()) + } +} diff --git a/testing/src/cln.rs b/testing/src/cln.rs new file mode 100644 index 0000000..1bf239a --- /dev/null +++ b/testing/src/cln.rs @@ -0,0 +1,114 @@ +//! Integration testing library for core lightning +use port_selector as port; +use tempfile::TempDir; + +use clightningrpc::LightningRPC; + +use crate::btc::BtcNode; +use crate::prelude::*; + +pub mod macros { + #[macro_export] + macro_rules! lightningd { + ($dir:expr, $port:expr, $($opt_args:tt)*) => { + async { + use std::process::Stdio; + + use tokio::process::Command; + + let opt_args = format!($($opt_args)*); + let args = opt_args.trim(); + let args_tok: Vec<&str> = args.split(" ").collect(); + + let path = format!("{}/.lightning", $dir.path().to_str().unwrap()); + log::info!("core lightning home {path}"); + check_dir_or_make_if_missing(path.clone()).await.unwrap(); + let mut command = Command::new("lightningd"); + command + .args(&args_tok) + .arg(format!("--addr=127.0.0.1:{}", $port)) + .arg(format!("--bind-addr=127.0.0.1:{}", $port)) + .arg(format!("--lightning-dir={path}")) + .arg("--dev-fast-gossip") + .arg("--funding-confirms=1") + .stdout(Stdio::null()) + .spawn() + }.await + }; + ($dir:expr, $port:expr) => { + $crate::lightningd!($dir, $port, "") + }; + } + + pub use lightningd; +} + +pub struct Node { + inner: LightningRPC, + root_path: TempDir, + bitcoin: BtcNode, + process: Vec, +} + +impl Drop for Node { + fn drop(&mut self) { + for process in self.process.iter() { + let Some(child) = process.id() else { + continue; + }; + let Ok(mut kill) = std::process::Command::new("kill") + .args(["-s", "SIGKILL", &child.to_string()]) + .spawn() else { + continue; + }; + let _ = kill.wait(); + } + + let result = std::fs::remove_dir_all(self.root_path.path()); + log::debug!(target: "cln", "clean up function {:?}", result); + } +} + +impl Node { + pub async fn tmp() -> anyhow::Result { + let btc = BtcNode::tmp().await?; + + let dir = tempfile::tempdir()?; + + let process = macros::lightningd!( + dir, + port::random_free_port().unwrap(), + "--network=regtest --bitcoin-rpcuser={} --bitcoin-rpcpassword={} --bitcoin-rpcport={}", + btc.user, + btc.pass, + btc.port, + )?; + + let rpc = LightningRPC::new(dir.path().join(".lightning/regtest").join("lightning-rpc")); + + wait_for!(async { rpc.getinfo() }); + + Ok(Self { + inner: rpc, + root_path: dir, + bitcoin: btc, + process: vec![process], + }) + } + + pub fn rpc(&self) -> &LightningRPC { + &self.inner + } + + pub async fn stop(&mut self) -> anyhow::Result<()> { + log::info!("stop lightning node"); + self.inner.stop()?; + for process in self.process.iter_mut() { + process.kill().await?; + let _ = process.wait().await?; + log::debug!("killing process"); + } + self.bitcoin.stop().await?; + Ok(()) + } +} diff --git a/testing/src/lib.rs b/testing/src/lib.rs new file mode 100644 index 0000000..fd4c401 --- /dev/null +++ b/testing/src/lib.rs @@ -0,0 +1,50 @@ +pub mod btc; +pub mod cln; + +pub mod prelude { + pub use crate::macros::*; + pub use port_selector as port; + pub use tempfile; + + pub use crate::check_dir_or_make_if_missing; +} + +pub mod macros { + #[macro_export] + macro_rules! wait_for { + ($callback:expr, $timeout:expr) => { + use log; + use tokio::time::{sleep, Duration}; + + for wait in 0..$timeout { + if let Err(err) = $callback.await { + log::debug!("callback return {:?}", err); + sleep(Duration::from_millis(wait)).await; + continue; + } + log::info!("callback completed in {wait} milliseconds"); + break; + } + }; + ($callback:expr) => { + use crate::DEFAULT_TIMEOUT; + + $crate::wait_for!($callback, DEFAULT_TIMEOUT); + }; + } + + pub use wait_for; +} + +static DEFAULT_TIMEOUT: u64 = 100; + +pub async fn check_dir_or_make_if_missing(path: String) -> anyhow::Result<()> { + use std::path::Path; + use tokio::fs::create_dir; + + if !Path::exists(Path::new(&path.to_owned())) { + create_dir(path.clone()).await?; + log::debug!("created dir {path}"); + } + Ok(()) +}