browser_control/cli/
wait_for_cookie.rs1use 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
68pub(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
90pub(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 assert!(!cookie_matches(&cookie("example.com", "session_id"), &d, &n));
130 assert!(!cookie_matches(&cookie("other.test", "session"), &d, &n));
132 assert!(cookie_matches(&cookie("example.com", "session"), &d, &n));
134 }
135
136 #[test]
137 fn cookie_matches_anchored_regex() {
138 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 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}