Skip to main content

linera_service/
util.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    io::{BufRead, BufReader, Write},
6    num::ParseIntError,
7    path::Path,
8    time::Duration,
9};
10
11use anyhow::{bail, Context as _, Result};
12use async_graphql::http::GraphiQLSource;
13use axum::response::{self, IntoResponse};
14use http::Uri;
15#[cfg(test)]
16use linera_base::command::parse_version_message;
17use linera_base::data_types::TimeDelta;
18pub use linera_client::util::*;
19use tracing::debug;
20
21/// Default pause, in seconds, inserted after `linera service` commands in readme e2e tests.
22pub static DEFAULT_PAUSE_AFTER_LINERA_SERVICE_SECS: &str = "3";
23/// Default pause, in seconds, inserted after GraphQL mutations in readme e2e tests.
24pub static DEFAULT_PAUSE_AFTER_GQL_MUTATIONS_SECS: &str = "3";
25
26/// Extension trait for [`tokio::process::Child`].
27pub trait ChildExt: std::fmt::Debug {
28    /// Ensures the child process is still running, returning an error if it has exited.
29    fn ensure_is_running(&mut self) -> Result<()>;
30}
31
32impl ChildExt for tokio::process::Child {
33    fn ensure_is_running(&mut self) -> Result<()> {
34        if let Some(status) = self.try_wait().context("try_wait child process")? {
35            bail!("Child process {self:?} already exited with status: {status}");
36        }
37        debug!("Child process {self:?} is running as expected.");
38        Ok(())
39    }
40}
41
42/// Reads and deserializes a JSON value from the file at the given path.
43pub fn read_json<T: serde::de::DeserializeOwned>(path: impl Into<std::path::PathBuf>) -> Result<T> {
44    Ok(serde_json::from_reader(fs_err::File::open(path)?)?)
45}
46
47/// Expands to the name of the current test function.
48#[cfg(with_testing)]
49#[macro_export]
50macro_rules! test_name {
51    () => {
52        stdext::function_name!()
53            .strip_suffix("::{{closure}}")
54            .expect("should be called from the body of a test")
55    };
56}
57
58/// A reader over a Markdown document, used to extract embedded code blocks.
59pub struct Markdown<B> {
60    buffer: B,
61}
62
63impl Markdown<BufReader<fs_err::File>> {
64    /// Opens the Markdown file at the given path.
65    pub fn new(path: impl AsRef<Path>) -> std::io::Result<Self> {
66        let buffer = BufReader::new(fs_err::File::open(path.as_ref())?);
67        Ok(Self { buffer })
68    }
69}
70
71impl<B> Markdown<B>
72where
73    B: BufRead,
74{
75    /// Extracts the embedded bash and GraphQL code blocks as a runnable bash script.
76    #[expect(clippy::while_let_on_iterator)]
77    pub fn extract_bash_script_to(
78        self,
79        mut output: impl Write,
80        pause_after_linera_service: Option<Duration>,
81        pause_after_gql_mutations: Option<Duration>,
82    ) -> std::io::Result<()> {
83        let mut lines = self.buffer.lines();
84
85        while let Some(line) = lines.next() {
86            let line = line?;
87
88            if line.starts_with("```bash") {
89                if line.ends_with("ignore") {
90                    continue;
91                } else {
92                    let mut quote = String::new();
93                    while let Some(line) = lines.next() {
94                        let line = line?;
95                        if line.starts_with("```") {
96                            break;
97                        }
98                        quote += &line;
99                        quote += "\n";
100
101                        if let Some(pause) = pause_after_linera_service {
102                            if line.contains("linera service") {
103                                quote += &format!("sleep {}\n", pause.as_secs());
104                            }
105                        }
106                    }
107                    writeln!(output, "{quote}")?;
108                }
109            } else if let Some(uri) = line.strip_prefix("```gql,uri=") {
110                let mut quote = String::new();
111                while let Some(line) = lines.next() {
112                    let line = line?;
113                    if line.starts_with("```") {
114                        break;
115                    }
116                    quote += &line;
117                    quote += "\n";
118                }
119
120                writeln!(output, "QUERY=\"{}\"", quote.replace('"', "\\\""))?;
121                writeln!(
122                    output,
123                    "JSON_QUERY=$( jq -n --arg q \"$QUERY\" '{{\"query\": $q}}' )"
124                )?;
125                writeln!(
126                    output,
127                    "QUERY_RESULT=$( \
128                     curl -w '\\n' -g -X POST \
129                       -H \"Content-Type: application/json\" \
130                       -d \"$JSON_QUERY\" {uri} \
131                     | tee /dev/stderr \
132                     | jq -e .data \
133                     )"
134                )?;
135
136                if let Some(pause) = pause_after_gql_mutations {
137                    // Hack: let's add a pause after mutations.
138                    if quote.starts_with("mutation") {
139                        writeln!(output, "sleep {}\n", pause.as_secs())?;
140                    }
141                }
142            }
143        }
144
145        output.flush()?;
146        Ok(())
147    }
148}
149
150/// Returns an HTML response constructing the GraphiQL web page for the given URI.
151pub(crate) async fn graphiql(uri: Uri) -> impl IntoResponse {
152    let source = GraphiQLSource::build()
153        .endpoint(uri.path())
154        .subscription_endpoint("/ws")
155        .finish()
156        .replace("@17", "@18")
157        .replace(
158            "ReactDOM.render(",
159            "ReactDOM.createRoot(document.getElementById(\"graphiql\")).render(",
160        );
161    response::Html(source)
162}
163
164/// Parses a string of milliseconds into a [`Duration`].
165pub fn parse_millis(s: &str) -> Result<Duration, ParseIntError> {
166    Ok(Duration::from_millis(s.parse()?))
167}
168
169/// Converts a `Duration` to `Option<Duration>`, treating zero as `None`.
170pub fn non_zero_duration(d: Duration) -> Option<Duration> {
171    if d.is_zero() {
172        None
173    } else {
174        Some(d)
175    }
176}
177
178/// Parses a string of milliseconds into a [`TimeDelta`].
179pub fn parse_millis_delta(s: &str) -> Result<TimeDelta, ParseIntError> {
180    Ok(TimeDelta::from_millis(s.parse()?))
181}
182
183/// Validates that a string contains only ASCII alphanumeric characters, returning it unchanged.
184pub fn parse_ascii_alphanumeric_string(s: &str) -> Result<String, &'static str> {
185    if s.chars().all(|x| x.is_ascii_alphanumeric()) {
186        Ok(s.to_string())
187    } else {
188        Err("Expecting ASCII alphanumeric characters")
189    }
190}
191
192/// Checks the condition five times with increasing delays. Returns `true` if it is met.
193#[cfg(with_testing)]
194pub async fn eventually<F>(condition: impl Fn() -> F) -> bool
195where
196    F: std::future::Future<Output = bool>,
197{
198    for i in 0..5 {
199        linera_base::time::timer::sleep(linera_base::time::Duration::from_secs(i)).await;
200        if condition().await {
201            return true;
202        }
203    }
204    false
205}
206
207#[test]
208fn test_parse_version_message() {
209    let s = "something\n . . . version12\nother things";
210    assert_eq!(parse_version_message(s), "version12");
211
212    let s = "something\n . . . version12other things";
213    assert_eq!(parse_version_message(s), "things");
214
215    let s = "something . . . version12 other things";
216    assert_eq!(parse_version_message(s), "");
217
218    let s = "";
219    assert_eq!(parse_version_message(s), "");
220}
221
222#[test]
223fn test_ignore() {
224    let readme = r#"
225first line
226```bash
227some bash
228```
229second line
230```bash
231some other bash
232```
233third line
234```bash,ignore
235this will be ignored
236```
237    "#;
238    let buffer = std::io::Cursor::new(readme);
239    let markdown = Markdown { buffer };
240    let mut script = Vec::new();
241    markdown
242        .extract_bash_script_to(&mut script, None, None)
243        .unwrap();
244    let expected = "some bash\n\nsome other bash\n\n";
245    assert_eq!(String::from_utf8_lossy(&script), expected);
246}