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 BufReader::new(r)
201 .lines()
202 .find_map(|line| {
203 let line =
204 line.expect("failed to obtain next line from stdout for port discovery");
205
206 let line_end = line
209 .rsplit_once("Listening for new connections on 127.0.0.1:")
210 .or_else(|| {
211 line.rsplit_once("Running JSON-RPC WS server: addr=127.0.0.1:")
212 })
213 .or_else(|| line.rsplit_once("Running JSON-RPC server: addr=127.0.0.1:"))
214 .map(|(_, port_str)| port_str)?;
215
216 let re = regex::Regex::new(r"^\d+").expect("regex creation failed");
218 let port_capture = re
219 .captures(line_end)
220 .unwrap_or_else(|| panic!("unable to extract port from '{}'", line_end));
221 assert!(
222 port_capture.len() == 1,
223 "captured more than one port from '{}'",
224 line_end
225 );
226 let port_str = &port_capture[0];
227
228 let port_num = port_str.parse().unwrap_or_else(|_| {
231 panic!("valid port expected for tracing line, got '{port_str}'")
232 });
233
234 Some(port_num)
235 })
236 .expect("We should find a port before the reader ends")
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use subxt::{
243 backend::legacy::LegacyRpcMethods,
244 PolkadotConfig as SubxtConfig,
245 };
246
247 #[tokio::test]
248 #[allow(unused_assignments)]
249 async fn spawning_and_killing_nodes_works() {
250 let mut client1: Option<LegacyRpcMethods<SubxtConfig>> = None;
251 let mut client2: Option<LegacyRpcMethods<SubxtConfig>> = None;
252
253 {
254 let node_proc1 = TestNodeProcess::<SubxtConfig>::build("ink-node")
255 .spawn()
256 .await
257 .unwrap();
258 client1 = Some(LegacyRpcMethods::new(node_proc1.rpc()));
259
260 let node_proc2 = TestNodeProcess::<SubxtConfig>::build("ink-node")
261 .spawn()
262 .await
263 .unwrap();
264 client2 = Some(LegacyRpcMethods::new(node_proc2.rpc()));
265
266 let res1 = client1.clone().unwrap().chain_get_block_hash(None).await;
267 let res2 = client2.clone().unwrap().chain_get_block_hash(None).await;
268
269 assert!(res1.is_ok());
270 assert!(res2.is_ok());
271 }
272
273 let res1 = client1.unwrap().chain_get_block_hash(None).await;
275 let res2 = client2.unwrap().chain_get_block_hash(None).await;
276
277 assert!(res1.is_err());
278 assert!(res2.is_err());
279 }
280
281 #[test]
282 fn parse_port_from_node_output() {
283 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 ";
284 let port = find_substrate_port_from_output(log.as_bytes());
285 assert_eq!(port, 9944);
286
287 let log = "2024-12-04 10:57:03.893 INFO main sc_rpc_server: Running JSON-RPC server: addr=127.0.0.1:9944 ";
288 let port = find_substrate_port_from_output(log.as_bytes());
289 assert_eq!(port, 9944);
290
291 let log = r#"2024-12-04 11:02:24.637 INFO main sc_cli::runner: 👤 Role: AUTHORITY
2922024-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
2932024-12-04 11:02:25.324 WARN main sc_service::config: Using default protocol ID "sup" because none is configured in the chain specs
2942024-12-04 11:02:25.327 INFO main sc_rpc_server: Running JSON-RPC server: addr=127.0.0.1:9944,[::1]:9944
2952024-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
2962024-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
297"#;
298 let port = find_substrate_port_from_output(log.as_bytes());
299 assert_eq!(port, 9944);
300 }
301}