Skip to main content

qk_executor/
readiness.rs

1//! A continuous target's `readyWhen`, as Nx 24 checks it: a url to request, a
2//! port to connect to, a command that must succeed, or text to find in the
3//! output. Tasks that depend on the target with `waitFor: "ready"` start once
4//! the check passes.
5//!
6//! This is unrelated to the run-commands option of the same name, which marks
7//! the run-commands task itself as ready.
8
9use std::collections::BTreeMap;
10use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
11use std::path::Path;
12use std::process::Stdio;
13use std::sync::Mutex;
14use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
15use std::time::{Duration, Instant};
16
17use anyhow::Result;
18use command_group::CommandGroup;
19use serde_json::{Map, Value};
20
21use crate::tokens::{closing_brace, env_name, fallbacks};
22
23const DEFAULT_TIMEOUT_MS: u64 = 60_000;
24/// As in Nx 24.0.0-beta.3, probes retry every 250 ms unless `interval` says
25/// otherwise.
26const DEFAULT_INTERVAL_MS: u64 = 250;
27const MAX_DURATION_MS: u64 = 2_147_483_647;
28/// How often a wait looks at whether it has been stopped or decided.
29const POLL: Duration = Duration::from_millis(20);
30
31/// What tells that a task is ready.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub enum Probe {
34    /// A GET answered with a status from 200 to 403.
35    Url(String),
36    /// A TCP connection, to loopback unless a host is named.
37    Port { port: u16, host: Option<String> },
38    /// A shell command at the workspace root exiting with 0.
39    Command(String),
40    /// Every one of these appearing in the task's output.
41    LogMatches(Vec<String>),
42}
43
44/// A resolved `readyWhen`.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct ReadyCheck {
47    pub probe: Probe,
48    pub timeout: Duration,
49    pub interval: Duration,
50}
51
52impl ReadyCheck {
53    pub fn describe(&self) -> String {
54        match &self.probe {
55            Probe::Url(url) => format!("url {url}"),
56            Probe::Port {
57                port,
58                host: Some(host),
59            } => format!("port {host}:{port}"),
60            Probe::Port { port, host: None } => format!("port {port}"),
61            Probe::Command(command) => format!("command \"{command}\""),
62            Probe::LogMatches(matches) => format!(
63                "logMatches {}",
64                matches
65                    .iter()
66                    .map(|text| format!("\"{text}\""))
67                    .collect::<Vec<_>>()
68                    .join(", ")
69            ),
70        }
71    }
72}
73
74const PENDING: u8 = 0;
75const READY: u8 = 1;
76const FAILED: u8 = 2;
77
78/// What a task's readiness check concluded, shared between the check, the
79/// task's output and the scheduler. The first conclusion stands.
80#[derive(Debug, Default)]
81pub struct Verdict {
82    state: AtomicU8,
83    reason: Mutex<Option<String>>,
84}
85
86impl Verdict {
87    pub fn is_ready(&self) -> bool {
88        self.state.load(Ordering::SeqCst) == READY
89    }
90
91    pub fn is_decided(&self) -> bool {
92        self.state.load(Ordering::SeqCst) != PENDING
93    }
94
95    /// Why the check failed, once it has.
96    pub fn failure(&self) -> Option<String> {
97        (self.state.load(Ordering::SeqCst) == FAILED)
98            .then(|| self.reason.lock().unwrap().clone().unwrap_or_default())
99    }
100
101    pub fn set_ready(&self) {
102        let _ = self
103            .state
104            .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst);
105    }
106
107    /// Records a failure unless the check was decided already, and says
108    /// whether this call decided it.
109    pub fn fail(&self, reason: String) -> bool {
110        let mut stored = self.reason.lock().unwrap();
111        let decided = self
112            .state
113            .compare_exchange(PENDING, FAILED, Ordering::SeqCst, Ordering::SeqCst)
114            .is_ok();
115        if decided {
116            *stored = Some(reason);
117        }
118        decided
119    }
120}
121
122/// What `readyWhen` placeholders read.
123pub struct Scope<'a> {
124    pub workspace_root: &'a str,
125    pub project_root: &'a str,
126    pub project_name: &'a str,
127    /// The options the task runs with, its arguments applied.
128    pub options: &'a Map<String, Value>,
129    /// The environment the task runs with.
130    pub env: &'a BTreeMap<String, String>,
131}
132
133/// How a placeholder treats what it reads.
134#[derive(Clone, Copy, PartialEq, Eq)]
135enum Field {
136    Text,
137    /// `true` and `false`, which a flag without a value gives, are unset.
138    Host,
139    /// As `Host`, and an empty value is `0`, a port the server picks.
140    Port,
141}
142
143/// Resolves a target's `readyWhen`: `None` when the server picks its own
144/// port, which leaves nothing to check.
145pub fn resolve(task: &str, ready_when: &Value, scope: &Scope) -> Result<Option<ReadyCheck>> {
146    let invalid = |reason: String| -> anyhow::Error {
147        anyhow::anyhow!("Task \"{task}\" has an invalid \"readyWhen\": {reason}.")
148    };
149    let Value::Object(config) = ready_when else {
150        return Err(invalid("expected an object".into()));
151    };
152    for key in config.keys() {
153        if !matches!(
154            key.as_str(),
155            "url" | "port" | "host" | "command" | "logMatches" | "timeout" | "interval"
156        ) {
157            return Err(invalid(format!("unknown key \"{key}\"")));
158        }
159    }
160    let present: Vec<&str> = ["url", "port", "command", "logMatches"]
161        .into_iter()
162        .filter(|key| config.get(*key).is_some_and(|value| !value.is_null()))
163        .collect();
164    let [kind] = present[..] else {
165        return Err(invalid(
166            "expected exactly one of \"url\", \"port\", \"command\", \"logMatches\"".into(),
167        ));
168    };
169    let duration = |key: &str, default: Option<u64>| -> Result<Option<Duration>> {
170        match config.get(key).filter(|value| !value.is_null()) {
171            None => Ok(default.map(Duration::from_millis)),
172            Some(value) => match value
173                .as_u64()
174                .filter(|ms| (1..=MAX_DURATION_MS).contains(ms))
175            {
176                Some(ms) => Ok(Some(Duration::from_millis(ms))),
177                None => Err(invalid(format!(
178                    "\"{key}\" must be an integer from 1 to {MAX_DURATION_MS}"
179                ))),
180            },
181        }
182    };
183    let timeout = duration("timeout", Some(DEFAULT_TIMEOUT_MS))?.unwrap();
184    let interval = duration("interval", Some(DEFAULT_INTERVAL_MS))?.unwrap();
185    let text = |key: &str, field: Field| -> Result<Option<Value>> {
186        match config.get(key) {
187            None | Some(Value::Null) => Ok(None),
188            Some(Value::String(template)) => render(template, scope, field)
189                .map(|text| Some(Value::String(text)))
190                .map_err(invalid),
191            Some(other) => Ok(Some(other.clone())),
192        }
193    };
194    let probe = match kind {
195        "url" => match text("url", Field::Text)? {
196            Some(Value::String(url))
197                if (url.starts_with("http://") || url.starts_with("https://"))
198                    && url.parse::<ureq::http::Uri>().is_ok() =>
199            {
200                Probe::Url(url)
201            }
202            _ => return Err(invalid("\"url\" must be an http or https URL".into())),
203        },
204        "port" => {
205            let port = text("port", Field::Port)?;
206            let host = match text("host", Field::Host)? {
207                None => None,
208                Some(Value::String(host)) if !host.is_empty() && !host.contains(['{', '}']) => {
209                    Some(host)
210                }
211                Some(Value::String(host)) if host.contains(['{', '}']) => {
212                    return Err(invalid("\"host\" contains an unknown placeholder".into()));
213                }
214                _ => return Err(invalid("\"host\" must be a non-empty string".into())),
215            };
216            let port = match port {
217                Some(Value::String(port)) if port == "auto" => 0,
218                Some(Value::String(port)) if port.contains(['{', '}']) => {
219                    return Err(invalid("\"port\" contains an unknown placeholder".into()));
220                }
221                Some(Value::String(port)) => port.parse::<u64>().unwrap_or(u64::MAX),
222                Some(Value::Number(port)) => port.as_u64().unwrap_or(u64::MAX),
223                _ => u64::MAX,
224            };
225            match port {
226                // The server picks its port when it starts, so nothing can be
227                // checked: it is ready once started.
228                0 => return Ok(None),
229                1..=65535 => Probe::Port {
230                    port: port as u16,
231                    host,
232                },
233                _ => {
234                    return Err(invalid(
235                        "\"port\" must be an integer from 0 to 65535, or \"auto\"".into(),
236                    ));
237                }
238            }
239        }
240        "command" => match text("command", Field::Text)? {
241            Some(Value::String(command)) if !command.is_empty() => Probe::Command(command),
242            _ => return Err(invalid("\"command\" must be a non-empty string".into())),
243        },
244        _ => {
245            let templates = match &config["logMatches"] {
246                Value::Array(templates) => templates.clone(),
247                single => vec![single.clone()],
248            };
249            let mut matches = Vec::new();
250            for template in &templates {
251                match template {
252                    Value::String(template) if !template.is_empty() => {
253                        matches.push(render(template, scope, Field::Text).map_err(invalid)?);
254                    }
255                    _ => {
256                        return Err(invalid(
257                            "\"logMatches\" must be a non-empty string or an array of them".into(),
258                        ));
259                    }
260                }
261            }
262            if matches.is_empty() || matches.iter().any(String::is_empty) {
263                return Err(invalid(
264                    "\"logMatches\" must be a non-empty string or an array of them".into(),
265                ));
266            }
267            Probe::LogMatches(matches)
268        }
269    };
270    Ok(Some(ReadyCheck {
271        probe,
272        timeout,
273        interval,
274    }))
275}
276
277/// A `readyWhen` string with its placeholders replaced. Braces that are no
278/// placeholder stay as written.
279fn render(template: &str, scope: &Scope, field: Field) -> std::result::Result<String, String> {
280    let mut result = String::new();
281    let mut index = 0;
282    while let Some(offset) = template[index..].find('{') {
283        let open = index + offset;
284        let resolved = match closing_brace(template, open) {
285            Some(close) => {
286                placeholder(&template[open..=close], scope, field)?.map(|value| (value, close))
287            }
288            None => None,
289        };
290        match resolved {
291            Some((value, close)) => {
292                result.push_str(&template[index..open]);
293                result.push_str(&value);
294                index = close + 1;
295            }
296            None => {
297                result.push_str(&template[index..=open]);
298                index = open + 1;
299            }
300        }
301    }
302    result.push_str(&template[index..]);
303    Ok(result)
304}
305
306/// The value a `{…}` placeholder stands for, or `None` when it is no
307/// placeholder.
308fn placeholder(
309    raw: &str,
310    scope: &Scope,
311    field: Field,
312) -> std::result::Result<Option<String>, String> {
313    let sources = fallbacks(&raw[1..raw.len() - 1]);
314    let read = |source: &str| -> Option<Option<Value>> {
315        if let Some(name) = env_name(source) {
316            return Some(scope.env.get(name).cloned().map(Value::String));
317        }
318        if let Some(path) = source.strip_prefix("options.") {
319            let mut value = Some(&Value::Object(scope.options.clone()));
320            let mut owned = None;
321            for key in path.split('.') {
322                owned = value.and_then(|value| value.get(key)).cloned();
323                value = owned.as_ref();
324            }
325            return Some(owned);
326        }
327        match source {
328            "workspaceRoot" => Some(Some(Value::String(scope.workspace_root.into()))),
329            "projectRoot" => Some(Some(Value::String(scope.project_root.into()))),
330            "projectName" => Some(Some(Value::String(scope.project_name.into()))),
331            _ => None,
332        }
333    };
334    if read(sources[0]).is_none() {
335        return Ok(None);
336    }
337    for (index, source) in sources.iter().enumerate() {
338        let value = match read(source) {
339            Some(value) => value,
340            None if index + 1 == sources.len() => {
341                Some(Value::String(render(source, scope, Field::Text)?))
342            }
343            None => {
344                return Err(format!(
345                    "\"{raw}\" has a fallback after the literal \"{source}\" that can never be used"
346                ));
347            }
348        };
349        let value = match value {
350            Some(Value::String(text)) if text.is_empty() && field == Field::Port => {
351                Value::Number(0.into())
352            }
353            Some(value) => value,
354            None => continue,
355        };
356        let unset = match &value {
357            Value::Null => true,
358            Value::String(text) => {
359                text.is_empty()
360                    || (field != Field::Text && matches!(text.as_str(), "true" | "false"))
361            }
362            Value::Bool(_) => field != Field::Text,
363            _ => false,
364        };
365        if unset {
366            continue;
367        }
368        return match value {
369            Value::String(text) => Ok(Some(text)),
370            Value::Number(number) => Ok(Some(number.to_string())),
371            Value::Bool(flag) => Ok(Some(flag.to_string())),
372            _ => Err(format!("\"{raw}\" is not a string or a number")),
373        };
374    }
375    Err(format!("\"{raw}\" has no value"))
376}
377
378/// Checks until the task is ready, the check times out, or `stop` is set,
379/// and records the conclusion in `verdict`. `logMatches` is decided by the
380/// task's output, so this only times it out.
381pub fn wait(task: &str, check: &ReadyCheck, root: &Path, verdict: &Verdict, stop: &AtomicBool) {
382    let deadline = Instant::now() + check.timeout;
383    loop {
384        if verdict.is_decided() || stop.load(Ordering::SeqCst) {
385            return;
386        }
387        let ready = match &check.probe {
388            Probe::Url(url) => probe_url(url, deadline),
389            Probe::Port { port, host } => probe_port(*port, host.as_deref(), deadline),
390            Probe::Command(command) => probe_command(command, root, deadline, stop),
391            Probe::LogMatches(_) => false,
392        };
393        if ready {
394            verdict.set_ready();
395            return;
396        }
397        let now = Instant::now();
398        if now >= deadline {
399            verdict.fail(format!(
400                "Task \"{task}\" did not become ready within {}ms (readyWhen: {}). To change \
401                 the check, set \"readyWhen\" on the target or in \"targetDefaults\" in nx.json, \
402                 or set it to null to turn it off.",
403                check.timeout.as_millis(),
404                check.describe()
405            ));
406            return;
407        }
408        let pause = if matches!(check.probe, Probe::LogMatches(_)) {
409            deadline - now
410        } else {
411            check.interval.min(deadline - now)
412        };
413        let until = now + pause;
414        while Instant::now() < until {
415            if verdict.is_decided() || stop.load(Ordering::SeqCst) {
416                return;
417            }
418            std::thread::sleep(POLL.min(until.saturating_duration_since(Instant::now())));
419        }
420    }
421}
422
423fn unbracketed(host: &str) -> &str {
424    host.trim_start_matches('[').trim_end_matches(']')
425}
426
427/// A wildcard means loopback of its own family, as Nx probes it.
428fn destination(ip: IpAddr) -> IpAddr {
429    match ip.to_canonical() {
430        IpAddr::V4(v4) if v4.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
431        IpAddr::V6(v6) if v6.is_unspecified() => Ipv6Addr::LOCALHOST.into(),
432        _ => ip,
433    }
434}
435
436fn is_loopback(ip: IpAddr) -> bool {
437    match ip {
438        IpAddr::V4(ip) => ip.is_loopback(),
439        IpAddr::V6(ip) => {
440            ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
441        }
442    }
443}
444
445/// The address webpack-dev-server and the Rspack dev server pick for the
446/// `local-ip` hosts: the machine's own outward address of that family, or
447/// loopback when it has none.
448fn local_ip(v6: bool) -> IpAddr {
449    let (bind, probe): (SocketAddr, SocketAddr) = if v6 {
450        (
451            (Ipv6Addr::UNSPECIFIED, 0).into(),
452            "[2001:4860:4860::8888]:53".parse().unwrap(),
453        )
454    } else {
455        (
456            (Ipv4Addr::UNSPECIFIED, 0).into(),
457            "8.8.8.8:53".parse().unwrap(),
458        )
459    };
460    // Connecting a UDP socket sends nothing; it only picks the route.
461    UdpSocket::bind(bind)
462        .and_then(|socket| socket.connect(probe).and_then(|()| socket.local_addr()))
463        .map(|address| address.ip())
464        .unwrap_or(if v6 {
465            Ipv6Addr::LOCALHOST.into()
466        } else {
467            Ipv4Addr::LOCALHOST.into()
468        })
469}
470
471fn connects(address: SocketAddr, deadline: Instant) -> bool {
472    let left = deadline.saturating_duration_since(Instant::now());
473    !left.is_zero() && TcpStream::connect_timeout(&address, left).is_ok()
474}
475
476/// Without a host, either loopback address accepting is enough: a server
477/// bound to only one of them is still ready.
478fn probe_port(port: u16, host: Option<&str>, deadline: Instant) -> bool {
479    let addresses: Vec<SocketAddr> = match host.map(unbracketed) {
480        None => vec![
481            (Ipv4Addr::LOCALHOST, port).into(),
482            (Ipv6Addr::LOCALHOST, port).into(),
483        ],
484        Some("local-ip") => {
485            let ip = local_ip(false);
486            let ip = if ip.is_loopback() { local_ip(true) } else { ip };
487            vec![(ip, port).into()]
488        }
489        Some("local-ipv4") => vec![(local_ip(false), port).into()],
490        Some("local-ipv6") => vec![(local_ip(true), port).into()],
491        Some(host) => match host.parse::<IpAddr>() {
492            Ok(ip) => vec![(destination(ip), port).into()],
493            Err(_) => match (host, port).to_socket_addrs() {
494                Ok(addresses) => addresses.collect(),
495                Err(_) => return false,
496            },
497        },
498    };
499    addresses
500        .into_iter()
501        .any(|address| connects(address, deadline))
502}
503
504/// The url to request, a wildcard host rewritten to loopback, and whether it
505/// stays on this machine: a loopback address, or a `localhost` name whose
506/// every address is loopback.
507fn target(url: &str) -> Option<(String, bool)> {
508    let uri: ureq::http::Uri = url.parse().ok()?;
509    let host = uri.host()?;
510    let bare = unbracketed(host);
511    if let Ok(ip) = bare.parse::<IpAddr>() {
512        let ip = destination(ip);
513        let written = match ip {
514            IpAddr::V4(v4) => v4.to_string(),
515            IpAddr::V6(v6) => format!("[{v6}]"),
516        };
517        let url = url.replacen(host, &written, 1);
518        return Some((url, is_loopback(ip)));
519    }
520    let name = bare.trim_end_matches('.');
521    let local = (name == "localhost" || name.ends_with(".localhost"))
522        && (bare, uri.port_u16().unwrap_or(80))
523            .to_socket_addrs()
524            .is_ok_and(|addresses| {
525                let addresses: Vec<_> = addresses.collect();
526                !addresses.is_empty() && addresses.iter().all(|address| is_loopback(address.ip()))
527            });
528    Some((url.to_owned(), local))
529}
530
531/// Ready on a status from 200 to 403, a redirect included: an auth challenge
532/// or a forbidden root still means the server is up. A 404 at the root is
533/// tried again at `/index.html`, which may be all a static server answers.
534fn probe_url(url: &str, deadline: Instant) -> bool {
535    let Some((url, local)) = target(url) else {
536        return false;
537    };
538    let status = |url: &str| -> Option<u16> {
539        let left = deadline.saturating_duration_since(Instant::now());
540        if left.is_zero() {
541            return None;
542        }
543        let mut config = ureq::Agent::config_builder()
544            .max_redirects(0)
545            .http_status_as_error(false)
546            .timeout_global(Some(left));
547        if local {
548            // A request to this machine skips proxies and accepts a
549            // self-signed development certificate.
550            config = config.proxy(None).tls_config(
551                ureq::tls::TlsConfig::builder()
552                    .disable_verification(true)
553                    .build(),
554            );
555        }
556        let agent: ureq::Agent = config.build().into();
557        agent
558            .get(url)
559            .call()
560            .ok()
561            .map(|response| response.status().as_u16())
562    };
563    let ready = |status: u16| (200..=403).contains(&status);
564    match status(&url) {
565        Some(404) => {
566            let uri: Option<ureq::http::Uri> = url.parse().ok();
567            if uri.as_ref().is_some_and(|uri| uri.path() == "/") {
568                let index = format!("{}/index.html", url.trim_end_matches('/'));
569                status(&index).is_some_and(ready)
570            } else {
571                false
572            }
573        }
574        Some(found) => ready(found),
575        None => false,
576    }
577}
578
579/// Runs the command through the shell at the workspace root. On timeout or
580/// stop, its whole process group is killed.
581fn probe_command(command: &str, root: &Path, deadline: Instant, stop: &AtomicBool) -> bool {
582    let mut shell = crate::shell(command);
583    shell
584        .current_dir(root)
585        .stdin(Stdio::null())
586        .stdout(Stdio::null())
587        .stderr(Stdio::null());
588    let Ok(mut child) = shell.group_spawn() else {
589        return false;
590    };
591    loop {
592        match child.try_wait() {
593            Ok(Some(status)) => return status.success(),
594            Ok(None) => {}
595            Err(_) => break,
596        }
597        if Instant::now() >= deadline || stop.load(Ordering::SeqCst) {
598            break;
599        }
600        std::thread::sleep(POLL);
601    }
602    let _ = child.kill();
603    let _ = child.wait();
604    false
605}
606
607/// Watches a task's output for `logMatches` text, in any order, on either
608/// stream and across reads. Terminal control sequences are left out, as a
609/// terminal would not show them.
610#[derive(Debug)]
611pub struct LogMatcher {
612    pending: Vec<String>,
613    /// Each stream's visible text, kept as long as the longest pending match.
614    tails: [String; 2],
615    /// Each stream's place in a control sequence a read ended inside.
616    states: [Escape; 2],
617}
618
619#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
620enum Escape {
621    #[default]
622    Text,
623    /// After ESC.
624    Start,
625    /// In a CSI sequence, up to its final byte.
626    Csi,
627    /// In an OSC, DCS, SOS, PM or APC string, up to BEL or ST.
628    Control,
629    /// After ESC inside such a string, which ST ends.
630    ControlEnd,
631}
632
633impl LogMatcher {
634    pub fn new(patterns: Vec<String>) -> Self {
635        Self {
636            pending: patterns,
637            tails: Default::default(),
638            states: Default::default(),
639        }
640    }
641
642    /// Feeds one read; true once every pattern has appeared.
643    pub fn feed(&mut self, stderr: bool, bytes: &[u8]) -> bool {
644        let stream = usize::from(stderr);
645        let text = String::from_utf8_lossy(bytes);
646        let (tail, state) = (&mut self.tails[stream], &mut self.states[stream]);
647        for c in text.chars() {
648            *state = match (*state, c) {
649                (Escape::Text, '\u{1b}') => Escape::Start,
650                (Escape::Text, c) => {
651                    tail.push(c);
652                    Escape::Text
653                }
654                (Escape::Start, '[') => Escape::Csi,
655                (Escape::Start, ']' | 'P' | 'X' | '^' | '_') => Escape::Control,
656                (Escape::Start, _) => Escape::Text,
657                (Escape::Csi, '\u{40}'..='\u{7e}') => Escape::Text,
658                (Escape::Csi, _) => Escape::Csi,
659                (Escape::Control, '\u{7}') => Escape::Text,
660                (Escape::Control, '\u{1b}') => Escape::ControlEnd,
661                (Escape::Control, _) => Escape::Control,
662                (Escape::ControlEnd, '\\') => Escape::Text,
663                (Escape::ControlEnd, _) => Escape::Control,
664            };
665        }
666        self.pending
667            .retain(|pattern| !tail.contains(pattern.as_str()));
668        let keep = self
669            .pending
670            .iter()
671            .map(|pattern| pattern.len().saturating_sub(1))
672            .max()
673            .unwrap_or(0);
674        if tail.len() > keep {
675            let mut start = tail.len() - keep;
676            while !tail.is_char_boundary(start) {
677                start += 1;
678            }
679            tail.drain(..start);
680        }
681        self.pending.is_empty()
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use serde_json::json;
689
690    fn scope<'a>(options: &'a Map<String, Value>, env: &'a BTreeMap<String, String>) -> Scope<'a> {
691        Scope {
692            workspace_root: "/repo",
693            project_root: "apps/app",
694            project_name: "app",
695            options,
696            env,
697        }
698    }
699
700    fn resolved(
701        ready_when: Value,
702        options: Value,
703        env: &[(&str, &str)],
704    ) -> Result<Option<ReadyCheck>> {
705        let options = options.as_object().unwrap().clone();
706        let env = env
707            .iter()
708            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
709            .collect();
710        resolve("app:serve", &ready_when, &scope(&options, &env))
711    }
712
713    #[test]
714    fn placeholders_read_options_env_and_fallbacks() {
715        let check = resolved(
716            json!({"url": "http://localhost:{options.port ?? env.PORT ?? 4200}/{projectName}"}),
717            json!({}),
718            &[("PORT", "5001")],
719        )
720        .unwrap()
721        .unwrap();
722        assert_eq!(check.probe, Probe::Url("http://localhost:5001/app".into()));
723        assert_eq!(check.timeout, Duration::from_millis(60_000));
724        assert_eq!(check.interval, Duration::from_millis(250));
725        let check = resolved(
726            json!({"port": "{options.port}"}),
727            json!({"port": 3000}),
728            &[],
729        )
730        .unwrap()
731        .unwrap();
732        assert_eq!(
733            check.probe,
734            Probe::Port {
735                port: 3000,
736                host: None
737            }
738        );
739        let check = resolved(
740            json!({"logMatches": ["ready on {options.server.port}", "{other}"], "timeout": 5}),
741            json!({"server": {"port": 1}}),
742            &[],
743        )
744        .unwrap()
745        .unwrap();
746        assert_eq!(
747            check.probe,
748            Probe::LogMatches(vec!["ready on 1".into(), "{other}".into()])
749        );
750        assert_eq!(check.timeout, Duration::from_millis(5));
751    }
752
753    #[test]
754    fn a_port_the_server_picks_leaves_nothing_to_check() {
755        for port in [json!(0), json!("auto"), json!("{options.port}")] {
756            let options = json!({"port": ""});
757            assert_eq!(resolved(json!({"port": port}), options, &[]).unwrap(), None);
758        }
759    }
760
761    #[test]
762    fn a_flag_without_a_value_leaves_host_and_port_unset() {
763        let check = resolved(
764            json!({"port": "{options.port ?? 4200}", "host": "{options.host ?? env.HOST}"}),
765            json!({"port": "true", "host": "true"}),
766            &[],
767        );
768        assert!(format!("{:#}", check.unwrap_err()).contains("has no value"));
769        let check = resolved(
770            json!({"port": "{options.port ?? 4200}"}),
771            json!({"port": true}),
772            &[],
773        )
774        .unwrap()
775        .unwrap();
776        assert_eq!(
777            check.probe,
778            Probe::Port {
779                port: 4200,
780                host: None
781            }
782        );
783    }
784
785    #[test]
786    fn invalid_settings_say_why() {
787        for (ready_when, reason) in [
788            (json!({}), "exactly one of"),
789            (json!({"url": "a", "port": 1}), "exactly one of"),
790            (json!({"url": "ftp://x"}), "http or https"),
791            (json!({"port": 70000}), "from 0 to 65535"),
792            (json!({"port": "{options.missing}"}), "has no value"),
793            (json!({"command": ""}), "non-empty"),
794            (json!({"logMatches": []}), "logMatches"),
795            (json!({"port": 1, "timeout": 0}), "\"timeout\""),
796            (json!({"port": 1, "other": 0}), "unknown key"),
797            (
798                json!({"url": "http://{options.a ?? x ?? options.b}"}),
799                "can never be used",
800            ),
801        ] {
802            let error = resolved(ready_when.clone(), json!({}), &[]).unwrap_err();
803            assert!(
804                format!("{error:#}").contains(reason),
805                "{ready_when}: {error:#}"
806            );
807        }
808    }
809
810    #[test]
811    fn log_matcher_needs_every_pattern_without_control_sequences() {
812        let mut matcher = LogMatcher::new(vec!["ready".into(), "listening".into()]);
813        assert!(!matcher.feed(false, b"server listening on 3000"));
814        assert!(matcher.feed(true, b"\x1b[32mrea\x1b[0mdy"));
815        let mut matcher = LogMatcher::new(vec!["ready".into()]);
816        assert!(!matcher.feed(false, b"rea\x1b["));
817        assert!(matcher.feed(false, b"32mdy"));
818        let mut matcher = LogMatcher::new(vec!["ready".into()]);
819        assert!(!matcher.feed(false, b"\x1b]0;ready\x07"));
820        assert!(matcher.feed(false, b"rea\x1b]8;;http://x\x1b\\dy"));
821        let mut matcher = LogMatcher::new(vec!["éready".into()]);
822        assert!(!matcher.feed(false, "éééa".as_bytes()));
823        assert!(matcher.feed(false, "éready".as_bytes()));
824    }
825
826    #[test]
827    fn port_and_command_probes_wait_for_readiness() {
828        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
829        let port = listener.local_addr().unwrap().port();
830        let stop = AtomicBool::new(false);
831        for host in [None, Some("127.0.0.1"), Some("0.0.0.0")] {
832            let verdict = Verdict::default();
833            let check = ReadyCheck {
834                probe: Probe::Port {
835                    port,
836                    host: host.map(str::to_owned),
837                },
838                timeout: Duration::from_secs(5),
839                interval: Duration::from_millis(10),
840            };
841            wait("app:serve", &check, Path::new("."), &verdict, &stop);
842            assert!(verdict.is_ready(), "{host:?}");
843        }
844        drop(listener);
845        let verdict = Verdict::default();
846        let check = ReadyCheck {
847            probe: Probe::Command("exit 1".into()),
848            timeout: Duration::from_millis(100),
849            interval: Duration::from_millis(10),
850        };
851        wait("app:serve", &check, Path::new("."), &verdict, &stop);
852        assert!(
853            verdict
854                .failure()
855                .unwrap()
856                .contains("did not become ready within 100ms")
857        );
858        let verdict = Verdict::default();
859        let check = ReadyCheck {
860            probe: Probe::Command("exit 0".into()),
861            ..check
862        };
863        wait("app:serve", &check, Path::new("."), &verdict, &stop);
864        assert!(verdict.is_ready());
865    }
866
867    #[test]
868    fn url_probes_take_statuses_up_to_403_and_try_index_html() {
869        use std::io::{Read, Write};
870        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
871        let port = listener.local_addr().unwrap().port();
872        std::thread::spawn(move || {
873            for mut stream in listener.incoming().flatten() {
874                let mut request = Vec::new();
875                let mut byte = [0u8; 1];
876                while stream.read(&mut byte).is_ok_and(|n| n == 1) {
877                    request.push(byte[0]);
878                    if request.ends_with(b"\r\n\r\n") {
879                        break;
880                    }
881                }
882                let request = String::from_utf8_lossy(&request);
883                let status = if request.starts_with("GET /index.html") {
884                    "200 OK"
885                } else if request.starts_with("GET /missing") || request.starts_with("GET / ") {
886                    "404 Not Found"
887                } else {
888                    "302 Found\r\nlocation: /elsewhere"
889                };
890                let _ = stream.write_all(
891                    format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
892                );
893            }
894        });
895        let deadline = Instant::now() + Duration::from_secs(5);
896        assert!(probe_url(&format!("http://127.0.0.1:{port}/"), deadline));
897        assert!(probe_url(
898            &format!("http://0.0.0.0:{port}/redirects"),
899            deadline
900        ));
901        assert!(!probe_url(
902            &format!("http://127.0.0.1:{port}/missing"),
903            deadline
904        ));
905    }
906}