Skip to main content

solari_browser/
lib.rs

1//! # solari-browser
2//!
3//! Rust binding for the **Solari Browser** platform — the control plane:
4//! acquire and release pooled stealth Chromium sessions, manage stored browser
5//! profiles, pull session replays, and inspect managed-proxy egress. It speaks
6//! the same wire contract as the reference `@solarisdk/browser` TypeScript
7//! package (`sdk/src/index.ts`).
8//!
9//! ```no_run
10//! use solari_browser::{Client, ClientOptions, CreateSessionOptions, ProxySpec};
11//!
12//! # async fn ex() -> Result<(), solari_browser::SolariError> {
13//! let client = Client::new(ClientOptions::default_region("slr_live_…"))?;
14//!
15//! let session = client
16//!     .sessions()
17//!     .create(
18//!         CreateSessionOptions::new()
19//!             .stealth(true)
20//!             .proxy(ProxySpec::country("us")),
21//!     )
22//!     .await?;
23//!
24//! println!("ws  {}", session.ws_endpoint);   // Playwright wire protocol
25//! println!("cdp {}", session.cdp_endpoint);  // raw CDP — use this from Rust
26//!
27//! client.sessions().release(&session.id).await?;
28//! # Ok(()) }
29//! ```
30//!
31//! ## Scope
32//!
33//! This crate is the **control plane plus a `connect()` helper**. There is no
34//! port of the TypeScript `Solari.launch()`, which hands back a live Playwright
35//! `Browser` — Playwright has no Rust binding. To drive a session from Rust,
36//! enable the `connect` feature and attach to [`Session::cdp_endpoint`] with
37//! [`chromiumoxide`](https://docs.rs/chromiumoxide):
38//!
39//! ```toml
40//! solari-browser = { path = "sdk/rust", features = ["connect"] }
41//! ```
42//!
43//! ## Endpoints vs. the TypeScript SDK
44//!
45//! The TS SDK routes `wsEndpoint` / `cdpEndpoint` through a Node-side loopback
46//! `LocalProxy`. This crate has no such indirection: [`Session::ws_endpoint`]
47//! and [`Session::cdp_endpoint`] are the **upstream** gateway endpoints.
48
49mod client;
50mod error;
51mod http;
52mod types;
53
54#[cfg(feature = "connect")]
55mod connect;
56
57pub use client::{Client, ClientOptions, CreateSessionOptions, Profiles, Proxy, Sessions};
58pub use error::{ApiErrorBody, SolariError, SolariErrorCode};
59pub use types::{
60    Cookie, LocalStorageEntry, Profile, ProfileSaveResult, ProxyCountries, ProxyRequest, ProxySpec,
61    ProxyTier, ReplayUrl, ResolvedProxyConfig, SameSite, Session, SessionView, SolariRegion,
62    StorageOrigin, StorageState,
63};
64
65#[cfg(feature = "connect")]
66pub use connect::{connect, connect_endpoint, ConnectedBrowser};
67
68#[cfg(test)]
69mod tests {
70    use super::client::{derive_cdp_from_ws, iso8601_utc};
71    use super::*;
72    use std::sync::{Arc, Mutex};
73    use tokio::io::{AsyncReadExt, AsyncWriteExt};
74    use tokio::net::TcpListener;
75
76    // -----------------------------------------------------------------------
77    // Mock HTTP server: serves a scripted list of responses, one per accepted
78    // connection, and captures every raw request it saw.
79    // -----------------------------------------------------------------------
80
81    async fn bind_mock() -> (TcpListener, u16) {
82        let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
83        let port = l.local_addr().unwrap().port();
84        (l, port)
85    }
86
87    /// Serve `responses` in order — one per connection — capturing raw requests.
88    fn serve(listener: TcpListener, responses: Vec<(u16, String)>) -> Arc<Mutex<Vec<String>>> {
89        let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
90        let cap = captured.clone();
91        tokio::spawn(async move {
92            for (status, body) in responses {
93                let Ok((mut sock, _)) = listener.accept().await else {
94                    return;
95                };
96                // Read headers, then the Content-Length body.
97                let mut buf = Vec::new();
98                let mut tmp = [0u8; 1024];
99                while let Ok(n) = sock.read(&mut tmp).await {
100                    if n == 0 {
101                        break; // peer closed before we saw a full request
102                    }
103                    buf.extend_from_slice(&tmp[..n]);
104                    let Some(pos) = find_subslice(&buf, b"\r\n\r\n") else {
105                        continue; // headers still incomplete
106                    };
107                    let head = String::from_utf8_lossy(&buf[..pos]).to_string();
108                    let clen = content_length(&head).unwrap_or(0);
109                    let body_start = pos + 4;
110                    while buf.len() < body_start + clen {
111                        match sock.read(&mut tmp).await {
112                            Ok(0) | Err(_) => break,
113                            Ok(n) => buf.extend_from_slice(&tmp[..n]),
114                        }
115                    }
116                    break;
117                }
118                cap.lock()
119                    .unwrap()
120                    .push(String::from_utf8_lossy(&buf).to_string());
121
122                let reason = match status {
123                    200 => "OK",
124                    201 => "Created",
125                    204 => "No Content",
126                    400 => "Bad Request",
127                    402 => "Payment Required",
128                    404 => "Not Found",
129                    429 => "Too Many Requests",
130                    503 => "Service Unavailable",
131                    _ => "Status",
132                };
133                let resp = format!(
134                    "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
135                    body.len(),
136                    body
137                );
138                let _ = sock.write_all(resp.as_bytes()).await;
139                let _ = sock.flush().await;
140            }
141        });
142        captured
143    }
144
145    fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
146        haystack.windows(needle.len()).position(|w| w == needle)
147    }
148
149    fn content_length(head: &str) -> Option<usize> {
150        for line in head.lines() {
151            if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
152                return v.trim().parse().ok();
153            }
154        }
155        None
156    }
157
158    fn split_req(raw: &str) -> (String, String) {
159        match raw.split_once("\r\n\r\n") {
160            Some((h, b)) => (h.to_string(), b.to_string()),
161            None => (raw.to_string(), String::new()),
162        }
163    }
164
165    fn client_for(port: u16) -> Client {
166        Client::new(
167            ClientOptions::new("slr_live_id_secret", format!("http://127.0.0.1:{port}"))
168                .backoff_ms(0),
169        )
170        .unwrap()
171    }
172
173    // -----------------------------------------------------------------------
174    // Tests
175    // -----------------------------------------------------------------------
176
177    // A BARE 404 on release means "already gone" and is success. A 404 the
178    // gateway marked InvalidSessionId means it REFUSED the id and released
179    // nothing — the pool slot is still held until orphan-grace, so reporting
180    // success is what made these releases leak slots silently.
181
182    #[tokio::test]
183    async fn release_tolerates_a_bare_404() {
184        let (l, port) = bind_mock().await;
185        let _ = serve(l, vec![(404, r#"{"error":"Not Found"}"#.to_string())]);
186        let client = client_for(port);
187        assert!(client.sessions().release("s_1").await.is_ok());
188    }
189
190    #[tokio::test]
191    async fn release_tolerates_a_404_with_an_unrelated_code() {
192        let (l, port) = bind_mock().await;
193        let _ = serve(
194            l,
195            vec![(404, r#"{"error":"Not Found","code":"SomethingElse"}"#.to_string())],
196        );
197        let client = client_for(port);
198        assert!(client.sessions().release("s_1").await.is_ok());
199    }
200
201    #[tokio::test]
202    async fn release_fails_on_404_invalid_session_id() {
203        let (l, port) = bind_mock().await;
204        let _ = serve(
205            l,
206            vec![(
207                404,
208                r#"{"error":"Not Found","code":"InvalidSessionId"}"#.to_string(),
209            )],
210        );
211        let client = client_for(port);
212        let err = client
213            .sessions()
214            .release("s_1")
215            .await
216            .expect_err("a refused session id released nothing and must not look like success");
217        assert_eq!(err.status(), Some(404));
218        assert_eq!(err.code(), Some(&SolariErrorCode::InvalidSessionId));
219    }
220
221    #[tokio::test]
222    async fn invalid_session_id_round_trips_through_the_wire_string() {
223        assert_eq!(
224            SolariErrorCode::from("InvalidSessionId"),
225            SolariErrorCode::InvalidSessionId
226        );
227        assert_eq!(SolariErrorCode::InvalidSessionId.as_str(), "InvalidSessionId");
228    }
229
230    /// POST /sessions request shape: method/path, auth + content-type headers,
231    /// only the truthy options serialized, everything else omitted.
232    #[tokio::test]
233    async fn create_session_request_shape() {
234        let (l, port) = bind_mock().await;
235        let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","cdpEndpoint":"wss://gw.example.com/cdp/s_1","expiresAt":"2026-01-01T00:00:00Z"}"#;
236        let captured = serve(l, vec![(201, body.to_string())]);
237
238        let client = client_for(port);
239        let session = client
240            .sessions()
241            .create(
242                CreateSessionOptions::new()
243                    .stealth(true)
244                    .proxy(ProxySpec::country("us")),
245            )
246            .await
247            .unwrap();
248
249        assert_eq!(session.id, "s_1");
250        assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
251        assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
252        assert_eq!(session.expires_at, "2026-01-01T00:00:00Z");
253        // No profile requested → storage state stays absent (not "empty").
254        assert!(session.storage_state.is_none());
255        assert!(session.proxy.is_none());
256
257        let raw = captured.lock().unwrap()[0].clone();
258        let (head, body) = split_req(&raw);
259        assert!(head.starts_with("POST /sessions "), "head: {head}");
260        let lower = head.to_lowercase();
261        assert!(
262            lower.contains("authorization: bearer slr_live_id_secret"),
263            "head: {head}"
264        );
265        assert!(lower.contains("content-type: application/json"), "head: {head}");
266
267        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
268        assert_eq!(json.get("stealth").unwrap(), true);
269        assert_eq!(json.get("proxy").unwrap(), "us");
270        // Falsy / unset options are omitted entirely.
271        assert!(json.get("recording").is_none(), "body: {body}");
272        assert!(json.get("captcha").is_none(), "body: {body}");
273        assert!(json.get("profileId").is_none(), "body: {body}");
274    }
275
276    /// With no options at all, the body is omitted entirely (not `{}`).
277    #[tokio::test]
278    async fn create_session_omits_empty_body() {
279        let (l, port) = bind_mock().await;
280        let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
281        let captured = serve(l, vec![(201, body.to_string())]);
282
283        let client = client_for(port);
284        client
285            .sessions()
286            .create(CreateSessionOptions::default())
287            .await
288            .unwrap();
289
290        let raw = captured.lock().unwrap()[0].clone();
291        let (head, body) = split_req(&raw);
292        assert!(body.trim().is_empty(), "expected no body, got: {body}");
293        assert!(
294            !head.to_lowercase().contains("content-length: 2"),
295            "head: {head}"
296        );
297    }
298
299    /// A full ProxyRequest serializes camelCase, with unset fields omitted.
300    #[tokio::test]
301    async fn create_session_proxy_request_shape() {
302        let (l, port) = bind_mock().await;
303        let body = r#"{"sessionId":"s_3","wsEndpoint":"wss://gw.example.com/ws/s_3","proxy":{"server":"http://p:1","username":"u","password":"p","timezoneId":"America/Los_Angeles","country":"us","tier":"mobile"}}"#;
304        let captured = serve(l, vec![(201, body.to_string())]);
305
306        let client = client_for(port);
307        let session = client
308            .sessions()
309            .create(CreateSessionOptions::new().stealth(true).proxy(
310                ProxyRequest::country("us")
311                    .tier(ProxyTier::Mobile)
312                    .session("warm-1")
313                    .session_duration(15),
314            ))
315            .await
316            .unwrap();
317
318        let proxy = session.proxy.expect("resolved proxy");
319        assert_eq!(proxy.country, "us");
320        assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
321        assert_eq!(proxy.timezone_id, "America/Los_Angeles");
322
323        let raw = captured.lock().unwrap()[0].clone();
324        let (_, body) = split_req(&raw);
325        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
326        let p = json.get("proxy").unwrap();
327        assert_eq!(p.get("country").unwrap(), "us");
328        assert_eq!(p.get("tier").unwrap(), "mobile");
329        assert_eq!(p.get("session").unwrap(), "warm-1");
330        assert_eq!(p.get("sessionDuration").unwrap(), 15);
331        assert!(p.get("asn").is_none());
332        assert!(p.get("city").is_none());
333    }
334
335    /// The gateway marks `404 ReplayPending` retryable: the recording upload is
336    /// still in flight. An idempotent GET must honour that hint even though 404
337    /// is nowhere near the 5xx allowlist.
338    #[tokio::test]
339    async fn retryable_hint_retries_idempotent_get() {
340        let (l, port) = bind_mock().await;
341        let pending =
342            r#"{"error":"replay still uploading","code":"ReplayPending","retryable":true}"#;
343        let ok = r#"{"url":"https://s3/replay.ndjson.gz"}"#;
344        let captured = serve(
345            l,
346            vec![(404, pending.to_string()), (200, ok.to_string())],
347        );
348
349        let client = client_for(port);
350        let link = client.sessions().replay_url("sess-1").await.unwrap();
351
352        assert_eq!(link.url, "https://s3/replay.ndjson.gz");
353        // Two attempts: the hint is what produced the second.
354        assert_eq!(captured.lock().unwrap().len(), 2);
355    }
356
357    /// A 404 WITHOUT the hint is terminal -- the FLAG changes the outcome, not
358    /// the status.
359    #[tokio::test]
360    async fn no_hint_means_no_retry() {
361        let (l, port) = bind_mock().await;
362        let terminal = r#"{"error":"no replay","code":"ReplayUnavailable"}"#;
363        let captured = serve(
364            l,
365            vec![
366                (404, terminal.to_string()),
367                (200, r#"{"url":"never reached"}"#.to_string()),
368            ],
369        );
370
371        let client = client_for(port);
372        assert!(client.sessions().replay_url("sess-1").await.is_err());
373        assert_eq!(captured.lock().unwrap().len(), 1);
374    }
375
376    /// RETRACTED REASON, kept deliberately. This asserted the opposite, because
377    /// "the browser API issues no Idempotency-Key, so a re-sent create could
378    /// leave a second live session behind". Creates now mint a key, so the
379    /// gateway answers the retry from the first result instead of creating
380    /// twice -- the condition was removed, not worked around.
381    ///
382    /// This is the case the gateway-side guard produces: a duplicate arriving
383    /// while the first create is still running is answered 409 + retryable.
384    #[tokio::test]
385    async fn retryable_hint_honoured_on_keyed_create() {
386        let (l, port) = bind_mock().await;
387        let captured = serve(
388            l,
389            vec![
390                (409, r#"{"error":"in progress","retryable":true}"#.to_string()),
391                (201, r#"{"sessionId":"s_ok","wsEndpoint":"wss://x/ws/s_ok"}"#.to_string()),
392            ],
393        );
394
395        let client = client_for(port);
396        assert!(client
397            .sessions()
398            .create(CreateSessionOptions::default())
399            .await
400            .is_ok());
401        let reqs = captured.lock().unwrap();
402        assert_eq!(reqs.len(), 2, "the hint must be honoured on a keyed create");
403
404        // The key identifies the CALL, not the attempt: a fresh key per retry
405        // would silently defeat the mechanism (the gateway would see two
406        // distinct keys and create twice) while every assertion above still
407        // passes. Only comparing the two captured headers catches that.
408        fn idempotency_key(head: &str) -> Option<String> {
409            for line in head.lines() {
410                if let Some(v) = line.to_lowercase().strip_prefix("idempotency-key:") {
411                    return Some(v.trim().to_string());
412                }
413            }
414            None
415        }
416        let (head1, _) = split_req(&reqs[0]);
417        let (head2, _) = split_req(&reqs[1]);
418        let key1 = idempotency_key(&head1).expect("first attempt must carry a key");
419        let key2 = idempotency_key(&head2).expect("second attempt must carry a key");
420        assert_eq!(key1, key2, "retries must reuse the SAME key, not mint a fresh one");
421    }
422
423    /// A key identifies the CALL: two separate creates must not share one.
424    #[tokio::test]
425    async fn idempotency_keys_differ_between_calls() {
426        assert_ne!(
427            crate::http::new_idempotency_key(),
428            crate::http::new_idempotency_key()
429        );
430    }
431
432    /// 503 is retried up to max_attempts and can succeed on the second try.
433    #[tokio::test]
434    async fn retries_on_503() {
435        let (l, port) = bind_mock().await;
436        let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
437        let captured = serve(
438            l,
439            vec![
440                (503, r#"{"error":"no capacity"}"#.to_string()),
441                (201, ok.to_string()),
442            ],
443        );
444
445        let client = client_for(port);
446        let session = client
447            .sessions()
448            .create(CreateSessionOptions::default())
449            .await
450            .unwrap();
451
452        assert_eq!(session.id, "s_ok");
453        // Two attempts were actually made.
454        assert_eq!(captured.lock().unwrap().len(), 2);
455    }
456
457    /// Retries are exhausted after max_attempts → a Transport error.
458    #[tokio::test]
459    async fn retries_exhausted_on_persistent_503() {
460        let (l, port) = bind_mock().await;
461        let captured = serve(
462            l,
463            vec![
464                (503, "{}".to_string()),
465                (503, "{}".to_string()),
466                (201, "{}".to_string()), // never reached: max_attempts is 2
467            ],
468        );
469
470        let client = client_for(port);
471        let err = client
472            .sessions()
473            .create(CreateSessionOptions::default())
474            .await
475            .unwrap_err();
476
477        assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
478        assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
479        assert_eq!(captured.lock().unwrap().len(), 2);
480    }
481
482    /// 400 is NOT retried — it comes straight back on the first attempt.
483    #[tokio::test]
484    async fn does_not_retry_on_400() {
485        let (l, port) = bind_mock().await;
486        let captured = serve(
487            l,
488            vec![
489                (400, r#"{"error":"bad proxy country"}"#.to_string()),
490                (201, "{}".to_string()), // must NOT be consumed
491            ],
492        );
493
494        let client = client_for(port);
495        let err = client
496            .sessions()
497            .create(CreateSessionOptions::default())
498            .await
499            .unwrap_err();
500
501        assert_eq!(err.status(), Some(400));
502        assert!(err.to_string().contains("bad proxy country"), "{err}");
503        // Exactly one request was made.
504        assert_eq!(captured.lock().unwrap().len(), 1);
505    }
506
507    /// `{code}` is parsed out of error bodies into the typed enum; unknown codes
508    /// survive as `Other`.
509    #[tokio::test]
510    async fn parses_error_codes() {
511        let (l, port) = bind_mock().await;
512        serve(
513            l,
514            vec![
515                (
516                    402,
517                    r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
518                        .to_string(),
519                ),
520                (
521                    429,
522                    r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
523                ),
524                (400, r#"{"code":"SomethingNew"}"#.to_string()),
525                (400, "not json at all".to_string()),
526            ],
527        );
528
529        let client = client_for(port);
530
531        let err = client
532            .sessions()
533            .create(CreateSessionOptions::new().captcha(true))
534            .await
535            .unwrap_err();
536        assert_eq!(err.status(), Some(402));
537        assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
538        assert!(err.to_string().contains("captcha requires a paid plan"));
539
540        let err = client
541            .sessions()
542            .create(CreateSessionOptions::default())
543            .await
544            .unwrap_err();
545        assert_eq!(err.status(), Some(429));
546        assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
547
548        // Unknown code → preserved verbatim, not dropped.
549        let err = client
550            .sessions()
551            .create(CreateSessionOptions::default())
552            .await
553            .unwrap_err();
554        assert_eq!(
555            err.code(),
556            Some(&SolariErrorCode::Other("SomethingNew".to_string()))
557        );
558
559        // Non-JSON body → no code, but still a typed API error with the status.
560        let err = client
561            .sessions()
562            .create(CreateSessionOptions::default())
563            .await
564            .unwrap_err();
565        assert_eq!(err.status(), Some(400));
566        assert_eq!(err.code(), None);
567    }
568
569    /// GET /profiles parses the list, including unknown keys via `extra`.
570    #[tokio::test]
571    async fn profiles_list_parses() {
572        let (l, port) = bind_mock().await;
573        let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
574        let captured = serve(l, vec![(200, body.to_string())]);
575
576        let client = client_for(port);
577        let profiles = client.profiles().list().await.unwrap();
578
579        assert_eq!(profiles.len(), 2);
580        assert_eq!(profiles[0].id, "p_1");
581        assert_eq!(profiles[0].name, "amazon");
582        // Index-signature keys survive.
583        assert_eq!(
584            profiles[0].extra.get("createdAt").unwrap(),
585            "2026-01-01T00:00:00Z"
586        );
587        assert_eq!(profiles[1].id, "p_2");
588        assert!(profiles[1].extra.is_empty());
589
590        let (head, _) = split_req(&captured.lock().unwrap()[0]);
591        assert!(head.starts_with("GET /profiles "), "head: {head}");
592    }
593
594    /// POST /profiles sends `{name}` and parses the created profile.
595    #[tokio::test]
596    async fn profiles_create_and_save() {
597        let (l, port) = bind_mock().await;
598        let captured = serve(
599            l,
600            vec![
601                (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
602                (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
603                (200, "{}".to_string()), // save with defaults
604            ],
605        );
606
607        let client = client_for(port);
608        let profile = client.profiles().create("shopify").await.unwrap();
609        assert_eq!(profile.id, "p_9");
610        assert_eq!(profile.name, "shopify");
611
612        let state = StorageState {
613            cookies: Some(vec![Cookie {
614                name: "sid".into(),
615                value: "abc".into(),
616                domain: Some(".shopify.com".into()),
617                same_site: Some(SameSite::Lax),
618                ..Default::default()
619            }]),
620            ..Default::default()
621        };
622        let saved = client.profiles().save("p_9", &state).await.unwrap();
623        assert_eq!(saved.version, 3);
624        assert_eq!(saved.size_bytes, 2048);
625
626        // Missing fields default to 0.
627        let saved = client.profiles().save("p_9", &state).await.unwrap();
628        assert_eq!(saved.version, 0);
629        assert_eq!(saved.size_bytes, 0);
630
631        let reqs = captured.lock().unwrap().clone();
632        let (head, body) = split_req(&reqs[0]);
633        assert!(head.starts_with("POST /profiles "), "head: {head}");
634        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
635        assert_eq!(json.get("name").unwrap(), "shopify");
636
637        // save wraps the state under `storageState` and keeps camelCase inside.
638        let (head, body) = split_req(&reqs[1]);
639        assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
640        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
641        let cookie = &json["storageState"]["cookies"][0];
642        assert_eq!(cookie.get("name").unwrap(), "sid");
643        assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
644        assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
645        // Unset cookie fields are omitted, not sent as null.
646        assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
647    }
648
649    /// DELETE tolerates 404 on both sessions and profiles.
650    #[tokio::test]
651    async fn delete_tolerates_404() {
652        let (l, port) = bind_mock().await;
653        serve(
654            l,
655            vec![
656                (404, r#"{"error":"Not Found"}"#.to_string()),
657                (404, r#"{"error":"Not Found"}"#.to_string()),
658                (500, r#"{"error":"boom"}"#.to_string()),
659            ],
660        );
661
662        let client = client_for(port);
663        client.sessions().release("s_gone").await.unwrap();
664        client.profiles().delete("p_gone").await.unwrap();
665        // But a real failure still propagates.
666        let err = client.sessions().release("s_bad").await.unwrap_err();
667        assert_eq!(err.status(), Some(500));
668    }
669
670    /// replay-url defaults: expiresInSeconds → 0, contentEncoding → "gzip".
671    #[tokio::test]
672    async fn replay_url_defaults() {
673        let (l, port) = bind_mock().await;
674        let captured = serve(
675            l,
676            vec![
677                (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
678                (
679                    200,
680                    r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
681                        .to_string(),
682                ),
683                (200, r#"{"expiresInSeconds":600}"#.to_string()),
684            ],
685        );
686
687        let client = client_for(port);
688
689        let replay = client.sessions().replay_url("s_1").await.unwrap();
690        assert_eq!(replay.url, "https://s3.example.com/r1");
691        assert_eq!(replay.expires_in_seconds, 0);
692        assert_eq!(replay.content_encoding, "gzip");
693
694        let replay = client.sessions().replay_url("s_2").await.unwrap();
695        assert_eq!(replay.expires_in_seconds, 600);
696        assert_eq!(replay.content_encoding, "identity");
697
698        // A body with no url is a protocol error, not a silent empty string.
699        let err = client.sessions().replay_url("s_3").await.unwrap_err();
700        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
701
702        let (head, _) = split_req(&captured.lock().unwrap()[0]);
703        assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
704    }
705
706    /// GET /proxy/countries.
707    #[tokio::test]
708    async fn proxy_countries() {
709        let (l, port) = bind_mock().await;
710        let captured = serve(
711            l,
712            vec![(
713                200,
714                r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
715            )],
716        );
717
718        let client = client_for(port);
719        let res = client.proxy().countries().await.unwrap();
720        assert!(res.enabled);
721        assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
722
723        let (head, _) = split_req(&captured.lock().unwrap()[0]);
724        assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
725    }
726
727    /// GET /sessions/:id.
728    #[tokio::test]
729    async fn session_get_parses() {
730        let (l, port) = bind_mock().await;
731        serve(
732            l,
733            vec![(
734                200,
735                r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
736                    .to_string(),
737            )],
738        );
739
740        let client = client_for(port);
741        let view = client.sessions().get("s_1").await.unwrap();
742        assert_eq!(view.session_id.as_deref(), Some("s_1"));
743        assert_eq!(view.status.as_deref(), Some("busy"));
744        assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
745        assert_eq!(view.extra.get("slot").unwrap(), 3);
746    }
747
748    /// cdpEndpoint is derived from wsEndpoint when the API omits it.
749    #[test]
750    fn cdp_derivation() {
751        assert_eq!(
752            derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
753            "wss://api.getsolari.com/cdp/abc.def"
754        );
755        assert_eq!(
756            derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
757            "ws://127.0.0.1:3000/cdp/s_1?token=x"
758        );
759        // Non-/ws/ paths pass through untouched.
760        assert_eq!(
761            derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
762            "wss://api.getsolari.com/other/abc"
763        );
764        // Unparseable input passes through untouched.
765        assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
766    }
767
768    /// A create response without cdpEndpoint derives one from wsEndpoint.
769    #[tokio::test]
770    async fn create_session_derives_cdp() {
771        let (l, port) = bind_mock().await;
772        let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
773        serve(l, vec![(201, body.to_string())]);
774
775        let client = client_for(port);
776        let session = client
777            .sessions()
778            .create(CreateSessionOptions::default())
779            .await
780            .unwrap();
781
782        assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
783        assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
784        // expiresAt was omitted → a ~1h ISO-8601 UTC fallback was synthesized.
785        assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
786        assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
787    }
788
789    /// A malformed create response (no sessionId) is a protocol error.
790    #[tokio::test]
791    async fn create_session_rejects_bad_response() {
792        let (l, port) = bind_mock().await;
793        serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
794
795        let client = client_for(port);
796        let err = client
797            .sessions()
798            .create(CreateSessionOptions::default())
799            .await
800            .unwrap_err();
801        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
802    }
803
804    /// With a profileId, `storageStateUrl` is followed to the presigned object.
805    #[tokio::test]
806    async fn storage_state_fetched_from_presigned_url() {
807        let (l, port) = bind_mock().await;
808        let create = format!(
809            r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
810        );
811        let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
812        serve(l, vec![(201, create), (200, state.to_string())]);
813
814        let client = client_for(port);
815        let session = client
816            .sessions()
817            .create(CreateSessionOptions::new().profile_id("p_1"))
818            .await
819            .unwrap();
820
821        let state = session
822            .storage_state
823            .expect("profile attached → Some")
824            .expect("presigned state → Some");
825        let cookies = state.cookies.unwrap();
826        assert_eq!(cookies[0].name, "sid");
827        assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
828        let origins = state.origins.unwrap();
829        assert_eq!(origins[0].origin, "https://example.com");
830        assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
831    }
832
833    /// The three storage-state states are distinguished: absent vs empty vs set.
834    #[tokio::test]
835    async fn storage_state_absent_vs_empty() {
836        let (l, port) = bind_mock().await;
837        serve(
838            l,
839            vec![
840                // profileId requested, storageStateUrl.url is null → empty profile.
841                (
842                    201,
843                    r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
844                        .to_string(),
845                ),
846                // profileId requested, no storageStateUrl at all → empty profile.
847                (
848                    201,
849                    r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
850                ),
851                // profileId requested, legacy inline storageState → populated.
852                (
853                    201,
854                    r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
855                        .to_string(),
856                ),
857                // No profileId → absent entirely.
858                (
859                    201,
860                    r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
861                ),
862            ],
863        );
864
865        let client = client_for(port);
866
867        let s = client
868            .sessions()
869            .create(CreateSessionOptions::new().profile_id("p_1"))
870            .await
871            .unwrap();
872        assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
873
874        let s = client
875            .sessions()
876            .create(CreateSessionOptions::new().profile_id("p_1"))
877            .await
878            .unwrap();
879        assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
880
881        let s = client
882            .sessions()
883            .create(CreateSessionOptions::new().profile_id("p_1"))
884            .await
885            .unwrap();
886        let inline = s.storage_state.expect("Some").expect("Some");
887        assert_eq!(inline.cookies.unwrap().len(), 0);
888
889        let s = client
890            .sessions()
891            .create(CreateSessionOptions::default())
892            .await
893            .unwrap();
894        assert!(s.storage_state.is_none(), "no profile → None");
895    }
896
897    /// Client construction: defaults, validation, region resolution.
898    #[test]
899    fn client_options_and_defaults() {
900        let opts = ClientOptions::default_region("k");
901        assert_eq!(opts.base_url, "https://api.getsolari.com");
902        assert_eq!(opts.max_attempts, 2);
903        assert_eq!(opts.backoff_ms, 500);
904        assert_eq!(opts.timeout_ms, 90_000);
905
906        assert_eq!(
907            ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
908            "https://api.getsolari.com"
909        );
910
911        // Trailing slashes are stripped.
912        let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
913        assert_eq!(c.base_url(), "https://gw.example.com");
914
915        // An empty api key is rejected.
916        let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
917        assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
918    }
919
920    /// The ISO-8601 fallback formatter. Expectations cross-checked against
921    /// `date -u -d @<ts>`.
922    #[test]
923    fn iso8601_formatting() {
924        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
925        assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
926        assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
927        // 2024-02-29 (leap day) 12:00:00Z.
928        assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
929        // 2000 is a leap year (divisible by 400) — the century special case.
930        assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
931        // 2100 is NOT a leap year (divisible by 100, not 400).
932        assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
933        // Pre-epoch must floor, not truncate toward zero.
934        assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
935    }
936
937    /// Error codes round-trip through their wire strings.
938    #[test]
939    fn error_code_round_trip() {
940        for code in [
941            SolariErrorCode::FeatureRequiresPlan,
942            SolariErrorCode::ConcurrencyLimitExceeded,
943            SolariErrorCode::PlanLimitExceeded,
944            SolariErrorCode::BrowserUnhealthy,
945        ] {
946            assert_eq!(SolariErrorCode::from(code.as_str()), code);
947        }
948        assert_eq!(
949            SolariErrorCode::from("BrowserUnhealthy"),
950            SolariErrorCode::BrowserUnhealthy
951        );
952        assert_eq!(
953            SolariErrorCode::Other("Nope".into()).to_string(),
954            "Nope".to_string()
955        );
956    }
957}