Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add e2e config startup tests by benthecarman · Pull Request #142 · lightningdevkit/ldk-server · GitHub
Skip to content
Merged
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
300 changes: 259 additions & 41 deletions e2e-tests/src/lib.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,11 +16,11 @@ use std::time::Duration;
use corepc_node::Node;
use hex_conservative::DisplayHex;
use ldk_server_client::client::LdkServerClient;
use serde_json::Value;
use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse};
use ldk_server_grpc::api::{
GetBalancesRequest, ListChannelsRequest, OnchainReceiveRequest, OpenChannelRequest,
};
use serde_json::Value;

/// Wrapper around a managed bitcoind process for regtest.
pub struct TestBitcoind {
Expand DownExpand Up@@ -103,42 +103,159 @@ pub struct LdkServerConfig {
pub metrics_auth: Option<(String, String)>,
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_config(bitcoind, LdkServerConfig::default()).await
/// Dynamic parameters available when building test configs.
pub struct TestServerParams {
pub grpc_port: u16,
pub p2p_port: u16,
pub storage_dir: PathBuf,
pub rpc_address: String,
pub rpc_user: String,
pub rpc_password: String,
}

/// A chain source for the test config, mirroring the server's supported backends.
pub enum ChainSource {
Bitcoind { rpc_address: String, rpc_user: String, rpc_password: String },
Electrum { server_url: String },
Esplora { server_url: String },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum and Esplora seem to be unused. Would remove it if it is dead currently.

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just gonna leave for now, we may implement it eventually.

}

impl ChainSource {
/// Render the chain source as its TOML section.
fn to_toml(&self) -> String {
match self {
ChainSource::Bitcoind { rpc_address, rpc_user, rpc_password } => format!(
"[bitcoind]\nrpc_address = \"{}\"\nrpc_user = \"{}\"\nrpc_password = \"{}\"",
rpc_address, rpc_user, rpc_password
),
ChainSource::Electrum { server_url } => {
format!("[electrum]\nserver_url = \"{}\"", server_url)
},
ChainSource::Esplora { server_url } => {
format!("[esplora]\nserver_url = \"{}\"", server_url)
},
}
}
}

/// Builder for the ldk-server config TOML used in tests.
///
/// Tests tweak named, typed knobs and call [`TestConfigBuilder::build`] once to
/// produce the TOML. This keeps tests from doing string surgery on rendered output.
pub struct TestConfigBuilder {
listening_addresses: Vec<String>,
announcement_addresses: Vec<String>,
grpc_service_address: String,
alias: Option<String>,
storage_dir: PathBuf,
chain_source: ChainSource,
metrics_auth: Option<(String, String)>,
log: Option<(Option<String>, String)>,
tls_hosts: Option<Vec<String>>,
}

impl TestConfigBuilder {
/// Start from the default test config: a single localhost listening address, the
/// `e2e-test-node` alias, and a bitcoind RPC chain source derived from `params`.
pub fn new(params: &TestServerParams) -> Self {
Self {
listening_addresses: vec![format!("127.0.0.1:{}", params.p2p_port)],
announcement_addresses: Vec::new(),
grpc_service_address: format!("127.0.0.1:{}", params.grpc_port),
alias: Some("e2e-test-node".to_string()),
storage_dir: params.storage_dir.clone(),
chain_source: ChainSource::Bitcoind {
rpc_address: params.rpc_address.clone(),
rpc_user: params.rpc_user.clone(),
rpc_password: params.rpc_password.clone(),
},
metrics_auth: None,
log: None,
tls_hosts: None,
}
}

/// Set the node alias, or `None` to omit it entirely.
pub fn alias(mut self, alias: Option<&str>) -> Self {
self.alias = alias.map(str::to_string);
self
}

/// Set the listening addresses. An empty vec omits the key entirely.
pub fn listening_addresses(mut self, addresses: Vec<String>) -> Self {
self.listening_addresses = addresses;
self
}

/// Set the announcement addresses. An empty vec (the default) omits the key.
pub fn announcement_addresses(mut self, addresses: Vec<String>) -> Self {
self.announcement_addresses = addresses;
self
}

/// Replace the chain source backend.
pub fn chain_source(mut self, chain_source: ChainSource) -> Self {
self.chain_source = chain_source;
self
}

/// Add HTTP basic auth credentials to the `[metrics]` section.
pub fn metrics_auth(mut self, username: &str, password: &str) -> Self {
self.metrics_auth = Some((username.to_string(), password.to_string()));
self
}

/// Add a `[log]` section with the given file path and optional level.
pub fn log(mut self, level: Option<&str>, file: &str) -> Self {
self.log = Some((level.map(str::to_string), file.to_string()));
self
}

pub async fn start_with_config(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();
/// Add a `[tls]` section advertising the given hosts.
pub fn tls_hosts(mut self, hosts: Vec<String>) -> Self {
self.tls_hosts = Some(hosts);
self
}

/// Build the config into a TOML string.
pub fn build(&self) -> String {
fn toml_string_array(values: &[String]) -> String {
let quoted: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
format!("[{}]", quoted.join(", "))
}

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");
let mut node = vec!["[node]".to_string(), "network = \"regtest\"".to_string()];
if !self.listening_addresses.is_empty() {
node.push(format!(
"listening_addresses = {}",
toml_string_array(&self.listening_addresses)
));
}
node.push(format!("grpc_service_address = \"{}\"", self.grpc_service_address));
Comment thread
benthecarman marked this conversation as resolved.
if let Some(alias) = &self.alias {
node.push(format!("alias = \"{}\"", alias));
}
if !self.announcement_addresses.is_empty() {
node.push(format!(
"announcement_addresses = {}",
toml_string_array(&self.announcement_addresses)
));
}

let metrics_auth_config = if let Some((user, pass)) = config.metrics_auth {
format!("username = \"{}\"\npassword = \"{}\"", user, pass)
} else {
String::new()
let metrics_auth = match &self.metrics_auth {
Some((user, pass)) => {
format!("\nusername = \"{}\"\npassword = \"{}\"", user, pass)
},
None => String::new(),
};

let config_content = format!(
r#"[node]
network = "regtest"
listening_addresses = ["127.0.0.1:{p2p_port}"]
grpc_service_address = "127.0.0.1:{grpc_port}"
alias = "e2e-test-node"
let mut config = format!(
r#"{node}

[storage.disk]
dir_path = "{storage_dir}"

[bitcoind]
rpc_address = "{rpc_address}"
rpc_user = "{rpc_user}"
rpc_password = "{rpc_password}"
{chain_source}

[liquidity.lsps2_service]
advertise_service = false
Expand All@@ -154,24 +271,53 @@ disable_client_reserve = false

[metrics]
enabled = true
poll_metrics_interval = 1
{metrics_auth_config}
poll_metrics_interval = 1{metrics_auth}
"#,
storage_dir = storage_dir.display(),
node = node.join("\n"),
storage_dir = self.storage_dir.display(),
chain_source = self.chain_source.to_toml(),
metrics_auth = metrics_auth,
);

let config_path = storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();
if let Some((level, file)) = &self.log {
config.push_str("\n[log]\n");
if let Some(level) = level {
config.push_str(&format!("level = \"{}\"\n", level));
}
config.push_str(&format!("file = \"{}\"\n", file));
}

let server_binary = server_binary_path();
let mut child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});
if let Some(hosts) = &self.tls_hosts {
config.push_str(&format!("\n[tls]\nhosts = {}\n", toml_string_array(hosts)));
}

config
}
}

impl LdkServerHandle {
/// Starts a new ldk-server instance against the given bitcoind.
/// Waits until the server is ready to accept requests.
pub async fn start(bitcoind: &TestBitcoind) -> Self {
Self::start_with_options(bitcoind, LdkServerConfig::default()).await
}

pub async fn start_with_options(bitcoind: &TestBitcoind, config: LdkServerConfig) -> Self {
Self::start_with_config(bitcoind, |params| {
let mut builder = TestConfigBuilder::new(params);
if let Some((user, pass)) = &config.metrics_auth {
builder = builder.metrics_auth(user, pass);
}
builder.build()
})
.await
}

pub async fn start_with_config(
config_bitcoind: &TestBitcoind, config: impl FnOnce(&TestServerParams) -> String,
) -> Self {
let (mut child, params, config_path) = spawn_server(config_bitcoind, config);
let TestServerParams { grpc_port, p2p_port, storage_dir, .. } = params;

// Spawn threads to forward stdout and stderr for debugging
let stdout = child.stdout.take().unwrap();
Expand DownExpand Up@@ -251,6 +397,78 @@ impl Drop for LdkServerHandle {
}
}

/// Prepare test server params and spawn the ldk-server process.
fn spawn_server(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> (Child, TestServerParams, PathBuf) {
#[allow(deprecated)]
let storage_dir = tempfile::tempdir().unwrap().into_path();
let grpc_port = find_available_port();
let p2p_port = find_available_port();

let (rpc_host, rpc_port_num, rpc_user, rpc_password) = bitcoind.rpc_details();
let rpc_address = format!("{rpc_host}:{rpc_port_num}");

let params =
TestServerParams { grpc_port, p2p_port, storage_dir, rpc_address, rpc_user, rpc_password };

let config_content = config_fn(&params);

let config_path = params.storage_dir.join("config.toml");
std::fs::write(&config_path, &config_content).unwrap();

let server_binary = server_binary_path();
let child = Command::new(&server_binary)
.arg(config_path.to_str().unwrap())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap_or_else(|e| {
panic!("Failed to start ldk-server binary at {:?}: {}", server_binary, e)
});

(child, params, config_path)
}

/// Start ldk-server with the given config and expect it to fail (exit non-zero).
/// Returns the stderr output for assertion in tests.
pub fn start_expect_failure(
bitcoind: &TestBitcoind, config_fn: impl FnOnce(&TestServerParams) -> String,
) -> String {
let (mut child, ..) = spawn_server(bitcoind, config_fn);

let timeout = Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
panic!(
"Server did not exit within {:?} — it may have started successfully \
instead of failing",
timeout
);
}
std::thread::sleep(Duration::from_millis(100));
},
Err(e) => panic!("Failed to wait for ldk-server process: {}", e),
}
}

let output = child
.wait_with_output()
.unwrap_or_else(|e| panic!("Failed to read ldk-server output: {}", e));

assert!(
!output.status.success(),
"Expected server to fail but it exited with status: {}",
output.status
);

String::from_utf8_lossy(&output.stderr).to_string()
}
/// Find an available TCP port by binding to port 0.
pub fn find_available_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
Expand Down
Loading
Loading