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("profileId").is_none(), "body: {body}");
221    }
222
223    /// With no options at all, the body is omitted entirely (not `{}`).
224    #[tokio::test]
225    async fn create_session_omits_empty_body() {
226        let (l, port) = bind_mock().await;
227        let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
228        let captured = serve(l, vec![(201, body.to_string())]);
229
230        let client = client_for(port);
231        client
232            .sessions()
233            .create(CreateSessionOptions::default())
234            .await
235            .unwrap();
236
237        let raw = captured.lock().unwrap()[0].clone();
238        let (head, body) = split_req(&raw);
239        assert!(body.trim().is_empty(), "expected no body, got: {body}");
240        assert!(
241            !head.to_lowercase().contains("content-length: 2"),
242            "head: {head}"
243        );
244    }
245
246    /// A full ProxyRequest serializes camelCase, with unset fields omitted.
247    #[tokio::test]
248    async fn create_session_proxy_request_shape() {
249        let (l, port) = bind_mock().await;
250        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"}}"#;
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(CreateSessionOptions::new().stealth(true).proxy(
257                ProxyRequest::country("us")
258                    .tier(ProxyTier::Mobile)
259                    .session("warm-1")
260                    .session_duration(15),
261            ))
262            .await
263            .unwrap();
264
265        let proxy = session.proxy.expect("resolved proxy");
266        assert_eq!(proxy.country, "us");
267        assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
268        assert_eq!(proxy.timezone_id, "America/Los_Angeles");
269
270        let raw = captured.lock().unwrap()[0].clone();
271        let (_, body) = split_req(&raw);
272        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
273        let p = json.get("proxy").unwrap();
274        assert_eq!(p.get("country").unwrap(), "us");
275        assert_eq!(p.get("tier").unwrap(), "mobile");
276        assert_eq!(p.get("session").unwrap(), "warm-1");
277        assert_eq!(p.get("sessionDuration").unwrap(), 15);
278        assert!(p.get("asn").is_none());
279        assert!(p.get("city").is_none());
280    }
281
282    /// 503 is retried up to max_attempts and can succeed on the second try.
283    #[tokio::test]
284    async fn retries_on_503() {
285        let (l, port) = bind_mock().await;
286        let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
287        let captured = serve(
288            l,
289            vec![
290                (503, r#"{"error":"no capacity"}"#.to_string()),
291                (201, ok.to_string()),
292            ],
293        );
294
295        let client = client_for(port);
296        let session = client
297            .sessions()
298            .create(CreateSessionOptions::default())
299            .await
300            .unwrap();
301
302        assert_eq!(session.id, "s_ok");
303        // Two attempts were actually made.
304        assert_eq!(captured.lock().unwrap().len(), 2);
305    }
306
307    /// Retries are exhausted after max_attempts → a Transport error.
308    #[tokio::test]
309    async fn retries_exhausted_on_persistent_503() {
310        let (l, port) = bind_mock().await;
311        let captured = serve(
312            l,
313            vec![
314                (503, "{}".to_string()),
315                (503, "{}".to_string()),
316                (201, "{}".to_string()), // never reached: max_attempts is 2
317            ],
318        );
319
320        let client = client_for(port);
321        let err = client
322            .sessions()
323            .create(CreateSessionOptions::default())
324            .await
325            .unwrap_err();
326
327        assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
328        assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
329        assert_eq!(captured.lock().unwrap().len(), 2);
330    }
331
332    /// 400 is NOT retried — it comes straight back on the first attempt.
333    #[tokio::test]
334    async fn does_not_retry_on_400() {
335        let (l, port) = bind_mock().await;
336        let captured = serve(
337            l,
338            vec![
339                (400, r#"{"error":"bad proxy country"}"#.to_string()),
340                (201, "{}".to_string()), // must NOT be consumed
341            ],
342        );
343
344        let client = client_for(port);
345        let err = client
346            .sessions()
347            .create(CreateSessionOptions::default())
348            .await
349            .unwrap_err();
350
351        assert_eq!(err.status(), Some(400));
352        assert!(err.to_string().contains("bad proxy country"), "{err}");
353        // Exactly one request was made.
354        assert_eq!(captured.lock().unwrap().len(), 1);
355    }
356
357    /// `{code}` is parsed out of error bodies into the typed enum; unknown codes
358    /// survive as `Other`.
359    #[tokio::test]
360    async fn parses_error_codes() {
361        let (l, port) = bind_mock().await;
362        serve(
363            l,
364            vec![
365                (
366                    402,
367                    r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
368                        .to_string(),
369                ),
370                (
371                    429,
372                    r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
373                ),
374                (400, r#"{"code":"SomethingNew"}"#.to_string()),
375                (400, "not json at all".to_string()),
376            ],
377        );
378
379        let client = client_for(port);
380
381        let err = client
382            .sessions()
383            .create(CreateSessionOptions::new().captcha(true))
384            .await
385            .unwrap_err();
386        assert_eq!(err.status(), Some(402));
387        assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
388        assert!(err.to_string().contains("captcha requires a paid plan"));
389
390        let err = client
391            .sessions()
392            .create(CreateSessionOptions::default())
393            .await
394            .unwrap_err();
395        assert_eq!(err.status(), Some(429));
396        assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
397
398        // Unknown code → preserved verbatim, not dropped.
399        let err = client
400            .sessions()
401            .create(CreateSessionOptions::default())
402            .await
403            .unwrap_err();
404        assert_eq!(
405            err.code(),
406            Some(&SolariErrorCode::Other("SomethingNew".to_string()))
407        );
408
409        // Non-JSON body → no code, but still a typed API error with the status.
410        let err = client
411            .sessions()
412            .create(CreateSessionOptions::default())
413            .await
414            .unwrap_err();
415        assert_eq!(err.status(), Some(400));
416        assert_eq!(err.code(), None);
417    }
418
419    /// GET /profiles parses the list, including unknown keys via `extra`.
420    #[tokio::test]
421    async fn profiles_list_parses() {
422        let (l, port) = bind_mock().await;
423        let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
424        let captured = serve(l, vec![(200, body.to_string())]);
425
426        let client = client_for(port);
427        let profiles = client.profiles().list().await.unwrap();
428
429        assert_eq!(profiles.len(), 2);
430        assert_eq!(profiles[0].id, "p_1");
431        assert_eq!(profiles[0].name, "amazon");
432        // Index-signature keys survive.
433        assert_eq!(
434            profiles[0].extra.get("createdAt").unwrap(),
435            "2026-01-01T00:00:00Z"
436        );
437        assert_eq!(profiles[1].id, "p_2");
438        assert!(profiles[1].extra.is_empty());
439
440        let (head, _) = split_req(&captured.lock().unwrap()[0]);
441        assert!(head.starts_with("GET /profiles "), "head: {head}");
442    }
443
444    /// POST /profiles sends `{name}` and parses the created profile.
445    #[tokio::test]
446    async fn profiles_create_and_save() {
447        let (l, port) = bind_mock().await;
448        let captured = serve(
449            l,
450            vec![
451                (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
452                (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
453                (200, "{}".to_string()), // save with defaults
454            ],
455        );
456
457        let client = client_for(port);
458        let profile = client.profiles().create("shopify").await.unwrap();
459        assert_eq!(profile.id, "p_9");
460        assert_eq!(profile.name, "shopify");
461
462        let state = StorageState {
463            cookies: Some(vec![Cookie {
464                name: "sid".into(),
465                value: "abc".into(),
466                domain: Some(".shopify.com".into()),
467                same_site: Some(SameSite::Lax),
468                ..Default::default()
469            }]),
470            ..Default::default()
471        };
472        let saved = client.profiles().save("p_9", &state).await.unwrap();
473        assert_eq!(saved.version, 3);
474        assert_eq!(saved.size_bytes, 2048);
475
476        // Missing fields default to 0.
477        let saved = client.profiles().save("p_9", &state).await.unwrap();
478        assert_eq!(saved.version, 0);
479        assert_eq!(saved.size_bytes, 0);
480
481        let reqs = captured.lock().unwrap().clone();
482        let (head, body) = split_req(&reqs[0]);
483        assert!(head.starts_with("POST /profiles "), "head: {head}");
484        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
485        assert_eq!(json.get("name").unwrap(), "shopify");
486
487        // save wraps the state under `storageState` and keeps camelCase inside.
488        let (head, body) = split_req(&reqs[1]);
489        assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
490        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
491        let cookie = &json["storageState"]["cookies"][0];
492        assert_eq!(cookie.get("name").unwrap(), "sid");
493        assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
494        assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
495        // Unset cookie fields are omitted, not sent as null.
496        assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
497    }
498
499    /// DELETE tolerates 404 on both sessions and profiles.
500    #[tokio::test]
501    async fn delete_tolerates_404() {
502        let (l, port) = bind_mock().await;
503        serve(
504            l,
505            vec![
506                (404, r#"{"error":"Not Found"}"#.to_string()),
507                (404, r#"{"error":"Not Found"}"#.to_string()),
508                (500, r#"{"error":"boom"}"#.to_string()),
509            ],
510        );
511
512        let client = client_for(port);
513        client.sessions().release("s_gone").await.unwrap();
514        client.profiles().delete("p_gone").await.unwrap();
515        // But a real failure still propagates.
516        let err = client.sessions().release("s_bad").await.unwrap_err();
517        assert_eq!(err.status(), Some(500));
518    }
519
520    /// replay-url defaults: expiresInSeconds → 0, contentEncoding → "gzip".
521    #[tokio::test]
522    async fn replay_url_defaults() {
523        let (l, port) = bind_mock().await;
524        let captured = serve(
525            l,
526            vec![
527                (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
528                (
529                    200,
530                    r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
531                        .to_string(),
532                ),
533                (200, r#"{"expiresInSeconds":600}"#.to_string()),
534            ],
535        );
536
537        let client = client_for(port);
538
539        let replay = client.sessions().replay_url("s_1").await.unwrap();
540        assert_eq!(replay.url, "https://s3.example.com/r1");
541        assert_eq!(replay.expires_in_seconds, 0);
542        assert_eq!(replay.content_encoding, "gzip");
543
544        let replay = client.sessions().replay_url("s_2").await.unwrap();
545        assert_eq!(replay.expires_in_seconds, 600);
546        assert_eq!(replay.content_encoding, "identity");
547
548        // A body with no url is a protocol error, not a silent empty string.
549        let err = client.sessions().replay_url("s_3").await.unwrap_err();
550        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
551
552        let (head, _) = split_req(&captured.lock().unwrap()[0]);
553        assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
554    }
555
556    /// GET /proxy/countries.
557    #[tokio::test]
558    async fn proxy_countries() {
559        let (l, port) = bind_mock().await;
560        let captured = serve(
561            l,
562            vec![(
563                200,
564                r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
565            )],
566        );
567
568        let client = client_for(port);
569        let res = client.proxy().countries().await.unwrap();
570        assert!(res.enabled);
571        assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
572
573        let (head, _) = split_req(&captured.lock().unwrap()[0]);
574        assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
575    }
576
577    /// GET /sessions/:id.
578    #[tokio::test]
579    async fn session_get_parses() {
580        let (l, port) = bind_mock().await;
581        serve(
582            l,
583            vec![(
584                200,
585                r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
586                    .to_string(),
587            )],
588        );
589
590        let client = client_for(port);
591        let view = client.sessions().get("s_1").await.unwrap();
592        assert_eq!(view.session_id.as_deref(), Some("s_1"));
593        assert_eq!(view.status.as_deref(), Some("busy"));
594        assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
595        assert_eq!(view.extra.get("slot").unwrap(), 3);
596    }
597
598    /// cdpEndpoint is derived from wsEndpoint when the API omits it.
599    #[test]
600    fn cdp_derivation() {
601        assert_eq!(
602            derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
603            "wss://api.getsolari.com/cdp/abc.def"
604        );
605        assert_eq!(
606            derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
607            "ws://127.0.0.1:3000/cdp/s_1?token=x"
608        );
609        // Non-/ws/ paths pass through untouched.
610        assert_eq!(
611            derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
612            "wss://api.getsolari.com/other/abc"
613        );
614        // Unparseable input passes through untouched.
615        assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
616    }
617
618    /// A create response without cdpEndpoint derives one from wsEndpoint.
619    #[tokio::test]
620    async fn create_session_derives_cdp() {
621        let (l, port) = bind_mock().await;
622        let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
623        serve(l, vec![(201, body.to_string())]);
624
625        let client = client_for(port);
626        let session = client
627            .sessions()
628            .create(CreateSessionOptions::default())
629            .await
630            .unwrap();
631
632        assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
633        assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
634        // expiresAt was omitted → a ~1h ISO-8601 UTC fallback was synthesized.
635        assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
636        assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
637    }
638
639    /// A malformed create response (no sessionId) is a protocol error.
640    #[tokio::test]
641    async fn create_session_rejects_bad_response() {
642        let (l, port) = bind_mock().await;
643        serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
644
645        let client = client_for(port);
646        let err = client
647            .sessions()
648            .create(CreateSessionOptions::default())
649            .await
650            .unwrap_err();
651        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
652    }
653
654    /// With a profileId, `storageStateUrl` is followed to the presigned object.
655    #[tokio::test]
656    async fn storage_state_fetched_from_presigned_url() {
657        let (l, port) = bind_mock().await;
658        let create = format!(
659            r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
660        );
661        let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
662        serve(l, vec![(201, create), (200, state.to_string())]);
663
664        let client = client_for(port);
665        let session = client
666            .sessions()
667            .create(CreateSessionOptions::new().profile_id("p_1"))
668            .await
669            .unwrap();
670
671        let state = session
672            .storage_state
673            .expect("profile attached → Some")
674            .expect("presigned state → Some");
675        let cookies = state.cookies.unwrap();
676        assert_eq!(cookies[0].name, "sid");
677        assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
678        let origins = state.origins.unwrap();
679        assert_eq!(origins[0].origin, "https://example.com");
680        assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
681    }
682
683    /// The three storage-state states are distinguished: absent vs empty vs set.
684    #[tokio::test]
685    async fn storage_state_absent_vs_empty() {
686        let (l, port) = bind_mock().await;
687        serve(
688            l,
689            vec![
690                // profileId requested, storageStateUrl.url is null → empty profile.
691                (
692                    201,
693                    r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
694                        .to_string(),
695                ),
696                // profileId requested, no storageStateUrl at all → empty profile.
697                (
698                    201,
699                    r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
700                ),
701                // profileId requested, legacy inline storageState → populated.
702                (
703                    201,
704                    r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
705                        .to_string(),
706                ),
707                // No profileId → absent entirely.
708                (
709                    201,
710                    r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
711                ),
712            ],
713        );
714
715        let client = client_for(port);
716
717        let s = client
718            .sessions()
719            .create(CreateSessionOptions::new().profile_id("p_1"))
720            .await
721            .unwrap();
722        assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
723
724        let s = client
725            .sessions()
726            .create(CreateSessionOptions::new().profile_id("p_1"))
727            .await
728            .unwrap();
729        assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
730
731        let s = client
732            .sessions()
733            .create(CreateSessionOptions::new().profile_id("p_1"))
734            .await
735            .unwrap();
736        let inline = s.storage_state.expect("Some").expect("Some");
737        assert_eq!(inline.cookies.unwrap().len(), 0);
738
739        let s = client
740            .sessions()
741            .create(CreateSessionOptions::default())
742            .await
743            .unwrap();
744        assert!(s.storage_state.is_none(), "no profile → None");
745    }
746
747    /// Client construction: defaults, validation, region resolution.
748    #[test]
749    fn client_options_and_defaults() {
750        let opts = ClientOptions::default_region("k");
751        assert_eq!(opts.base_url, "https://api.getsolari.com");
752        assert_eq!(opts.max_attempts, 2);
753        assert_eq!(opts.backoff_ms, 500);
754        assert_eq!(opts.timeout_ms, 90_000);
755
756        assert_eq!(
757            ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
758            "https://api.getsolari.com"
759        );
760
761        // Trailing slashes are stripped.
762        let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
763        assert_eq!(c.base_url(), "https://gw.example.com");
764
765        // An empty api key is rejected.
766        let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
767        assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
768    }
769
770    /// The ISO-8601 fallback formatter. Expectations cross-checked against
771    /// `date -u -d @<ts>`.
772    #[test]
773    fn iso8601_formatting() {
774        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
775        assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
776        assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
777        // 2024-02-29 (leap day) 12:00:00Z.
778        assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
779        // 2000 is a leap year (divisible by 400) — the century special case.
780        assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
781        // 2100 is NOT a leap year (divisible by 100, not 400).
782        assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
783        // Pre-epoch must floor, not truncate toward zero.
784        assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
785    }
786
787    /// Error codes round-trip through their wire strings.
788    #[test]
789    fn error_code_round_trip() {
790        for code in [
791            SolariErrorCode::FeatureRequiresPlan,
792            SolariErrorCode::ConcurrencyLimitExceeded,
793            SolariErrorCode::PlanLimitExceeded,
794            SolariErrorCode::BrowserUnhealthy,
795        ] {
796            assert_eq!(SolariErrorCode::from(code.as_str()), code);
797        }
798        assert_eq!(
799            SolariErrorCode::from("BrowserUnhealthy"),
800            SolariErrorCode::BrowserUnhealthy
801        );
802        assert_eq!(
803            SolariErrorCode::Other("Nope".into()).to_string(),
804            "Nope".to_string()
805        );
806    }
807}