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    }
338
339    /// The wire field stays timeoutSeconds, and the new option's name is never sent.
340    #[tokio::test]
341    async fn session_timeout_ms_is_sent_as_timeout_seconds() {
342        let (l, port) = bind_mock().await;
343        let captured = serve(
344            l,
345            vec![(201, r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#.to_string())],
346        );
347        client_for(port)
348            .sessions()
349            .create(CreateSessionOptions::new().session_timeout_ms(61_001))
350            .await
351            .unwrap();
352        let raw = captured.lock().unwrap()[0].clone();
353        let (_, body) = split_req(&raw);
354        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
355        assert_eq!(json["timeoutSeconds"], 62);
356        assert!(json.get("sessionTimeoutMs").is_none() && json.get("session_timeout_ms").is_none());
357    }
358
359    /// The deprecated builder method still sends its seconds.
360    #[allow(deprecated)]
361    #[tokio::test]
362    async fn deprecated_timeout_seconds_still_works() {
363        let (l, port) = bind_mock().await;
364        let captured = serve(
365            l,
366            vec![(201, r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#.to_string())],
367        );
368        client_for(port)
369            .sessions()
370            .create(CreateSessionOptions::new().timeout_seconds(90))
371            .await
372            .unwrap();
373        let raw = captured.lock().unwrap()[0].clone();
374        let (_, body) = split_req(&raw);
375        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
376        assert_eq!(json["timeoutSeconds"], 90);
377    }
378
379    /// The new options add nothing to the body when unset: still no body at all.
380    #[tokio::test]
381    async fn new_create_options_are_omitted_when_unset() {
382        let (l, port) = bind_mock().await;
383        let captured = serve(
384            l,
385            vec![(201, r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#.to_string())],
386        );
387        client_for(port)
388            .sessions()
389            .create(CreateSessionOptions::default())
390            .await
391            .unwrap();
392        let raw = captured.lock().unwrap()[0].clone();
393        let (_, body) = split_req(&raw);
394        assert!(body.trim().is_empty(), "body: {body}");
395    }
396
397    /// get lifts the gateway's fields out of the response.
398    #[tokio::test]
399    async fn get_returns_the_gateway_view() {
400        let (l, port) = bind_mock().await;
401        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"}"#;
402        let _ = serve(l, vec![(200, body.to_string())]);
403        let v = client_for(port).sessions().get("s_1").await.unwrap();
404        assert_eq!(v.id.as_deref(), Some("s_1"));
405        assert_eq!(v.kind.as_deref(), Some("stealth"));
406        assert_eq!(v.org.as_deref(), Some("o1"));
407        assert_eq!(v.user_metadata.unwrap()["a"], "b");
408        assert!(v.cdp_endpoint.is_some() && v.ws_endpoint.is_some());
409    }
410
411    #[tokio::test]
412    async fn list_sends_paging_and_the_metadata_filter_and_parses_the_page() {
413        let (l, port) = bind_mock().await;
414        let body = r#"{"sessions":[{"id":"s1","status":"active","kind":"fast","createdAt":"c","expiresAt":"e","userMetadata":{"team":"x"}},{"id":"s2","status":"released"}],"nextCursor":"cur2"}"#;
415        let captured = serve(l, vec![(200, body.to_string())]);
416
417        let mut opts = ListSessionsOptions::default();
418        opts.limit = Some(2);
419        opts.cursor = Some("cur1".into());
420        opts.metadata.insert("team".into(), "x".into());
421        opts.metadata.insert("env".into(), "prod".into());
422        let page = client_for(port).sessions().list(opts).await.unwrap();
423
424        let raw = captured.lock().unwrap()[0].clone();
425        let (head, _) = split_req(&raw);
426        assert!(head.starts_with("GET /sessions?"), "head: {head}");
427        assert!(head.contains("limit=2"), "head: {head}");
428        assert!(head.contains("cursor=cur1"), "head: {head}");
429        // Pairs are sorted by key and joined by AND.
430        assert!(
431            head.contains("q=user_metadata%5B%27env%27%5D%3A%27prod%27%20AND%20user_metadata%5B%27team%27%5D%3A%27x%27"),
432            "head: {head}"
433        );
434        assert_eq!(page.sessions.len(), 2);
435        assert_eq!(page.sessions[0].id, "s1");
436        assert_eq!(page.sessions[0].user_metadata.as_ref().unwrap()["team"], "x");
437        assert_eq!(page.sessions[1].status, "released");
438        assert_eq!(page.next_cursor.as_deref(), Some("cur2"));
439    }
440
441    #[tokio::test]
442    async fn list_with_default_options_requests_a_bare_path_and_handles_an_empty_page() {
443        let (l, port) = bind_mock().await;
444        let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
445        let page = client_for(port)
446            .sessions()
447            .list(ListSessionsOptions::default())
448            .await
449            .unwrap();
450        let raw = captured.lock().unwrap()[0].clone();
451        let (head, _) = split_req(&raw);
452        assert!(head.starts_with("GET /sessions HTTP/1.1"), "head: {head}");
453        assert!(page.sessions.is_empty());
454        assert!(page.next_cursor.is_none());
455    }
456
457    /// A filter the gateway grammar cannot express is refused before any request is sent.
458    #[tokio::test]
459    async fn list_refuses_an_unexpressible_filter_without_a_request() {
460        let (l, port) = bind_mock().await;
461        let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
462        let client = client_for(port);
463
464        for (k, v) in [("k", "it's"), ("k'", "v"), ("", "v")] {
465            let mut opts = ListSessionsOptions::default();
466            opts.metadata.insert(k.into(), v.into());
467            assert!(client.sessions().list(opts).await.is_err(), "{k:?}={v:?}");
468        }
469        let mut many = ListSessionsOptions::default();
470        for i in 0..11 {
471            many.metadata.insert(format!("k{i}"), "v".into());
472        }
473        assert!(client.sessions().list(many).await.is_err());
474        assert!(captured.lock().unwrap().is_empty(), "no request may be sent");
475    }
476
477    /// A gateway 400 on the filter surfaces its code.
478    #[tokio::test]
479    async fn list_surfaces_a_gateway_validation_error() {
480        let (l, port) = bind_mock().await;
481        let _ = serve(
482            l,
483            vec![(400, r#"{"error":"Invalid q","code":"ValidationError","field":"q"}"#.to_string())],
484        );
485        let err = client_for(port)
486            .sessions()
487            .list(ListSessionsOptions::default())
488            .await
489            .unwrap_err();
490        assert_eq!(err.status(), Some(400));
491    }
492
493    /// With no options at all, the body is omitted entirely (not `{}`).
494    #[tokio::test]
495    async fn create_session_omits_empty_body() {
496        let (l, port) = bind_mock().await;
497        let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
498        let captured = serve(l, vec![(201, body.to_string())]);
499
500        let client = client_for(port);
501        client
502            .sessions()
503            .create(CreateSessionOptions::default())
504            .await
505            .unwrap();
506
507        let raw = captured.lock().unwrap()[0].clone();
508        let (head, body) = split_req(&raw);
509        assert!(body.trim().is_empty(), "expected no body, got: {body}");
510        assert!(
511            !head.to_lowercase().contains("content-length: 2"),
512            "head: {head}"
513        );
514    }
515
516    /// A full ProxyRequest serializes camelCase, with unset fields omitted.
517    #[tokio::test]
518    async fn create_session_proxy_request_shape() {
519        let (l, port) = bind_mock().await;
520        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"}}"#;
521        let captured = serve(l, vec![(201, body.to_string())]);
522
523        let client = client_for(port);
524        let session = client
525            .sessions()
526            .create(CreateSessionOptions::new().stealth(true).proxy(
527                ProxyRequest::country("us")
528                    .tier(ProxyTier::Mobile)
529                    .session("warm-1")
530                    .session_duration(15),
531            ))
532            .await
533            .unwrap();
534
535        let proxy = session.proxy.expect("resolved proxy");
536        assert_eq!(proxy.country, "us");
537        assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
538        assert_eq!(proxy.timezone_id, "America/Los_Angeles");
539
540        let raw = captured.lock().unwrap()[0].clone();
541        let (_, body) = split_req(&raw);
542        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
543        let p = json.get("proxy").unwrap();
544        assert_eq!(p.get("country").unwrap(), "us");
545        assert_eq!(p.get("tier").unwrap(), "mobile");
546        assert_eq!(p.get("session").unwrap(), "warm-1");
547        assert_eq!(p.get("sessionDuration").unwrap(), 15);
548        assert!(p.get("asn").is_none());
549        assert!(p.get("city").is_none());
550    }
551
552    /// The gateway marks `404 ReplayPending` retryable: the recording upload is
553    /// still in flight. An idempotent GET must honour that hint even though 404
554    /// is nowhere near the 5xx allowlist.
555    #[tokio::test]
556    async fn retryable_hint_retries_idempotent_get() {
557        let (l, port) = bind_mock().await;
558        let pending =
559            r#"{"error":"replay still uploading","code":"ReplayPending","retryable":true}"#;
560        let ok = r#"{"url":"https://s3/replay.ndjson.gz"}"#;
561        let captured = serve(
562            l,
563            vec![(404, pending.to_string()), (200, ok.to_string())],
564        );
565
566        let client = client_for(port);
567        let link = client.sessions().replay_url("sess-1").await.unwrap();
568
569        assert_eq!(link.url, "https://s3/replay.ndjson.gz");
570        // Two attempts: the hint is what produced the second.
571        assert_eq!(captured.lock().unwrap().len(), 2);
572    }
573
574    /// A 404 WITHOUT the hint is terminal -- the FLAG changes the outcome, not
575    /// the status.
576    #[tokio::test]
577    async fn no_hint_means_no_retry() {
578        let (l, port) = bind_mock().await;
579        let terminal = r#"{"error":"no replay","code":"ReplayUnavailable"}"#;
580        let captured = serve(
581            l,
582            vec![
583                (404, terminal.to_string()),
584                (200, r#"{"url":"never reached"}"#.to_string()),
585            ],
586        );
587
588        let client = client_for(port);
589        assert!(client.sessions().replay_url("sess-1").await.is_err());
590        assert_eq!(captured.lock().unwrap().len(), 1);
591    }
592
593    /// RETRACTED REASON, kept deliberately. This asserted the opposite, because
594    /// "the browser API issues no Idempotency-Key, so a re-sent create could
595    /// leave a second live session behind". Creates now mint a key, so the
596    /// gateway answers the retry from the first result instead of creating
597    /// twice -- the condition was removed, not worked around.
598    ///
599    /// This is the case the gateway-side guard produces: a duplicate arriving
600    /// while the first create is still running is answered 409 + retryable.
601    #[tokio::test]
602    async fn retryable_hint_honoured_on_keyed_create() {
603        let (l, port) = bind_mock().await;
604        let captured = serve(
605            l,
606            vec![
607                (409, r#"{"error":"in progress","retryable":true}"#.to_string()),
608                (201, r#"{"sessionId":"s_ok","wsEndpoint":"wss://x/ws/s_ok"}"#.to_string()),
609            ],
610        );
611
612        let client = client_for(port);
613        assert!(client
614            .sessions()
615            .create(CreateSessionOptions::default())
616            .await
617            .is_ok());
618        let reqs = captured.lock().unwrap();
619        assert_eq!(reqs.len(), 2, "the hint must be honoured on a keyed create");
620
621        // The key identifies the CALL, not the attempt: a fresh key per retry
622        // would silently defeat the mechanism (the gateway would see two
623        // distinct keys and create twice) while every assertion above still
624        // passes. Only comparing the two captured headers catches that.
625        fn idempotency_key(head: &str) -> Option<String> {
626            for line in head.lines() {
627                if let Some(v) = line.to_lowercase().strip_prefix("idempotency-key:") {
628                    return Some(v.trim().to_string());
629                }
630            }
631            None
632        }
633        let (head1, _) = split_req(&reqs[0]);
634        let (head2, _) = split_req(&reqs[1]);
635        let key1 = idempotency_key(&head1).expect("first attempt must carry a key");
636        let key2 = idempotency_key(&head2).expect("second attempt must carry a key");
637        assert_eq!(key1, key2, "retries must reuse the SAME key, not mint a fresh one");
638    }
639
640    /// A key identifies the CALL: two separate creates must not share one.
641    #[tokio::test]
642    async fn idempotency_keys_differ_between_calls() {
643        assert_ne!(
644            crate::http::new_idempotency_key(),
645            crate::http::new_idempotency_key()
646        );
647    }
648
649    /// 503 is retried up to max_attempts and can succeed on the second try.
650    #[tokio::test]
651    async fn retries_on_503() {
652        let (l, port) = bind_mock().await;
653        let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
654        let captured = serve(
655            l,
656            vec![
657                (503, r#"{"error":"no capacity"}"#.to_string()),
658                (201, ok.to_string()),
659            ],
660        );
661
662        let client = client_for(port);
663        let session = client
664            .sessions()
665            .create(CreateSessionOptions::default())
666            .await
667            .unwrap();
668
669        assert_eq!(session.id, "s_ok");
670        // Two attempts were actually made.
671        assert_eq!(captured.lock().unwrap().len(), 2);
672    }
673
674    /// A 502 PoolResponseUnparseable carries retryable:false (the pool may already have created the
675    /// session): one attempt, and the code reaches the caller.
676    #[tokio::test]
677    async fn pool_response_unparseable_502_is_not_retried() {
678        let (l, port) = bind_mock().await;
679        let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
680        let captured = serve(
681            l,
682            vec![
683                (
684                    502,
685                    r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#.to_string(),
686                ),
687                (201, ok.to_string()),
688            ],
689        );
690
691        let client = client_for(port);
692        let err = client
693            .sessions()
694            .create(CreateSessionOptions::default())
695            .await
696            .unwrap_err();
697
698        assert_eq!(err.status(), Some(502));
699        assert_eq!(captured.lock().unwrap().len(), 1, "retryable:false must not be retried");
700    }
701
702    /// Retries are exhausted after max_attempts → the last response, with its status and code.
703    #[tokio::test]
704    async fn retries_exhausted_on_persistent_503() {
705        let (l, port) = bind_mock().await;
706        let captured = serve(
707            l,
708            vec![
709                (503, "{}".to_string()),
710                (503, "{}".to_string()),
711                (201, "{}".to_string()), // never reached: max_attempts is 2
712            ],
713        );
714
715        let client = client_for(port);
716        let err = client
717            .sessions()
718            .create(CreateSessionOptions::default())
719            .await
720            .unwrap_err();
721
722        assert!(matches!(err, SolariError::Api { .. }), "{err:?}");
723        assert_eq!(err.status(), Some(503));
724        assert_eq!(captured.lock().unwrap().len(), 2);
725    }
726
727    /// A 503 with a code that outlasts the retries keeps that code.
728    #[tokio::test]
729    async fn final_5xx_keeps_its_code() {
730        let (l, port) = bind_mock().await;
731        let captured = serve(
732            l,
733            vec![
734                (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
735                (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
736            ],
737        );
738
739        let client = client_for(port);
740        let err = client
741            .sessions()
742            .create(CreateSessionOptions::default())
743            .await
744            .unwrap_err();
745
746        assert_eq!(err.status(), Some(503));
747        assert_eq!(err.code(), Some(&SolariErrorCode::BrowserUnhealthy));
748        assert_eq!(captured.lock().unwrap().len(), 2);
749    }
750
751    /// A transport failure that outlasts the retries is still the exhausted Transport error.
752    #[tokio::test]
753    async fn transport_failure_exhausts_as_transport_error() {
754        let (l, port) = bind_mock().await;
755        drop(l); // nothing listens on this port any more
756        let client = client_for(port);
757        let err = client
758            .sessions()
759            .create(CreateSessionOptions::default())
760            .await
761            .unwrap_err();
762
763        assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
764        assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
765    }
766
767    /// 400 is NOT retried — it comes straight back on the first attempt.
768    #[tokio::test]
769    async fn does_not_retry_on_400() {
770        let (l, port) = bind_mock().await;
771        let captured = serve(
772            l,
773            vec![
774                (400, r#"{"error":"bad proxy country"}"#.to_string()),
775                (201, "{}".to_string()), // must NOT be consumed
776            ],
777        );
778
779        let client = client_for(port);
780        let err = client
781            .sessions()
782            .create(CreateSessionOptions::default())
783            .await
784            .unwrap_err();
785
786        assert_eq!(err.status(), Some(400));
787        assert!(err.to_string().contains("bad proxy country"), "{err}");
788        // Exactly one request was made.
789        assert_eq!(captured.lock().unwrap().len(), 1);
790    }
791
792    /// `{code}` is parsed out of error bodies into the typed enum; unknown codes
793    /// survive as `Other`.
794    #[tokio::test]
795    async fn parses_error_codes() {
796        let (l, port) = bind_mock().await;
797        serve(
798            l,
799            vec![
800                (
801                    402,
802                    r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
803                        .to_string(),
804                ),
805                (
806                    429,
807                    r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
808                ),
809                (400, r#"{"code":"SomethingNew"}"#.to_string()),
810                (400, "not json at all".to_string()),
811            ],
812        );
813
814        let client = client_for(port);
815
816        let err = client
817            .sessions()
818            .create(CreateSessionOptions::new().captcha(true))
819            .await
820            .unwrap_err();
821        assert_eq!(err.status(), Some(402));
822        assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
823        assert!(err.to_string().contains("captcha requires a paid plan"));
824
825        let err = client
826            .sessions()
827            .create(CreateSessionOptions::default())
828            .await
829            .unwrap_err();
830        assert_eq!(err.status(), Some(429));
831        assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
832
833        // Unknown code → preserved verbatim, not dropped.
834        let err = client
835            .sessions()
836            .create(CreateSessionOptions::default())
837            .await
838            .unwrap_err();
839        assert_eq!(
840            err.code(),
841            Some(&SolariErrorCode::Other("SomethingNew".to_string()))
842        );
843
844        // Non-JSON body → no code, but still a typed API error with the status.
845        let err = client
846            .sessions()
847            .create(CreateSessionOptions::default())
848            .await
849            .unwrap_err();
850        assert_eq!(err.status(), Some(400));
851        assert_eq!(err.code(), None);
852    }
853
854    /// GET /profiles parses the list, including unknown keys via `extra`.
855    #[tokio::test]
856    async fn profiles_list_parses() {
857        let (l, port) = bind_mock().await;
858        let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
859        let captured = serve(l, vec![(200, body.to_string())]);
860
861        let client = client_for(port);
862        let profiles = client.profiles().list().await.unwrap();
863
864        assert_eq!(profiles.len(), 2);
865        assert_eq!(profiles[0].id, "p_1");
866        assert_eq!(profiles[0].name, "amazon");
867        // Index-signature keys survive.
868        assert_eq!(
869            profiles[0].extra.get("createdAt").unwrap(),
870            "2026-01-01T00:00:00Z"
871        );
872        assert_eq!(profiles[1].id, "p_2");
873        assert!(profiles[1].extra.is_empty());
874
875        let (head, _) = split_req(&captured.lock().unwrap()[0]);
876        assert!(head.starts_with("GET /profiles "), "head: {head}");
877    }
878
879    /// POST /profiles sends `{name}` and parses the created profile.
880    #[tokio::test]
881    async fn profiles_create_and_save() {
882        let (l, port) = bind_mock().await;
883        let captured = serve(
884            l,
885            vec![
886                (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
887                (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
888                (200, "{}".to_string()), // save with defaults
889            ],
890        );
891
892        let client = client_for(port);
893        let profile = client.profiles().create("shopify").await.unwrap();
894        assert_eq!(profile.id, "p_9");
895        assert_eq!(profile.name, "shopify");
896
897        let state = StorageState {
898            cookies: Some(vec![Cookie {
899                name: "sid".into(),
900                value: "abc".into(),
901                domain: Some(".shopify.com".into()),
902                same_site: Some(SameSite::Lax),
903                ..Default::default()
904            }]),
905            ..Default::default()
906        };
907        let saved = client.profiles().save("p_9", &state).await.unwrap();
908        assert_eq!(saved.version, 3);
909        assert_eq!(saved.size_bytes, 2048);
910
911        // Missing fields default to 0.
912        let saved = client.profiles().save("p_9", &state).await.unwrap();
913        assert_eq!(saved.version, 0);
914        assert_eq!(saved.size_bytes, 0);
915
916        let reqs = captured.lock().unwrap().clone();
917        let (head, body) = split_req(&reqs[0]);
918        assert!(head.starts_with("POST /profiles "), "head: {head}");
919        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
920        assert_eq!(json.get("name").unwrap(), "shopify");
921
922        // save wraps the state under `storageState` and keeps camelCase inside.
923        let (head, body) = split_req(&reqs[1]);
924        assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
925        let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
926        let cookie = &json["storageState"]["cookies"][0];
927        assert_eq!(cookie.get("name").unwrap(), "sid");
928        assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
929        assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
930        // Unset cookie fields are omitted, not sent as null.
931        assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
932    }
933
934    /// DELETE tolerates 404 on both sessions and profiles.
935    #[tokio::test]
936    async fn delete_tolerates_404() {
937        let (l, port) = bind_mock().await;
938        serve(
939            l,
940            vec![
941                (404, r#"{"error":"Not Found"}"#.to_string()),
942                (404, r#"{"error":"Not Found"}"#.to_string()),
943                (500, r#"{"error":"boom"}"#.to_string()),
944            ],
945        );
946
947        let client = client_for(port);
948        client.sessions().release("s_gone").await.unwrap();
949        client.profiles().delete("p_gone").await.unwrap();
950        // But a real failure still propagates.
951        let err = client.sessions().release("s_bad").await.unwrap_err();
952        assert_eq!(err.status(), Some(500));
953    }
954
955    /// replay-url defaults: expiresInSeconds → 0, contentEncoding → "gzip".
956    #[tokio::test]
957    async fn replay_url_defaults() {
958        let (l, port) = bind_mock().await;
959        let captured = serve(
960            l,
961            vec![
962                (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
963                (
964                    200,
965                    r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
966                        .to_string(),
967                ),
968                (200, r#"{"expiresInSeconds":600}"#.to_string()),
969            ],
970        );
971
972        let client = client_for(port);
973
974        let replay = client.sessions().replay_url("s_1").await.unwrap();
975        assert_eq!(replay.url, "https://s3.example.com/r1");
976        assert_eq!(replay.expires_in_seconds, 0);
977        assert_eq!(replay.content_encoding, "gzip");
978
979        let replay = client.sessions().replay_url("s_2").await.unwrap();
980        assert_eq!(replay.expires_in_seconds, 600);
981        assert_eq!(replay.content_encoding, "identity");
982
983        // A body with no url is a protocol error, not a silent empty string.
984        let err = client.sessions().replay_url("s_3").await.unwrap_err();
985        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
986
987        let (head, _) = split_req(&captured.lock().unwrap()[0]);
988        assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
989    }
990
991    /// GET /proxy/countries.
992    #[tokio::test]
993    async fn proxy_countries() {
994        let (l, port) = bind_mock().await;
995        let captured = serve(
996            l,
997            vec![(
998                200,
999                r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
1000            )],
1001        );
1002
1003        let client = client_for(port);
1004        let res = client.proxy().countries().await.unwrap();
1005        assert!(res.enabled);
1006        assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
1007
1008        let (head, _) = split_req(&captured.lock().unwrap()[0]);
1009        assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
1010    }
1011
1012    /// GET /sessions/:id.
1013    #[tokio::test]
1014    async fn session_get_parses() {
1015        let (l, port) = bind_mock().await;
1016        serve(
1017            l,
1018            vec![(
1019                200,
1020                r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
1021                    .to_string(),
1022            )],
1023        );
1024
1025        let client = client_for(port);
1026        let view = client.sessions().get("s_1").await.unwrap();
1027        assert_eq!(view.session_id.as_deref(), Some("s_1"));
1028        assert_eq!(view.status.as_deref(), Some("busy"));
1029        assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
1030        assert_eq!(view.extra.get("slot").unwrap(), 3);
1031    }
1032
1033    /// cdpEndpoint is derived from wsEndpoint when the API omits it.
1034    #[test]
1035    fn cdp_derivation() {
1036        assert_eq!(
1037            derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
1038            "wss://api.getsolari.com/cdp/abc.def"
1039        );
1040        assert_eq!(
1041            derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
1042            "ws://127.0.0.1:3000/cdp/s_1?token=x"
1043        );
1044        // Non-/ws/ paths pass through untouched.
1045        assert_eq!(
1046            derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
1047            "wss://api.getsolari.com/other/abc"
1048        );
1049        // Unparseable input passes through untouched.
1050        assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
1051    }
1052
1053    /// A create response without cdpEndpoint derives one from wsEndpoint.
1054    #[tokio::test]
1055    async fn create_session_derives_cdp() {
1056        let (l, port) = bind_mock().await;
1057        let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
1058        serve(l, vec![(201, body.to_string())]);
1059
1060        let client = client_for(port);
1061        let session = client
1062            .sessions()
1063            .create(CreateSessionOptions::default())
1064            .await
1065            .unwrap();
1066
1067        assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
1068        assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
1069        // expiresAt was omitted → a ~1h ISO-8601 UTC fallback was synthesized.
1070        assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
1071        assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
1072    }
1073
1074    /// A malformed create response (no sessionId) is a protocol error.
1075    #[tokio::test]
1076    async fn create_session_rejects_bad_response() {
1077        let (l, port) = bind_mock().await;
1078        serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
1079
1080        let client = client_for(port);
1081        let err = client
1082            .sessions()
1083            .create(CreateSessionOptions::default())
1084            .await
1085            .unwrap_err();
1086        assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
1087    }
1088
1089    /// With a profileId, `storageStateUrl` is followed to the presigned object.
1090    #[tokio::test]
1091    async fn storage_state_fetched_from_presigned_url() {
1092        let (l, port) = bind_mock().await;
1093        let create = format!(
1094            r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
1095        );
1096        let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
1097        serve(l, vec![(201, create), (200, state.to_string())]);
1098
1099        let client = client_for(port);
1100        let session = client
1101            .sessions()
1102            .create(CreateSessionOptions::new().profile_id("p_1"))
1103            .await
1104            .unwrap();
1105
1106        let state = session
1107            .storage_state
1108            .expect("profile attached → Some")
1109            .expect("presigned state → Some");
1110        let cookies = state.cookies.unwrap();
1111        assert_eq!(cookies[0].name, "sid");
1112        assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
1113        let origins = state.origins.unwrap();
1114        assert_eq!(origins[0].origin, "https://example.com");
1115        assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
1116    }
1117
1118    /// The three storage-state states are distinguished: absent vs empty vs set.
1119    #[tokio::test]
1120    async fn storage_state_absent_vs_empty() {
1121        let (l, port) = bind_mock().await;
1122        serve(
1123            l,
1124            vec![
1125                // profileId requested, storageStateUrl.url is null → empty profile.
1126                (
1127                    201,
1128                    r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
1129                        .to_string(),
1130                ),
1131                // profileId requested, no storageStateUrl at all → empty profile.
1132                (
1133                    201,
1134                    r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
1135                ),
1136                // profileId requested, legacy inline storageState → populated.
1137                (
1138                    201,
1139                    r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
1140                        .to_string(),
1141                ),
1142                // No profileId → absent entirely.
1143                (
1144                    201,
1145                    r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
1146                ),
1147            ],
1148        );
1149
1150        let client = client_for(port);
1151
1152        let s = client
1153            .sessions()
1154            .create(CreateSessionOptions::new().profile_id("p_1"))
1155            .await
1156            .unwrap();
1157        assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
1158
1159        let s = client
1160            .sessions()
1161            .create(CreateSessionOptions::new().profile_id("p_1"))
1162            .await
1163            .unwrap();
1164        assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
1165
1166        let s = client
1167            .sessions()
1168            .create(CreateSessionOptions::new().profile_id("p_1"))
1169            .await
1170            .unwrap();
1171        let inline = s.storage_state.expect("Some").expect("Some");
1172        assert_eq!(inline.cookies.unwrap().len(), 0);
1173
1174        let s = client
1175            .sessions()
1176            .create(CreateSessionOptions::default())
1177            .await
1178            .unwrap();
1179        assert!(s.storage_state.is_none(), "no profile → None");
1180    }
1181
1182    /// Client construction: defaults, validation, region resolution.
1183    #[test]
1184    fn client_options_and_defaults() {
1185        let opts = ClientOptions::default_region("k");
1186        assert_eq!(opts.base_url, "https://api.getsolari.com");
1187        assert_eq!(opts.max_attempts, 2);
1188        assert_eq!(opts.backoff_ms, 500);
1189        assert_eq!(opts.timeout_ms, 90_000);
1190
1191        assert_eq!(
1192            ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
1193            "https://api.getsolari.com"
1194        );
1195
1196        // Trailing slashes are stripped.
1197        let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
1198        assert_eq!(c.base_url(), "https://gw.example.com");
1199
1200        // An empty api key is rejected.
1201        let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
1202        assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
1203    }
1204
1205    /// The ISO-8601 fallback formatter. Expectations cross-checked against
1206    /// `date -u -d @<ts>`.
1207    #[test]
1208    fn iso8601_formatting() {
1209        assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1210        assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1211        assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1212        // 2024-02-29 (leap day) 12:00:00Z.
1213        assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1214        // 2000 is a leap year (divisible by 400) — the century special case.
1215        assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1216        // 2100 is NOT a leap year (divisible by 100, not 400).
1217        assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1218        // Pre-epoch must floor, not truncate toward zero.
1219        assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1220    }
1221
1222    /// Error codes round-trip through their wire strings.
1223    #[test]
1224    fn error_code_round_trip() {
1225        for code in [
1226            SolariErrorCode::FeatureRequiresPlan,
1227            SolariErrorCode::ConcurrencyLimitExceeded,
1228            SolariErrorCode::PlanLimitExceeded,
1229            SolariErrorCode::BrowserUnhealthy,
1230        ] {
1231            assert_eq!(SolariErrorCode::from(code.as_str()), code);
1232        }
1233        assert_eq!(
1234            SolariErrorCode::from("BrowserUnhealthy"),
1235            SolariErrorCode::BrowserUnhealthy
1236        );
1237        assert_eq!(
1238            SolariErrorCode::Other("Nope".into()).to_string(),
1239            "Nope".to_string()
1240        );
1241    }
1242
1243    // Regression: launch()'s retry loop must retry create() itself on a
1244    // transient failure, not only the connect+probe step after it. A bare
1245    // `?` on create() short-circuits launch() before opts.retries/is_transient
1246    // are ever consulted -- found in review (Feature 3), proved with this
1247    // harness before the fix landed.
1248    #[cfg(feature = "connect")]
1249    #[tokio::test]
1250    async fn launch_retries_create_itself_on_transient_exhaustion() {
1251        let (l, port) = bind_mock().await;
1252        let captured = serve(
1253            l,
1254            vec![
1255                // First create(): exhausts max_attempts(2) on 503 -> Transport
1256                // error, which is_transient says yes to.
1257                (503, "{}".to_string()),
1258                (503, "{}".to_string()),
1259                // Second create() (the retry launch() must make): a single
1260                // non-transient 400 ends the loop deterministically, so the
1261                // request count alone proves the retry fired.
1262                (400, r#"{"error":"bad request"}"#.to_string()),
1263            ],
1264        );
1265
1266        let client = client_for(port);
1267        let result = client
1268            .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1269            .await;
1270        let Err(err) = result else {
1271            panic!("expected second create()'s 400 to end the loop, got Ok");
1272        };
1273
1274        assert_eq!(err.status(), Some(400));
1275        // 2 (exhausted first create) + 1 (second create) == 3. If the bare
1276        // `?` regression were present, launch() would return on the first
1277        // create()'s Transport error and this would be 2.
1278        assert_eq!(captured.lock().unwrap().len(), 3);
1279    }
1280}