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    /// A 502 PoolResponseUnparseable carries retryable:false (the pool may already have created the
470    /// session): one attempt, and the code reaches the caller.
471    #[tokio::test]
472    async fn pool_response_unparseable_502_is_not_retried() {
473        let (l, port) = bind_mock().await;
474        let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
475        let captured = serve(
476            l,
477            vec![
478                (
479                    502,
480                    r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#.to_string(),
481                ),
482                (201, ok.to_string()),
483            ],
484        );
485
486        let client = client_for(port);
487        let err = client
488            .sessions()
489            .create(CreateSessionOptions::default())
490            .await
491            .unwrap_err();
492
493        assert_eq!(err.status(), Some(502));
494        assert_eq!(captured.lock().unwrap().len(), 1, "retryable:false must not be retried");
495    }
496
497    /// Retries are exhausted after max_attempts → the last response, with its status and code.
498    #[tokio::test]
499    async fn retries_exhausted_on_persistent_503() {
500        let (l, port) = bind_mock().await;
501        let captured = serve(
502            l,
503            vec![
504                (503, "{}".to_string()),
505                (503, "{}".to_string()),
506                (201, "{}".to_string()), // never reached: max_attempts is 2
507            ],
508        );
509
510        let client = client_for(port);
511        let err = client
512            .sessions()
513            .create(CreateSessionOptions::default())
514            .await
515            .unwrap_err();
516
517        assert!(matches!(err, SolariError::Api { .. }), "{err:?}");
518        assert_eq!(err.status(), Some(503));
519        assert_eq!(captured.lock().unwrap().len(), 2);
520    }
521
522    /// A 503 with a code that outlasts the retries keeps that code.
523    #[tokio::test]
524    async fn final_5xx_keeps_its_code() {
525        let (l, port) = bind_mock().await;
526        let captured = serve(
527            l,
528            vec![
529                (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
530                (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
531            ],
532        );
533
534        let client = client_for(port);
535        let err = client
536            .sessions()
537            .create(CreateSessionOptions::default())
538            .await
539            .unwrap_err();
540
541        assert_eq!(err.status(), Some(503));
542        assert_eq!(err.code(), Some(&SolariErrorCode::BrowserUnhealthy));
543        assert_eq!(captured.lock().unwrap().len(), 2);
544    }
545
546    /// A transport failure that outlasts the retries is still the exhausted Transport error.
547    #[tokio::test]
548    async fn transport_failure_exhausts_as_transport_error() {
549        let (l, port) = bind_mock().await;
550        drop(l); // nothing listens on this port any more
551        let client = client_for(port);
552        let err = client
553            .sessions()
554            .create(CreateSessionOptions::default())
555            .await
556            .unwrap_err();
557
558        assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
559        assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
560    }
561
562    /// 400 is NOT retried — it comes straight back on the first attempt.
563    #[tokio::test]
564    async fn does_not_retry_on_400() {
565        let (l, port) = bind_mock().await;
566        let captured = serve(
567            l,
568            vec![
569                (400, r#"{"error":"bad proxy country"}"#.to_string()),
570                (201, "{}".to_string()), // must NOT be consumed
571            ],
572        );
573
574        let client = client_for(port);
575        let err = client
576            .sessions()
577            .create(CreateSessionOptions::default())
578            .await
579            .unwrap_err();
580
581        assert_eq!(err.status(), Some(400));
582        assert!(err.to_string().contains("bad proxy country"), "{err}");
583        // Exactly one request was made.
584        assert_eq!(captured.lock().unwrap().len(), 1);
585    }
586
587    /// `{code}` is parsed out of error bodies into the typed enum; unknown codes
588    /// survive as `Other`.
589    #[tokio::test]
590    async fn parses_error_codes() {
591        let (l, port) = bind_mock().await;
592        serve(
593            l,
594            vec![
595                (
596                    402,
597                    r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
598                        .to_string(),
599                ),
600                (
601                    429,
602                    r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
603                ),
604                (400, r#"{"code":"SomethingNew"}"#.to_string()),
605                (400, "not json at all".to_string()),
606            ],
607        );
608
609        let client = client_for(port);
610
611        let err = client
612            .sessions()
613            .create(CreateSessionOptions::new().captcha(true))
614            .await
615            .unwrap_err();
616        assert_eq!(err.status(), Some(402));
617        assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
618        assert!(err.to_string().contains("captcha requires a paid plan"));
619
620        let err = client
621            .sessions()
622            .create(CreateSessionOptions::default())
623            .await
624            .unwrap_err();
625        assert_eq!(err.status(), Some(429));
626        assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
627
628        // Unknown code → preserved verbatim, not dropped.
629        let err = client
630            .sessions()
631            .create(CreateSessionOptions::default())
632            .await
633            .unwrap_err();
634        assert_eq!(
635            err.code(),
636            Some(&SolariErrorCode::Other("SomethingNew".to_string()))
637        );
638
639        // Non-JSON body → no code, but still a typed API error with the status.
640        let err = client
641            .sessions()
642            .create(CreateSessionOptions::default())
643            .await
644            .unwrap_err();
645        assert_eq!(err.status(), Some(400));
646        assert_eq!(err.code(), None);
647    }
648
649    /// GET /profiles parses the list, including unknown keys via `extra`.
650    #[tokio::test]
651    async fn profiles_list_parses() {
652        let (l, port) = bind_mock().await;
653        let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
654        let captured = serve(l, vec![(200, body.to_string())]);
655
656        let client = client_for(port);
657        let profiles = client.profiles().list().await.unwrap();
658
659        assert_eq!(profiles.len(), 2);
660        assert_eq!(profiles[0].id, "p_1");
661        assert_eq!(profiles[0].name, "amazon");
662        // Index-signature keys survive.
663        assert_eq!(
664            profiles[0].extra.get("createdAt").unwrap(),
665            "2026-01-01T00:00:00Z"
666        );
667        assert_eq!(profiles[1].id, "p_2");
668        assert!(profiles[1].extra.is_empty());
669
670        let (head, _) = split_req(&captured.lock().unwrap()[0]);
671        assert!(head.starts_with("GET /profiles "), "head: {head}");
672    }
673
674    /// POST /profiles sends `{name}` and parses the created profile.
675    #[tokio::test]
676    async fn profiles_create_and_save() {
677        let (l, port) = bind_mock().await;
678        let captured = serve(
679            l,
680            vec![
681                (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
682                (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
683                (200, "{}".to_string()), // save with defaults
684            ],
685        );
686
687        let client = client_for(port);
688        let profile = client.profiles().create("shopify").await.unwrap();
689        assert_eq!(profile.id, "p_9");
690        assert_eq!(profile.name, "shopify");
691
692        let state = StorageState {
693            cookies: Some(vec![Cookie {
694                name: "sid".into(),
695                value: "abc".into(),
696                domain: Some(".shopify.com".into()),
697                same_site: Some(SameSite::Lax),
698                ..Default::default()
699            }]),
700            ..Default::default()
701        };
702        let saved = client.profiles().save("p_9", &state).await.unwrap();
703        assert_eq!(saved.version, 3);
704        assert_eq!(saved.size_bytes, 2048);
705
706        // Missing fields default to 0.
707        let saved = client.profiles().save("p_9", &state).await.unwrap();
708        assert_eq!(saved.version, 0);
709        assert_eq!(saved.size_bytes, 0);
710
711        let reqs = captured.lock().unwrap().clone();
712        let (head, body) = split_req(&reqs[0]);
713        assert!(head.starts_with("POST /profiles "), "head: {head}");
714        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
715        assert_eq!(json.get("name").unwrap(), "shopify");
716
717        // save wraps the state under `storageState` and keeps camelCase inside.
718        let (head, body) = split_req(&reqs[1]);
719        assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
720        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
721        let cookie = &json["storageState"]["cookies"][0];
722        assert_eq!(cookie.get("name").unwrap(), "sid");
723        assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
724        assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
725        // Unset cookie fields are omitted, not sent as null.
726        assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
727    }
728
729    /// DELETE tolerates 404 on both sessions and profiles.
730    #[tokio::test]
731    async fn delete_tolerates_404() {
732        let (l, port) = bind_mock().await;
733        serve(
734            l,
735            vec![
736                (404, r#"{"error":"Not Found"}"#.to_string()),
737                (404, r#"{"error":"Not Found"}"#.to_string()),
738                (500, r#"{"error":"boom"}"#.to_string()),
739            ],
740        );
741
742        let client = client_for(port);
743        client.sessions().release("s_gone").await.unwrap();
744        client.profiles().delete("p_gone").await.unwrap();
745        // But a real failure still propagates.
746        let err = client.sessions().release("s_bad").await.unwrap_err();
747        assert_eq!(err.status(), Some(500));
748    }
749
750    /// replay-url defaults: expiresInSeconds → 0, contentEncoding → "gzip".
751    #[tokio::test]
752    async fn replay_url_defaults() {
753        let (l, port) = bind_mock().await;
754        let captured = serve(
755            l,
756            vec![
757                (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
758                (
759                    200,
760                    r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
761                        .to_string(),
762                ),
763                (200, r#"{"expiresInSeconds":600}"#.to_string()),
764            ],
765        );
766
767        let client = client_for(port);
768
769        let replay = client.sessions().replay_url("s_1").await.unwrap();
770        assert_eq!(replay.url, "https://s3.example.com/r1");
771        assert_eq!(replay.expires_in_seconds, 0);
772        assert_eq!(replay.content_encoding, "gzip");
773
774        let replay = client.sessions().replay_url("s_2").await.unwrap();
775        assert_eq!(replay.expires_in_seconds, 600);
776        assert_eq!(replay.content_encoding, "identity");
777
778        // A body with no url is a protocol error, not a silent empty string.
779        let err = client.sessions().replay_url("s_3").await.unwrap_err();
780        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
781
782        let (head, _) = split_req(&captured.lock().unwrap()[0]);
783        assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
784    }
785
786    /// GET /proxy/countries.
787    #[tokio::test]
788    async fn proxy_countries() {
789        let (l, port) = bind_mock().await;
790        let captured = serve(
791            l,
792            vec![(
793                200,
794                r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
795            )],
796        );
797
798        let client = client_for(port);
799        let res = client.proxy().countries().await.unwrap();
800        assert!(res.enabled);
801        assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
802
803        let (head, _) = split_req(&captured.lock().unwrap()[0]);
804        assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
805    }
806
807    /// GET /sessions/:id.
808    #[tokio::test]
809    async fn session_get_parses() {
810        let (l, port) = bind_mock().await;
811        serve(
812            l,
813            vec![(
814                200,
815                r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
816                    .to_string(),
817            )],
818        );
819
820        let client = client_for(port);
821        let view = client.sessions().get("s_1").await.unwrap();
822        assert_eq!(view.session_id.as_deref(), Some("s_1"));
823        assert_eq!(view.status.as_deref(), Some("busy"));
824        assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
825        assert_eq!(view.extra.get("slot").unwrap(), 3);
826    }
827
828    /// cdpEndpoint is derived from wsEndpoint when the API omits it.
829    #[test]
830    fn cdp_derivation() {
831        assert_eq!(
832            derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
833            "wss://api.getsolari.com/cdp/abc.def"
834        );
835        assert_eq!(
836            derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
837            "ws://127.0.0.1:3000/cdp/s_1?token=x"
838        );
839        // Non-/ws/ paths pass through untouched.
840        assert_eq!(
841            derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
842            "wss://api.getsolari.com/other/abc"
843        );
844        // Unparseable input passes through untouched.
845        assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
846    }
847
848    /// A create response without cdpEndpoint derives one from wsEndpoint.
849    #[tokio::test]
850    async fn create_session_derives_cdp() {
851        let (l, port) = bind_mock().await;
852        let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
853        serve(l, vec![(201, body.to_string())]);
854
855        let client = client_for(port);
856        let session = client
857            .sessions()
858            .create(CreateSessionOptions::default())
859            .await
860            .unwrap();
861
862        assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
863        assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
864        // expiresAt was omitted → a ~1h ISO-8601 UTC fallback was synthesized.
865        assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
866        assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
867    }
868
869    /// A malformed create response (no sessionId) is a protocol error.
870    #[tokio::test]
871    async fn create_session_rejects_bad_response() {
872        let (l, port) = bind_mock().await;
873        serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
874
875        let client = client_for(port);
876        let err = client
877            .sessions()
878            .create(CreateSessionOptions::default())
879            .await
880            .unwrap_err();
881        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
882    }
883
884    /// With a profileId, `storageStateUrl` is followed to the presigned object.
885    #[tokio::test]
886    async fn storage_state_fetched_from_presigned_url() {
887        let (l, port) = bind_mock().await;
888        let create = format!(
889            r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
890        );
891        let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
892        serve(l, vec![(201, create), (200, state.to_string())]);
893
894        let client = client_for(port);
895        let session = client
896            .sessions()
897            .create(CreateSessionOptions::new().profile_id("p_1"))
898            .await
899            .unwrap();
900
901        let state = session
902            .storage_state
903            .expect("profile attached → Some")
904            .expect("presigned state → Some");
905        let cookies = state.cookies.unwrap();
906        assert_eq!(cookies[0].name, "sid");
907        assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
908        let origins = state.origins.unwrap();
909        assert_eq!(origins[0].origin, "https://example.com");
910        assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
911    }
912
913    /// The three storage-state states are distinguished: absent vs empty vs set.
914    #[tokio::test]
915    async fn storage_state_absent_vs_empty() {
916        let (l, port) = bind_mock().await;
917        serve(
918            l,
919            vec![
920                // profileId requested, storageStateUrl.url is null → empty profile.
921                (
922                    201,
923                    r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
924                        .to_string(),
925                ),
926                // profileId requested, no storageStateUrl at all → empty profile.
927                (
928                    201,
929                    r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
930                ),
931                // profileId requested, legacy inline storageState → populated.
932                (
933                    201,
934                    r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
935                        .to_string(),
936                ),
937                // No profileId → absent entirely.
938                (
939                    201,
940                    r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
941                ),
942            ],
943        );
944
945        let client = client_for(port);
946
947        let s = client
948            .sessions()
949            .create(CreateSessionOptions::new().profile_id("p_1"))
950            .await
951            .unwrap();
952        assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
953
954        let s = client
955            .sessions()
956            .create(CreateSessionOptions::new().profile_id("p_1"))
957            .await
958            .unwrap();
959        assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
960
961        let s = client
962            .sessions()
963            .create(CreateSessionOptions::new().profile_id("p_1"))
964            .await
965            .unwrap();
966        let inline = s.storage_state.expect("Some").expect("Some");
967        assert_eq!(inline.cookies.unwrap().len(), 0);
968
969        let s = client
970            .sessions()
971            .create(CreateSessionOptions::default())
972            .await
973            .unwrap();
974        assert!(s.storage_state.is_none(), "no profile → None");
975    }
976
977    /// Client construction: defaults, validation, region resolution.
978    #[test]
979    fn client_options_and_defaults() {
980        let opts = ClientOptions::default_region("k");
981        assert_eq!(opts.base_url, "https://api.getsolari.com");
982        assert_eq!(opts.max_attempts, 2);
983        assert_eq!(opts.backoff_ms, 500);
984        assert_eq!(opts.timeout_ms, 90_000);
985
986        assert_eq!(
987            ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
988            "https://api.getsolari.com"
989        );
990
991        // Trailing slashes are stripped.
992        let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
993        assert_eq!(c.base_url(), "https://gw.example.com");
994
995        // An empty api key is rejected.
996        let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
997        assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
998    }
999
1000    /// The ISO-8601 fallback formatter. Expectations cross-checked against
1001    /// `date -u -d @<ts>`.
1002    #[test]
1003    fn iso8601_formatting() {
1004        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1005        assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1006        assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1007        // 2024-02-29 (leap day) 12:00:00Z.
1008        assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1009        // 2000 is a leap year (divisible by 400) — the century special case.
1010        assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1011        // 2100 is NOT a leap year (divisible by 100, not 400).
1012        assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1013        // Pre-epoch must floor, not truncate toward zero.
1014        assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1015    }
1016
1017    /// Error codes round-trip through their wire strings.
1018    #[test]
1019    fn error_code_round_trip() {
1020        for code in [
1021            SolariErrorCode::FeatureRequiresPlan,
1022            SolariErrorCode::ConcurrencyLimitExceeded,
1023            SolariErrorCode::PlanLimitExceeded,
1024            SolariErrorCode::BrowserUnhealthy,
1025        ] {
1026            assert_eq!(SolariErrorCode::from(code.as_str()), code);
1027        }
1028        assert_eq!(
1029            SolariErrorCode::from("BrowserUnhealthy"),
1030            SolariErrorCode::BrowserUnhealthy
1031        );
1032        assert_eq!(
1033            SolariErrorCode::Other("Nope".into()).to_string(),
1034            "Nope".to_string()
1035        );
1036    }
1037
1038    // Regression: launch()'s retry loop must retry create() itself on a
1039    // transient failure, not only the connect+probe step after it. A bare
1040    // `?` on create() short-circuits launch() before opts.retries/is_transient
1041    // are ever consulted -- found in review (Feature 3), proved with this
1042    // harness before the fix landed.
1043    #[cfg(feature = "connect")]
1044    #[tokio::test]
1045    async fn launch_retries_create_itself_on_transient_exhaustion() {
1046        let (l, port) = bind_mock().await;
1047        let captured = serve(
1048            l,
1049            vec![
1050                // First create(): exhausts max_attempts(2) on 503 -> Transport
1051                // error, which is_transient says yes to.
1052                (503, "{}".to_string()),
1053                (503, "{}".to_string()),
1054                // Second create() (the retry launch() must make): a single
1055                // non-transient 400 ends the loop deterministically, so the
1056                // request count alone proves the retry fired.
1057                (400, r#"{"error":"bad request"}"#.to_string()),
1058            ],
1059        );
1060
1061        let client = client_for(port);
1062        let result = client
1063            .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1064            .await;
1065        let Err(err) = result else {
1066            panic!("expected second create()'s 400 to end the loop, got Ok");
1067        };
1068
1069        assert_eq!(err.status(), Some(400));
1070        // 2 (exhausted first create) + 1 (second create) == 3. If the bare
1071        // `?` regression were present, launch() would return on the first
1072        // create()'s Transport error and this would be 2.
1073        assert_eq!(captured.lock().unwrap().len(), 3);
1074    }
1075}