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