Skip to main content

solari_browser/
lib.rs

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