Skip to main content

pop_common/
test_env.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use crate::{
4	Error,
5	polkadot_sdk::{sort_by_latest_semantic_version, sort_by_latest_stable_version},
6	resolve_port, set_executable_permission,
7	sourcing::{ArchiveFileSpec, Binary, GitHub::ReleaseArchive, Source::GitHub},
8	target,
9};
10
11use serde_json::json;
12use std::{
13	env::consts::{ARCH, OS},
14	process::{Child, Command, Stdio},
15	time::Duration,
16};
17use tokio::{sync::OnceCell, time::sleep};
18
19/// Internal struct representing a running test node process.
20struct NodeProcess {
21	child: Child,
22	ws_url: String,
23	_temp_dir: tempfile::TempDir,
24	_cache_dir: Option<tempfile::TempDir>,
25}
26
27impl Drop for NodeProcess {
28	fn drop(&mut self) {
29		let _ = self.child.kill();
30	}
31}
32
33impl NodeProcess {
34	/// Wait for the node to become available via RPC.
35	async fn wait_for_availability(host: &str, port: u16) -> anyhow::Result<()> {
36		let mut attempts = 0;
37		let url = format!("http://{host}:{port}");
38		let client = reqwest::Client::new();
39		let payload = json!({
40			"jsonrpc": "2.0",
41			"id": 1,
42			"method": "system_health",
43			"params": []
44		});
45
46		loop {
47			sleep(Duration::from_secs(2)).await;
48			match client.post(&url).json(&payload).send().await {
49				Ok(resp) => {
50					let text = resp.text().await?;
51					if !text.is_empty() {
52						return Ok(());
53					}
54				},
55				Err(_) => {
56					attempts += 1;
57					if attempts > 10 {
58						return Err(anyhow::anyhow!("Node could not be started"));
59					}
60				},
61			}
62		}
63	}
64
65	/// Spawn a node with the given binary configuration.
66	///
67	/// If the binary is extracted to a temporary cache directory, pass it as `cache_dir`
68	/// to ensure it remains alive for the lifetime of the node process.
69	async fn spawn(binary: Binary, cache_dir: Option<tempfile::TempDir>) -> anyhow::Result<Self> {
70		let temp_dir = tempfile::tempdir()?;
71		let random_port = resolve_port(None);
72
73		binary.source(false, &(), true).await?;
74		set_executable_permission(binary.path())?;
75
76		let mut command = Command::new(binary.path());
77		command.arg("--dev");
78		command.arg(format!("--rpc-port={}", random_port));
79		command.stderr(Stdio::null());
80		command.stdout(Stdio::null());
81
82		let child = command.spawn()?;
83		let host = "127.0.0.1";
84
85		Self::wait_for_availability(host, random_port).await?;
86
87		let ws_url = format!("ws://{host}:{random_port}");
88
89		Ok(Self { child, ws_url, _temp_dir: temp_dir, _cache_dir: cache_dir })
90	}
91
92	fn ws_url(&self) -> &str {
93		&self.ws_url
94	}
95}
96
97/// Represents a temporary ink! test node process for contract testing.
98///
99/// This node includes pallet-revive for smart contract functionality.
100/// For non-contract testing (chain calls, storage, metadata), use `SubstrateTestNode` instead.
101pub struct InkTestNode(NodeProcess);
102
103impl InkTestNode {
104	/// Spawns a local ink! node and waits until it's ready.
105	pub async fn spawn() -> anyhow::Result<Self> {
106		let temp_dir = tempfile::tempdir()?;
107		let cache = temp_dir.path().to_path_buf();
108
109		let binary = Binary::Source {
110			name: "ink-node".to_string(),
111			source: GitHub(ReleaseArchive {
112				owner: "use-ink".into(),
113				repository: "ink-node".into(),
114				tag: None,
115				tag_pattern: Some("v{version}".into()),
116				prerelease: false,
117				version_comparator: sort_by_latest_semantic_version,
118				fallback: "v0.47.0".to_string(),
119				archive: ink_node_archive()?,
120				contents: ink_node_contents()?,
121				latest: None,
122			})
123			.into(),
124			cache: cache.to_path_buf(),
125		};
126
127		NodeProcess::spawn(binary, Some(temp_dir)).await.map(Self)
128	}
129
130	/// Returns the WebSocket URL of the running test node.
131	pub fn ws_url(&self) -> &str {
132		self.0.ws_url()
133	}
134}
135
136/// Represents a temporary Substrate test node process for testing chain functionality.
137///
138/// This node is intended for testing non-contract features like:
139/// - Chain calls and extrinsics
140/// - Storage queries
141/// - Metadata parsing
142/// - Runtime operations
143///
144/// For contract-specific testing, use `InkTestNode` which runs ink-node with pallet-revive.
145pub struct SubstrateTestNode(NodeProcess);
146
147impl SubstrateTestNode {
148	/// Spawns a local Substrate test node and waits until it's ready.
149	///
150	/// This uses substrate-node from r0gue-io/polkadot releases, which provides
151	/// a minimal Substrate runtime suitable for testing chain functionality.
152	pub async fn spawn() -> anyhow::Result<Self> {
153		let temp_dir = tempfile::tempdir()?;
154		let cache = temp_dir.path().to_path_buf();
155
156		let binary = Binary::Source {
157			name: "substrate-node".to_string(),
158			source: GitHub(ReleaseArchive {
159				owner: "r0gue-io".into(),
160				repository: "polkadot".into(),
161				tag: Some("polkadot-stable2512-1".into()),
162				tag_pattern: Some("polkadot-{version}".into()),
163				prerelease: false,
164				version_comparator: sort_by_latest_stable_version,
165				fallback: "stable2512-1".to_string(),
166				archive: format!("substrate-node-{}.tar.gz", target()?),
167				contents: vec![ArchiveFileSpec::new("substrate-node".into(), None, true)],
168				latest: None,
169			})
170			.into(),
171			cache: cache.to_path_buf(),
172		};
173
174		NodeProcess::spawn(binary, Some(temp_dir)).await.map(Self)
175	}
176
177	/// Returns the WebSocket URL of the running test node.
178	pub fn ws_url(&self) -> &str {
179		self.0.ws_url()
180	}
181}
182
183fn ink_node_archive() -> Result<String, Error> {
184	match OS {
185		"macos" => Ok("ink-node-mac-universal.tar.gz".to_string()),
186		"linux" => Ok("ink-node-linux.tar.gz".to_string()),
187		_ => Err(Error::UnsupportedPlatform { arch: ARCH, os: OS }),
188	}
189}
190
191fn ink_node_contents() -> Result<Vec<ArchiveFileSpec>, Error> {
192	match OS {
193		"macos" => Ok("ink-node-mac/ink-node"),
194		"linux" => Ok("ink-node-linux/ink-node"),
195		_ => Err(Error::UnsupportedPlatform { arch: ARCH, os: OS }),
196	}
197	.map(|name| vec![ArchiveFileSpec::new(name.into(), Some("ink-node".into()), true)])
198}
199
200static SHARED_INK_NODE_WS_URL: OnceCell<String> = OnceCell::const_new();
201static SHARED_SUBSTRATE_NODE_WS_URL: OnceCell<String> = OnceCell::const_new();
202
203/// Returns the WebSocket URL for a shared `InkTestNode` singleton.
204///
205/// The first call spawns the node and leaks it so it lives for the entire test binary.
206/// Subsequent calls reuse the same URL. Set `POP_TEST_INK_NODE_WS_URL` to skip spawning.
207pub async fn shared_ink_ws_url() -> String {
208	SHARED_INK_NODE_WS_URL
209		.get_or_init(|| async {
210			if let Ok(url) = std::env::var("POP_TEST_INK_NODE_WS_URL") {
211				return url;
212			}
213			let node = InkTestNode::spawn().await.expect("Failed to spawn shared InkTestNode");
214			let ws_url = node.ws_url().to_string();
215			let _ = Box::leak(Box::new(node));
216			ws_url
217		})
218		.await
219		.clone()
220}
221
222/// Returns the WebSocket URL for a shared `SubstrateTestNode` singleton.
223///
224/// The first call spawns the node and leaks it so it lives for the entire test binary.
225/// Subsequent calls reuse the same URL. Set `POP_TEST_SUBSTRATE_NODE_WS_URL` to skip spawning.
226pub async fn shared_substrate_ws_url() -> String {
227	SHARED_SUBSTRATE_NODE_WS_URL
228		.get_or_init(|| async {
229			if let Ok(url) = std::env::var("POP_TEST_SUBSTRATE_NODE_WS_URL") {
230				return url;
231			}
232			let node = SubstrateTestNode::spawn()
233				.await
234				.expect("Failed to spawn shared SubstrateTestNode");
235			let ws_url = node.ws_url().to_string();
236			let _ = Box::leak(Box::new(node));
237			ws_url
238		})
239		.await
240		.clone()
241}