1use sp_keyring::Sr25519Keyring;
16use std::{
17 ffi::{
18 OsStr,
19 OsString,
20 },
21 io::{
22 BufRead,
23 BufReader,
24 Read,
25 },
26 process,
27};
28use subxt::{
29 backend::rpc::RpcClient,
30 Config,
31 OnlineClient,
32};
33
34pub struct TestNodeProcess<R: Config> {
36 proc: process::Child,
37 rpc: RpcClient,
38 client: OnlineClient<R>,
39 url: String,
40}
41
42impl<R> Drop for TestNodeProcess<R>
43where
44 R: Config,
45{
46 fn drop(&mut self) {
47 let _ = self.kill();
48 }
49}
50
51impl<R> TestNodeProcess<R>
52where
53 R: Config,
54{
55 pub fn build<S>(program: S) -> TestNodeProcessBuilder<R>
57 where
58 S: AsRef<OsStr> + Clone,
59 {
60 TestNodeProcessBuilder::new(program)
61 }
62
63 pub fn build_with_env_or_default() -> TestNodeProcessBuilder<R> {
66 const DEFAULT_CONTRACTS_NODE: &str = "ink-node";
67
68 let contracts_node =
70 std::env::var("CONTRACTS_NODE").unwrap_or(DEFAULT_CONTRACTS_NODE.to_owned());
71
72 if which::which(&contracts_node).is_err() {
74 if contracts_node == DEFAULT_CONTRACTS_NODE {
75 panic!(
76 "The '{DEFAULT_CONTRACTS_NODE}' executable was not found. Install '{DEFAULT_CONTRACTS_NODE}' on the PATH, \
77 or specify the `CONTRACTS_NODE` environment variable.",
78 )
79 } else {
80 panic!("The contracts node executable '{contracts_node}' was not found.")
81 }
82 }
83 Self::build(contracts_node)
84 }
85
86 pub fn kill(&mut self) -> Result<(), String> {
88 tracing::info!("Killing node process {}", self.proc.id());
89 if let Err(err) = self.proc.kill() {
90 let err = format!("Error killing node process {}: {}", self.proc.id(), err);
91 tracing::error!("{}", err);
92 return Err(err)
93 }
94 Ok(())
95 }
96
97 pub fn rpc(&self) -> RpcClient {
99 self.rpc.clone()
100 }
101
102 pub fn client(&self) -> OnlineClient<R> {
104 self.client.clone()
105 }
106
107 pub fn url(&self) -> &str {
109 &self.url
110 }
111}
112
113pub struct TestNodeProcessBuilder<R> {
115 node_path: OsString,
116 authority: Option<Sr25519Keyring>,
117 marker: std::marker::PhantomData<R>,
118}
119
120impl<R> TestNodeProcessBuilder<R>
121where
122 R: Config,
123{
124 pub fn new<P>(node_path: P) -> TestNodeProcessBuilder<R>
125 where
126 P: AsRef<OsStr>,
127 {
128 Self {
129 node_path: node_path.as_ref().into(),
130 authority: None,
131 marker: Default::default(),
132 }
133 }
134
135 pub fn with_authority(&mut self, account: Sr25519Keyring) -> &mut Self {
137 self.authority = Some(account);
138 self
139 }
140
141 pub async fn spawn(&self) -> Result<TestNodeProcess<R>, String> {
143 let mut cmd = process::Command::new(&self.node_path);
144 cmd.env("RUST_LOG", "info")
145 .arg("--dev")
146 .stdout(process::Stdio::piped())
147 .stderr(process::Stdio::piped())
148 .arg("--port=0")
149 .arg("--rpc-port=0")
150 .arg("-lruntime::revive=debug");
151
152 if let Some(authority) = self.authority {
153 let authority = format!("{authority:?}");
154 let arg = format!("--{}", authority.as_str().to_lowercase());
155 cmd.arg(arg);
156 }
157
158 let mut proc = cmd.spawn().map_err(|e| {
159 format!(
160 "Error spawning substrate node '{}': {}",
161 self.node_path.to_string_lossy(),
162 e
163 )
164 })?;
165
166 let stderr = proc.stderr.take().unwrap();
168 let port = find_substrate_port_from_output(stderr);
169 let url = format!("ws://127.0.0.1:{port}");
170
171 let rpc = RpcClient::from_url(url.clone())
173 .await
174 .map_err(|err| format!("Error initializing rpc client: {}", err))?;
175 let client = OnlineClient::from_url(url.clone()).await;
176 match client {
177 Ok(client) => {
178 Ok(TestNodeProcess {
179 proc,
180 rpc,
181 client,
182 url: url.clone(),
183 })
184 }
185 Err(err) => {
186 let err = format!("Failed to connect to node rpc at {url}: {err}");
187 tracing::error!("{}", err);
188 proc.kill().map_err(|e| {
189 format!("Error killing substrate process '{}': {}", proc.id(), e)
190 })?;
191 Err(err)
192 }
193 }
194 }
195}
196
197fn find_substrate_port_from_output(r: impl Read + Send + 'static) -> u16 {
200 let mut all_lines = String::new();
201 BufReader::new(r)
202 .lines()
203 .find_map(|line| {
204 let line =
205 line.expect("failed to obtain next line from stdout for port discovery");
206 all_lines.push_str(&format!("{}\n", line));
207
208 let line_end = line
211 .rsplit_once("Listening for new connections on 127.0.0.1:")
212 .or_else(|| {
213 line.rsplit_once("Running JSON-RPC WS server: addr=127.0.0.1:")
214 })
215 .or_else(|| line.rsplit_once("Running JSON-RPC server: addr=127.0.0.1:"))
216 .map(|(_, port_str)| port_str)?;
217
218 let re = regex::Regex::new(r"^\d+").expect("regex creation failed");
220 let port_capture = re
221 .captures(line_end)
222 .unwrap_or_else(|| panic!("unable to extract port from '{}'", line_end));
223 assert!(
224 port_capture.len() == 1,
225 "captured more than one port from '{}'",
226 line_end
227 );
228 let port_str = &port_capture[0];
229
230 let port_num = port_str.parse().unwrap_or_else(|_| {
233 panic!("valid port expected for tracing line, got '{port_str}'")
234 });
235
236 Some(port_num)
237 })
238 .unwrap_or_else(|| {
239 panic!(
240 "Unable to extract port from spawned node, the reader ended.\n\
241 These are the lines we saw up until here:\n{}",
242 all_lines
243 );
244 })
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use subxt::{
251 backend::legacy::LegacyRpcMethods,
252 PolkadotConfig as SubxtConfig,
253 };
254
255 #[tokio::test]
256 #[allow(unused_assignments)]
257 async fn spawning_and_killing_nodes_works() {
258 let mut client1: Option<LegacyRpcMethods<SubxtConfig>> = None;
259 let mut client2: Option<LegacyRpcMethods<SubxtConfig>> = None;
260
261 {
262 let node_proc1 = TestNodeProcess::<SubxtConfig>::build("ink-node")
263 .spawn()
264 .await
265 .unwrap();
266 client1 = Some(LegacyRpcMethods::new(node_proc1.rpc()));
267
268 let node_proc2 = TestNodeProcess::<SubxtConfig>::build("ink-node")
269 .spawn()
270 .await
271 .unwrap();
272 client2 = Some(LegacyRpcMethods::new(node_proc2.rpc()));
273
274 let res1 = client1.clone().unwrap().chain_get_block_hash(None).await;
275 let res2 = client2.clone().unwrap().chain_get_block_hash(None).await;
276
277 assert!(res1.is_ok(), "process 1 is not ok, but should be");
278 assert!(res2.is_ok(), "process 2 is not ok, but should be");
279 }
280
281 let res1 = client1.unwrap().chain_get_block_hash(None).await;
283 let res2 = client2.unwrap().chain_get_block_hash(None).await;
284
285 assert!(
286 res1.is_err(),
287 "process 1: did not find err, but expected one"
288 );
289 assert!(
290 res2.is_err(),
291 "process 2: did not find err, but expected one"
292 );
293 }
294
295 #[test]
296 fn parse_port_from_node_output() {
297 let log = "2024-12-04 10:57:03.893 INFO main sc_rpc_server: Running JSON-RPC server: addr=127.0.0.1:9944,[::1]:9944 ";
298 let port = find_substrate_port_from_output(log.as_bytes());
299 assert_eq!(port, 9944);
300
301 let log = "2024-12-04 10:57:03.893 INFO main sc_rpc_server: Running JSON-RPC server: addr=127.0.0.1:9944 ";
302 let port = find_substrate_port_from_output(log.as_bytes());
303 assert_eq!(port, 9944);
304
305 let log = r#"2024-12-04 11:02:24.637 INFO main sc_cli::runner: 👤 Role: AUTHORITY
3062024-12-04 11:02:24.637 INFO main sc_cli::runner: 💾 Database: RocksDb at /var/folders/s5/5gcp8ck95k39z006fj059_0c0000gn/T/substrateHZoRbb/chains/dev/db/full
3072024-12-04 11:02:25.324 WARN main sc_service::config: Using default protocol ID "sup" because none is configured in the chain specs
3082024-12-04 11:02:25.327 INFO main sc_rpc_server: Running JSON-RPC server: addr=127.0.0.1:9944,[::1]:9944
3092024-12-04 11:02:24.637 INFO main sc_cli::runner: 💾 Database: RocksDb at /var/folders/s5/5gcp8ck95k39z006fj059_0c0000gn/T/substrateHZoRbb/chains/dev/db/full
3102024-12-04 11:02:24.637 INFO main sc_cli::runner: 💾 Database: RocksDb at /var/folders/s5/5gcp8ck95k39z006fj059_0c0000gn/T/substrateHZoRbb/chains/dev/db/full
311"#;
312 let port = find_substrate_port_from_output(log.as_bytes());
313 assert_eq!(port, 9944);
314 }
315}