Skip to main content

solari_browser/
lib.rs

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