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