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