1mod 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::{Client, ClientOptions, CreateSessionOptions, Profiles, Proxy, Sessions};
67pub use error::{ApiErrorBody, SolariError, SolariErrorCode};
68pub use types::{
69 Cookie, LocalStorageEntry, Profile, ProfileSaveResult, ProxyCountries, ProxyRequest, ProxySpec,
70 ProxyTier, ReplayUrl, ResolvedProxyConfig, SameSite, Session, SessionView, SolariRegion,
71 StorageOrigin, StorageState,
72};
73
74#[cfg(feature = "connect")]
75pub use connect::{connect, connect_endpoint, ConnectedBrowser};
76
77#[cfg(feature = "connect")]
78pub use session::{LaunchOptions, LaunchedSession};
79
80#[cfg(test)]
81mod tests {
82 use super::client::{derive_cdp_from_ws, iso8601_utc};
83 use super::*;
84 use std::sync::{Arc, Mutex};
85 use tokio::io::{AsyncReadExt, AsyncWriteExt};
86 use tokio::net::TcpListener;
87
88 async fn bind_mock() -> (TcpListener, u16) {
94 let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
95 let port = l.local_addr().unwrap().port();
96 (l, port)
97 }
98
99 fn serve(listener: TcpListener, responses: Vec<(u16, String)>) -> Arc<Mutex<Vec<String>>> {
101 let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
102 let cap = captured.clone();
103 tokio::spawn(async move {
104 for (status, body) in responses {
105 let Ok((mut sock, _)) = listener.accept().await else {
106 return;
107 };
108 let mut buf = Vec::new();
110 let mut tmp = [0u8; 1024];
111 while let Ok(n) = sock.read(&mut tmp).await {
112 if n == 0 {
113 break; }
115 buf.extend_from_slice(&tmp[..n]);
116 let Some(pos) = find_subslice(&buf, b"\r\n\r\n") else {
117 continue; };
119 let head = String::from_utf8_lossy(&buf[..pos]).to_string();
120 let clen = content_length(&head).unwrap_or(0);
121 let body_start = pos + 4;
122 while buf.len() < body_start + clen {
123 match sock.read(&mut tmp).await {
124 Ok(0) | Err(_) => break,
125 Ok(n) => buf.extend_from_slice(&tmp[..n]),
126 }
127 }
128 break;
129 }
130 cap.lock()
131 .unwrap()
132 .push(String::from_utf8_lossy(&buf).to_string());
133
134 let reason = match status {
135 200 => "OK",
136 201 => "Created",
137 204 => "No Content",
138 400 => "Bad Request",
139 402 => "Payment Required",
140 404 => "Not Found",
141 429 => "Too Many Requests",
142 503 => "Service Unavailable",
143 _ => "Status",
144 };
145 let resp = format!(
146 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
147 body.len(),
148 body
149 );
150 let _ = sock.write_all(resp.as_bytes()).await;
151 let _ = sock.flush().await;
152 }
153 });
154 captured
155 }
156
157 fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
158 haystack.windows(needle.len()).position(|w| w == needle)
159 }
160
161 fn content_length(head: &str) -> Option<usize> {
162 for line in head.lines() {
163 if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
164 return v.trim().parse().ok();
165 }
166 }
167 None
168 }
169
170 fn split_req(raw: &str) -> (String, String) {
171 match raw.split_once("\r\n\r\n") {
172 Some((h, b)) => (h.to_string(), b.to_string()),
173 None => (raw.to_string(), String::new()),
174 }
175 }
176
177 fn client_for(port: u16) -> Client {
178 Client::new(
179 ClientOptions::new("slr_live_id_secret", format!("http://127.0.0.1:{port}"))
180 .backoff_ms(0),
181 )
182 .unwrap()
183 }
184
185 #[tokio::test]
195 async fn release_tolerates_a_bare_404() {
196 let (l, port) = bind_mock().await;
197 let _ = serve(l, vec![(404, r#"{"error":"Not Found"}"#.to_string())]);
198 let client = client_for(port);
199 assert!(client.sessions().release("s_1").await.is_ok());
200 }
201
202 #[tokio::test]
203 async fn release_tolerates_a_404_with_an_unrelated_code() {
204 let (l, port) = bind_mock().await;
205 let _ = serve(
206 l,
207 vec![(404, r#"{"error":"Not Found","code":"SomethingElse"}"#.to_string())],
208 );
209 let client = client_for(port);
210 assert!(client.sessions().release("s_1").await.is_ok());
211 }
212
213 #[tokio::test]
214 async fn release_fails_on_404_invalid_session_id() {
215 let (l, port) = bind_mock().await;
216 let _ = serve(
217 l,
218 vec![(
219 404,
220 r#"{"error":"Not Found","code":"InvalidSessionId"}"#.to_string(),
221 )],
222 );
223 let client = client_for(port);
224 let err = client
225 .sessions()
226 .release("s_1")
227 .await
228 .expect_err("a refused session id released nothing and must not look like success");
229 assert_eq!(err.status(), Some(404));
230 assert_eq!(err.code(), Some(&SolariErrorCode::InvalidSessionId));
231 }
232
233 #[tokio::test]
234 async fn invalid_session_id_round_trips_through_the_wire_string() {
235 assert_eq!(
236 SolariErrorCode::from("InvalidSessionId"),
237 SolariErrorCode::InvalidSessionId
238 );
239 assert_eq!(SolariErrorCode::InvalidSessionId.as_str(), "InvalidSessionId");
240 }
241
242 #[tokio::test]
245 async fn create_session_request_shape() {
246 let (l, port) = bind_mock().await;
247 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"}"#;
248 let captured = serve(l, vec![(201, body.to_string())]);
249
250 let client = client_for(port);
251 let session = client
252 .sessions()
253 .create(
254 CreateSessionOptions::new()
255 .stealth(true)
256 .proxy(ProxySpec::country("us")),
257 )
258 .await
259 .unwrap();
260
261 assert_eq!(session.id, "s_1");
262 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
263 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
264 assert_eq!(session.expires_at, "2026-01-01T00:00:00Z");
265 assert!(session.storage_state.is_none());
267 assert!(session.proxy.is_none());
268
269 let raw = captured.lock().unwrap()[0].clone();
270 let (head, body) = split_req(&raw);
271 assert!(head.starts_with("POST /sessions "), "head: {head}");
272 let lower = head.to_lowercase();
273 assert!(
274 lower.contains("authorization: bearer slr_live_id_secret"),
275 "head: {head}"
276 );
277 assert!(lower.contains("content-type: application/json"), "head: {head}");
278
279 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
280 assert_eq!(json.get("stealth").unwrap(), true);
281 assert_eq!(json.get("proxy").unwrap(), "us");
282 assert!(json.get("recording").is_none(), "body: {body}");
284 assert!(json.get("captcha").is_none(), "body: {body}");
285 assert!(json.get("profileId").is_none(), "body: {body}");
286 }
287
288 #[tokio::test]
290 async fn create_session_omits_empty_body() {
291 let (l, port) = bind_mock().await;
292 let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
293 let captured = serve(l, vec![(201, body.to_string())]);
294
295 let client = client_for(port);
296 client
297 .sessions()
298 .create(CreateSessionOptions::default())
299 .await
300 .unwrap();
301
302 let raw = captured.lock().unwrap()[0].clone();
303 let (head, body) = split_req(&raw);
304 assert!(body.trim().is_empty(), "expected no body, got: {body}");
305 assert!(
306 !head.to_lowercase().contains("content-length: 2"),
307 "head: {head}"
308 );
309 }
310
311 #[tokio::test]
313 async fn create_session_proxy_request_shape() {
314 let (l, port) = bind_mock().await;
315 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"}}"#;
316 let captured = serve(l, vec![(201, body.to_string())]);
317
318 let client = client_for(port);
319 let session = client
320 .sessions()
321 .create(CreateSessionOptions::new().stealth(true).proxy(
322 ProxyRequest::country("us")
323 .tier(ProxyTier::Mobile)
324 .session("warm-1")
325 .session_duration(15),
326 ))
327 .await
328 .unwrap();
329
330 let proxy = session.proxy.expect("resolved proxy");
331 assert_eq!(proxy.country, "us");
332 assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
333 assert_eq!(proxy.timezone_id, "America/Los_Angeles");
334
335 let raw = captured.lock().unwrap()[0].clone();
336 let (_, body) = split_req(&raw);
337 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
338 let p = json.get("proxy").unwrap();
339 assert_eq!(p.get("country").unwrap(), "us");
340 assert_eq!(p.get("tier").unwrap(), "mobile");
341 assert_eq!(p.get("session").unwrap(), "warm-1");
342 assert_eq!(p.get("sessionDuration").unwrap(), 15);
343 assert!(p.get("asn").is_none());
344 assert!(p.get("city").is_none());
345 }
346
347 #[tokio::test]
351 async fn retryable_hint_retries_idempotent_get() {
352 let (l, port) = bind_mock().await;
353 let pending =
354 r#"{"error":"replay still uploading","code":"ReplayPending","retryable":true}"#;
355 let ok = r#"{"url":"https://s3/replay.ndjson.gz"}"#;
356 let captured = serve(
357 l,
358 vec![(404, pending.to_string()), (200, ok.to_string())],
359 );
360
361 let client = client_for(port);
362 let link = client.sessions().replay_url("sess-1").await.unwrap();
363
364 assert_eq!(link.url, "https://s3/replay.ndjson.gz");
365 assert_eq!(captured.lock().unwrap().len(), 2);
367 }
368
369 #[tokio::test]
372 async fn no_hint_means_no_retry() {
373 let (l, port) = bind_mock().await;
374 let terminal = r#"{"error":"no replay","code":"ReplayUnavailable"}"#;
375 let captured = serve(
376 l,
377 vec![
378 (404, terminal.to_string()),
379 (200, r#"{"url":"never reached"}"#.to_string()),
380 ],
381 );
382
383 let client = client_for(port);
384 assert!(client.sessions().replay_url("sess-1").await.is_err());
385 assert_eq!(captured.lock().unwrap().len(), 1);
386 }
387
388 #[tokio::test]
397 async fn retryable_hint_honoured_on_keyed_create() {
398 let (l, port) = bind_mock().await;
399 let captured = serve(
400 l,
401 vec![
402 (409, r#"{"error":"in progress","retryable":true}"#.to_string()),
403 (201, r#"{"sessionId":"s_ok","wsEndpoint":"wss://x/ws/s_ok"}"#.to_string()),
404 ],
405 );
406
407 let client = client_for(port);
408 assert!(client
409 .sessions()
410 .create(CreateSessionOptions::default())
411 .await
412 .is_ok());
413 let reqs = captured.lock().unwrap();
414 assert_eq!(reqs.len(), 2, "the hint must be honoured on a keyed create");
415
416 fn idempotency_key(head: &str) -> Option<String> {
421 for line in head.lines() {
422 if let Some(v) = line.to_lowercase().strip_prefix("idempotency-key:") {
423 return Some(v.trim().to_string());
424 }
425 }
426 None
427 }
428 let (head1, _) = split_req(&reqs[0]);
429 let (head2, _) = split_req(&reqs[1]);
430 let key1 = idempotency_key(&head1).expect("first attempt must carry a key");
431 let key2 = idempotency_key(&head2).expect("second attempt must carry a key");
432 assert_eq!(key1, key2, "retries must reuse the SAME key, not mint a fresh one");
433 }
434
435 #[tokio::test]
437 async fn idempotency_keys_differ_between_calls() {
438 assert_ne!(
439 crate::http::new_idempotency_key(),
440 crate::http::new_idempotency_key()
441 );
442 }
443
444 #[tokio::test]
446 async fn retries_on_503() {
447 let (l, port) = bind_mock().await;
448 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
449 let captured = serve(
450 l,
451 vec![
452 (503, r#"{"error":"no capacity"}"#.to_string()),
453 (201, ok.to_string()),
454 ],
455 );
456
457 let client = client_for(port);
458 let session = client
459 .sessions()
460 .create(CreateSessionOptions::default())
461 .await
462 .unwrap();
463
464 assert_eq!(session.id, "s_ok");
465 assert_eq!(captured.lock().unwrap().len(), 2);
467 }
468
469 #[tokio::test]
472 async fn pool_response_unparseable_502_is_not_retried() {
473 let (l, port) = bind_mock().await;
474 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
475 let captured = serve(
476 l,
477 vec![
478 (
479 502,
480 r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#.to_string(),
481 ),
482 (201, ok.to_string()),
483 ],
484 );
485
486 let client = client_for(port);
487 let err = client
488 .sessions()
489 .create(CreateSessionOptions::default())
490 .await
491 .unwrap_err();
492
493 assert_eq!(err.status(), Some(502));
494 assert_eq!(captured.lock().unwrap().len(), 1, "retryable:false must not be retried");
495 }
496
497 #[tokio::test]
499 async fn retries_exhausted_on_persistent_503() {
500 let (l, port) = bind_mock().await;
501 let captured = serve(
502 l,
503 vec![
504 (503, "{}".to_string()),
505 (503, "{}".to_string()),
506 (201, "{}".to_string()), ],
508 );
509
510 let client = client_for(port);
511 let err = client
512 .sessions()
513 .create(CreateSessionOptions::default())
514 .await
515 .unwrap_err();
516
517 assert!(matches!(err, SolariError::Api { .. }), "{err:?}");
518 assert_eq!(err.status(), Some(503));
519 assert_eq!(captured.lock().unwrap().len(), 2);
520 }
521
522 #[tokio::test]
524 async fn final_5xx_keeps_its_code() {
525 let (l, port) = bind_mock().await;
526 let captured = serve(
527 l,
528 vec![
529 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
530 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
531 ],
532 );
533
534 let client = client_for(port);
535 let err = client
536 .sessions()
537 .create(CreateSessionOptions::default())
538 .await
539 .unwrap_err();
540
541 assert_eq!(err.status(), Some(503));
542 assert_eq!(err.code(), Some(&SolariErrorCode::BrowserUnhealthy));
543 assert_eq!(captured.lock().unwrap().len(), 2);
544 }
545
546 #[tokio::test]
548 async fn transport_failure_exhausts_as_transport_error() {
549 let (l, port) = bind_mock().await;
550 drop(l); let client = client_for(port);
552 let err = client
553 .sessions()
554 .create(CreateSessionOptions::default())
555 .await
556 .unwrap_err();
557
558 assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
559 assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
560 }
561
562 #[tokio::test]
564 async fn does_not_retry_on_400() {
565 let (l, port) = bind_mock().await;
566 let captured = serve(
567 l,
568 vec![
569 (400, r#"{"error":"bad proxy country"}"#.to_string()),
570 (201, "{}".to_string()), ],
572 );
573
574 let client = client_for(port);
575 let err = client
576 .sessions()
577 .create(CreateSessionOptions::default())
578 .await
579 .unwrap_err();
580
581 assert_eq!(err.status(), Some(400));
582 assert!(err.to_string().contains("bad proxy country"), "{err}");
583 assert_eq!(captured.lock().unwrap().len(), 1);
585 }
586
587 #[tokio::test]
590 async fn parses_error_codes() {
591 let (l, port) = bind_mock().await;
592 serve(
593 l,
594 vec![
595 (
596 402,
597 r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
598 .to_string(),
599 ),
600 (
601 429,
602 r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
603 ),
604 (400, r#"{"code":"SomethingNew"}"#.to_string()),
605 (400, "not json at all".to_string()),
606 ],
607 );
608
609 let client = client_for(port);
610
611 let err = client
612 .sessions()
613 .create(CreateSessionOptions::new().captcha(true))
614 .await
615 .unwrap_err();
616 assert_eq!(err.status(), Some(402));
617 assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
618 assert!(err.to_string().contains("captcha requires a paid plan"));
619
620 let err = client
621 .sessions()
622 .create(CreateSessionOptions::default())
623 .await
624 .unwrap_err();
625 assert_eq!(err.status(), Some(429));
626 assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
627
628 let err = client
630 .sessions()
631 .create(CreateSessionOptions::default())
632 .await
633 .unwrap_err();
634 assert_eq!(
635 err.code(),
636 Some(&SolariErrorCode::Other("SomethingNew".to_string()))
637 );
638
639 let err = client
641 .sessions()
642 .create(CreateSessionOptions::default())
643 .await
644 .unwrap_err();
645 assert_eq!(err.status(), Some(400));
646 assert_eq!(err.code(), None);
647 }
648
649 #[tokio::test]
651 async fn profiles_list_parses() {
652 let (l, port) = bind_mock().await;
653 let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
654 let captured = serve(l, vec![(200, body.to_string())]);
655
656 let client = client_for(port);
657 let profiles = client.profiles().list().await.unwrap();
658
659 assert_eq!(profiles.len(), 2);
660 assert_eq!(profiles[0].id, "p_1");
661 assert_eq!(profiles[0].name, "amazon");
662 assert_eq!(
664 profiles[0].extra.get("createdAt").unwrap(),
665 "2026-01-01T00:00:00Z"
666 );
667 assert_eq!(profiles[1].id, "p_2");
668 assert!(profiles[1].extra.is_empty());
669
670 let (head, _) = split_req(&captured.lock().unwrap()[0]);
671 assert!(head.starts_with("GET /profiles "), "head: {head}");
672 }
673
674 #[tokio::test]
676 async fn profiles_create_and_save() {
677 let (l, port) = bind_mock().await;
678 let captured = serve(
679 l,
680 vec![
681 (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
682 (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
683 (200, "{}".to_string()), ],
685 );
686
687 let client = client_for(port);
688 let profile = client.profiles().create("shopify").await.unwrap();
689 assert_eq!(profile.id, "p_9");
690 assert_eq!(profile.name, "shopify");
691
692 let state = StorageState {
693 cookies: Some(vec![Cookie {
694 name: "sid".into(),
695 value: "abc".into(),
696 domain: Some(".shopify.com".into()),
697 same_site: Some(SameSite::Lax),
698 ..Default::default()
699 }]),
700 ..Default::default()
701 };
702 let saved = client.profiles().save("p_9", &state).await.unwrap();
703 assert_eq!(saved.version, 3);
704 assert_eq!(saved.size_bytes, 2048);
705
706 let saved = client.profiles().save("p_9", &state).await.unwrap();
708 assert_eq!(saved.version, 0);
709 assert_eq!(saved.size_bytes, 0);
710
711 let reqs = captured.lock().unwrap().clone();
712 let (head, body) = split_req(&reqs[0]);
713 assert!(head.starts_with("POST /profiles "), "head: {head}");
714 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
715 assert_eq!(json.get("name").unwrap(), "shopify");
716
717 let (head, body) = split_req(&reqs[1]);
719 assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
720 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
721 let cookie = &json["storageState"]["cookies"][0];
722 assert_eq!(cookie.get("name").unwrap(), "sid");
723 assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
724 assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
725 assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
727 }
728
729 #[tokio::test]
731 async fn delete_tolerates_404() {
732 let (l, port) = bind_mock().await;
733 serve(
734 l,
735 vec![
736 (404, r#"{"error":"Not Found"}"#.to_string()),
737 (404, r#"{"error":"Not Found"}"#.to_string()),
738 (500, r#"{"error":"boom"}"#.to_string()),
739 ],
740 );
741
742 let client = client_for(port);
743 client.sessions().release("s_gone").await.unwrap();
744 client.profiles().delete("p_gone").await.unwrap();
745 let err = client.sessions().release("s_bad").await.unwrap_err();
747 assert_eq!(err.status(), Some(500));
748 }
749
750 #[tokio::test]
752 async fn replay_url_defaults() {
753 let (l, port) = bind_mock().await;
754 let captured = serve(
755 l,
756 vec![
757 (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
758 (
759 200,
760 r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
761 .to_string(),
762 ),
763 (200, r#"{"expiresInSeconds":600}"#.to_string()),
764 ],
765 );
766
767 let client = client_for(port);
768
769 let replay = client.sessions().replay_url("s_1").await.unwrap();
770 assert_eq!(replay.url, "https://s3.example.com/r1");
771 assert_eq!(replay.expires_in_seconds, 0);
772 assert_eq!(replay.content_encoding, "gzip");
773
774 let replay = client.sessions().replay_url("s_2").await.unwrap();
775 assert_eq!(replay.expires_in_seconds, 600);
776 assert_eq!(replay.content_encoding, "identity");
777
778 let err = client.sessions().replay_url("s_3").await.unwrap_err();
780 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
781
782 let (head, _) = split_req(&captured.lock().unwrap()[0]);
783 assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
784 }
785
786 #[tokio::test]
788 async fn proxy_countries() {
789 let (l, port) = bind_mock().await;
790 let captured = serve(
791 l,
792 vec![(
793 200,
794 r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
795 )],
796 );
797
798 let client = client_for(port);
799 let res = client.proxy().countries().await.unwrap();
800 assert!(res.enabled);
801 assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
802
803 let (head, _) = split_req(&captured.lock().unwrap()[0]);
804 assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
805 }
806
807 #[tokio::test]
809 async fn session_get_parses() {
810 let (l, port) = bind_mock().await;
811 serve(
812 l,
813 vec![(
814 200,
815 r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
816 .to_string(),
817 )],
818 );
819
820 let client = client_for(port);
821 let view = client.sessions().get("s_1").await.unwrap();
822 assert_eq!(view.session_id.as_deref(), Some("s_1"));
823 assert_eq!(view.status.as_deref(), Some("busy"));
824 assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
825 assert_eq!(view.extra.get("slot").unwrap(), 3);
826 }
827
828 #[test]
830 fn cdp_derivation() {
831 assert_eq!(
832 derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
833 "wss://api.getsolari.com/cdp/abc.def"
834 );
835 assert_eq!(
836 derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
837 "ws://127.0.0.1:3000/cdp/s_1?token=x"
838 );
839 assert_eq!(
841 derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
842 "wss://api.getsolari.com/other/abc"
843 );
844 assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
846 }
847
848 #[tokio::test]
850 async fn create_session_derives_cdp() {
851 let (l, port) = bind_mock().await;
852 let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
853 serve(l, vec![(201, body.to_string())]);
854
855 let client = client_for(port);
856 let session = client
857 .sessions()
858 .create(CreateSessionOptions::default())
859 .await
860 .unwrap();
861
862 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
863 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
864 assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
866 assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
867 }
868
869 #[tokio::test]
871 async fn create_session_rejects_bad_response() {
872 let (l, port) = bind_mock().await;
873 serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
874
875 let client = client_for(port);
876 let err = client
877 .sessions()
878 .create(CreateSessionOptions::default())
879 .await
880 .unwrap_err();
881 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
882 }
883
884 #[tokio::test]
886 async fn storage_state_fetched_from_presigned_url() {
887 let (l, port) = bind_mock().await;
888 let create = format!(
889 r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
890 );
891 let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
892 serve(l, vec![(201, create), (200, state.to_string())]);
893
894 let client = client_for(port);
895 let session = client
896 .sessions()
897 .create(CreateSessionOptions::new().profile_id("p_1"))
898 .await
899 .unwrap();
900
901 let state = session
902 .storage_state
903 .expect("profile attached → Some")
904 .expect("presigned state → Some");
905 let cookies = state.cookies.unwrap();
906 assert_eq!(cookies[0].name, "sid");
907 assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
908 let origins = state.origins.unwrap();
909 assert_eq!(origins[0].origin, "https://example.com");
910 assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
911 }
912
913 #[tokio::test]
915 async fn storage_state_absent_vs_empty() {
916 let (l, port) = bind_mock().await;
917 serve(
918 l,
919 vec![
920 (
922 201,
923 r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
924 .to_string(),
925 ),
926 (
928 201,
929 r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
930 ),
931 (
933 201,
934 r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
935 .to_string(),
936 ),
937 (
939 201,
940 r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
941 ),
942 ],
943 );
944
945 let client = client_for(port);
946
947 let s = client
948 .sessions()
949 .create(CreateSessionOptions::new().profile_id("p_1"))
950 .await
951 .unwrap();
952 assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
953
954 let s = client
955 .sessions()
956 .create(CreateSessionOptions::new().profile_id("p_1"))
957 .await
958 .unwrap();
959 assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
960
961 let s = client
962 .sessions()
963 .create(CreateSessionOptions::new().profile_id("p_1"))
964 .await
965 .unwrap();
966 let inline = s.storage_state.expect("Some").expect("Some");
967 assert_eq!(inline.cookies.unwrap().len(), 0);
968
969 let s = client
970 .sessions()
971 .create(CreateSessionOptions::default())
972 .await
973 .unwrap();
974 assert!(s.storage_state.is_none(), "no profile → None");
975 }
976
977 #[test]
979 fn client_options_and_defaults() {
980 let opts = ClientOptions::default_region("k");
981 assert_eq!(opts.base_url, "https://api.getsolari.com");
982 assert_eq!(opts.max_attempts, 2);
983 assert_eq!(opts.backoff_ms, 500);
984 assert_eq!(opts.timeout_ms, 90_000);
985
986 assert_eq!(
987 ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
988 "https://api.getsolari.com"
989 );
990
991 let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
993 assert_eq!(c.base_url(), "https://gw.example.com");
994
995 let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
997 assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
998 }
999
1000 #[test]
1003 fn iso8601_formatting() {
1004 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1005 assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1006 assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1007 assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1009 assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1011 assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1013 assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1015 }
1016
1017 #[test]
1019 fn error_code_round_trip() {
1020 for code in [
1021 SolariErrorCode::FeatureRequiresPlan,
1022 SolariErrorCode::ConcurrencyLimitExceeded,
1023 SolariErrorCode::PlanLimitExceeded,
1024 SolariErrorCode::BrowserUnhealthy,
1025 ] {
1026 assert_eq!(SolariErrorCode::from(code.as_str()), code);
1027 }
1028 assert_eq!(
1029 SolariErrorCode::from("BrowserUnhealthy"),
1030 SolariErrorCode::BrowserUnhealthy
1031 );
1032 assert_eq!(
1033 SolariErrorCode::Other("Nope".into()).to_string(),
1034 "Nope".to_string()
1035 );
1036 }
1037
1038 #[cfg(feature = "connect")]
1044 #[tokio::test]
1045 async fn launch_retries_create_itself_on_transient_exhaustion() {
1046 let (l, port) = bind_mock().await;
1047 let captured = serve(
1048 l,
1049 vec![
1050 (503, "{}".to_string()),
1053 (503, "{}".to_string()),
1054 (400, r#"{"error":"bad request"}"#.to_string()),
1058 ],
1059 );
1060
1061 let client = client_for(port);
1062 let result = client
1063 .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1064 .await;
1065 let Err(err) = result else {
1066 panic!("expected second create()'s 400 to end the loop, got Ok");
1067 };
1068
1069 assert_eq!(err.status(), Some(400));
1070 assert_eq!(captured.lock().unwrap().len(), 3);
1074 }
1075}