Skip to main content

browser_control/cli/
wait_for_cookie.rs

1//! `browser-control wait-for-cookie` — block until a cookie appears.
2//!
3//! v1 strategy: **polling only**. The plan envisions an event-driven path via
4//! CDP `Network.responseReceived` / BiDi `network.responseCompleted`, but for
5//! v1 simplicity we poll `Network.getAllCookies` / `storage.getCookies` at a
6//! fixed interval until the matching cookie appears or the timeout elapses.
7//!
8//! After a match, an optional `--validate-url` performs a `fetch()` from the
9//! page context (credentials included) and requires a 2xx status.
10
11use std::time::{Duration, Instant};
12
13use anyhow::{bail, Context, Result};
14use regex::Regex;
15use serde_json::Value;
16use tokio::time::sleep;
17
18use crate::cli::cookies::{fetch_cookies, NormalCookie};
19use crate::cli::mcp::resolve_browser;
20use crate::dom::scripts::FETCH_JS;
21use crate::session::PageSession;
22
23pub async fn run(
24    browser: Option<String>,
25    domain: String,
26    name: String,
27    timeout: u64,
28    poll_interval: u64,
29    validate_url: Option<String>,
30) -> Result<()> {
31    let domain_re = Regex::new(&domain).context("invalid --domain regex")?;
32    let name_re = Regex::new(&name).context("invalid --name regex")?;
33
34    let resolved = resolve_browser(browser).await?;
35
36    let deadline = Instant::now() + Duration::from_secs(timeout);
37    let interval = Duration::from_secs(poll_interval.max(1));
38
39    let matched = loop {
40        let cookies = fetch_cookies(&resolved).await?;
41        if let Some(c) = cookies.into_iter().find(|c| cookie_matches(c, &domain_re, &name_re)) {
42            break c;
43        }
44        if Instant::now() >= deadline {
45            bail!("timed out waiting for cookie");
46        }
47        let remaining = deadline.saturating_duration_since(Instant::now());
48        let nap = std::cmp::min(interval, remaining);
49        if nap.is_zero() {
50            bail!("timed out waiting for cookie");
51        }
52        sleep(nap).await;
53    };
54
55    eprintln!("cookie {} appeared on {}", matched.name, matched.domain);
56
57    if let Some(url) = validate_url {
58        let session = PageSession::attach(&resolved.endpoint, resolved.engine, None).await?;
59        let result = validate_via_page(&session, &url).await;
60        session.close().await;
61        result?;
62    }
63
64    println!("{}", matched.name);
65    Ok(())
66}
67
68/// Returns true when both regexes match the cookie's domain and name. Both
69/// regexes are unanchored (`Regex::is_match` semantics).
70pub(crate) fn cookie_matches(c: &NormalCookie, domain_re: &Regex, name_re: &Regex) -> bool {
71    domain_re.is_match(&c.domain) && name_re.is_match(&c.name)
72}
73
74async fn validate_via_page(session: &PageSession, url: &str) -> Result<()> {
75    let args = serde_json::json!({ "url": url, "method": "GET" }).to_string();
76    let expr = format!("({})({})", FETCH_JS, serde_json::to_string(&args).unwrap());
77    let value = session.evaluate(&expr, true).await?;
78    let json_str = value
79        .as_str()
80        .ok_or_else(|| anyhow::anyhow!("validate-url: page returned non-string from fetch script"))?;
81    let parsed: Value = serde_json::from_str(json_str)
82        .context("validate-url: failed to parse fetch response envelope")?;
83    let status = parsed
84        .get("status")
85        .and_then(|v| v.as_i64())
86        .ok_or_else(|| anyhow::anyhow!("validate-url: missing `status` in fetch response"))?;
87    validate_status(status)
88}
89
90/// Require a 2xx status; otherwise produce an error.
91pub(crate) fn validate_status(status: i64) -> Result<()> {
92    if (200..=299).contains(&status) {
93        Ok(())
94    } else {
95        bail!("validate-url failed: status {status}");
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn cookie(domain: &str, name: &str) -> NormalCookie {
104        NormalCookie {
105            domain: domain.to_string(),
106            name: name.to_string(),
107            value: "v".to_string(),
108            path: "/".to_string(),
109            secure: false,
110            http_only: false,
111            same_site: None,
112            expires: None,
113        }
114    }
115
116    #[test]
117    fn cookie_matches_unanchored_domain_and_name() {
118        let d = Regex::new(r"example\.com").unwrap();
119        let n = Regex::new(r"session").unwrap();
120        assert!(cookie_matches(&cookie("www.example.com", "session_id"), &d, &n));
121        assert!(cookie_matches(&cookie(".example.com", "my_session"), &d, &n));
122    }
123
124    #[test]
125    fn cookie_matches_requires_both() {
126        let d = Regex::new(r"example\.com").unwrap();
127        let n = Regex::new(r"^session$").unwrap();
128        // wrong name
129        assert!(!cookie_matches(&cookie("example.com", "session_id"), &d, &n));
130        // wrong domain
131        assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
132        // both ok
133        assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
134    }
135
136    #[test]
137    fn cookie_matches_anchored_regex() {
138        // `^csrf$` strictly matches the literal name `csrf`.
139        let d = Regex::new(r".*").unwrap();
140        let n = Regex::new(r"^csrf$").unwrap();
141        assert!(cookie_matches(&cookie("a.test", "csrf"), &d, &n));
142        assert!(!cookie_matches(&cookie("a.test", "csrf_token"), &d, &n));
143    }
144
145    #[test]
146    fn validate_status_2xx_passes() {
147        assert!(validate_status(200).is_ok());
148        assert!(validate_status(204).is_ok());
149        assert!(validate_status(299).is_ok());
150    }
151
152    #[test]
153    fn validate_status_non_2xx_fails() {
154        assert!(validate_status(199).is_err());
155        assert!(validate_status(300).is_err());
156        assert!(validate_status(404).is_err());
157        assert!(validate_status(500).is_err());
158        let err = validate_status(403).unwrap_err().to_string();
159        assert!(err.contains("403"), "error should mention status: {err}");
160    }
161
162    /// Pure poll-loop helper mirroring `run`'s timing logic, parameterised
163    /// over a synchronous fetch closure so it can be tested without a browser.
164    async fn wait_loop<F>(
165        mut fetch: F,
166        domain_re: &Regex,
167        name_re: &Regex,
168        timeout: Duration,
169        interval: Duration,
170    ) -> Result<NormalCookie>
171    where
172        F: FnMut() -> Vec<NormalCookie>,
173    {
174        let deadline = Instant::now() + timeout;
175        loop {
176            let cookies = fetch();
177            if let Some(c) = cookies.into_iter().find(|c| cookie_matches(c, domain_re, name_re)) {
178                return Ok(c);
179            }
180            if Instant::now() >= deadline {
181                bail!("timed out waiting for cookie");
182            }
183            let remaining = deadline.saturating_duration_since(Instant::now());
184            let nap = std::cmp::min(interval, remaining);
185            if nap.is_zero() {
186                bail!("timed out waiting for cookie");
187            }
188            sleep(nap).await;
189        }
190    }
191
192    #[tokio::test(start_paused = true)]
193    async fn wait_loop_times_out_when_cookie_never_appears() {
194        let d = Regex::new(r"example\.com").unwrap();
195        let n = Regex::new(r"^sid$").unwrap();
196        let err = wait_loop(
197            Vec::new,
198            &d,
199            &n,
200            Duration::from_secs(3),
201            Duration::from_secs(1),
202        )
203        .await
204        .unwrap_err();
205        assert!(err.to_string().contains("timed out"));
206    }
207
208    #[tokio::test(start_paused = true)]
209    async fn wait_loop_returns_first_match() {
210        let d = Regex::new(r"example\.com").unwrap();
211        let n = Regex::new(r"^sid$").unwrap();
212        let mut calls = 0;
213        let fetch = move || {
214            calls += 1;
215            if calls >= 2 {
216                vec![cookie("www.example.com", "sid")]
217            } else {
218                vec![cookie("www.example.com", "other")]
219            }
220        };
221        let got = wait_loop(fetch, &d, &n, Duration::from_secs(10), Duration::from_secs(1))
222            .await
223            .unwrap();
224        assert_eq!(got.name, "sid");
225        assert_eq!(got.domain, "www.example.com");
226    }
227}