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]
471 async fn retries_exhausted_on_persistent_503() {
472 let (l, port) = bind_mock().await;
473 let captured = serve(
474 l,
475 vec![
476 (503, "{}".to_string()),
477 (503, "{}".to_string()),
478 (201, "{}".to_string()), ],
480 );
481
482 let client = client_for(port);
483 let err = client
484 .sessions()
485 .create(CreateSessionOptions::default())
486 .await
487 .unwrap_err();
488
489 assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
490 assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
491 assert_eq!(captured.lock().unwrap().len(), 2);
492 }
493
494 #[tokio::test]
496 async fn does_not_retry_on_400() {
497 let (l, port) = bind_mock().await;
498 let captured = serve(
499 l,
500 vec![
501 (400, r#"{"error":"bad proxy country"}"#.to_string()),
502 (201, "{}".to_string()), ],
504 );
505
506 let client = client_for(port);
507 let err = client
508 .sessions()
509 .create(CreateSessionOptions::default())
510 .await
511 .unwrap_err();
512
513 assert_eq!(err.status(), Some(400));
514 assert!(err.to_string().contains("bad proxy country"), "{err}");
515 assert_eq!(captured.lock().unwrap().len(), 1);
517 }
518
519 #[tokio::test]
522 async fn parses_error_codes() {
523 let (l, port) = bind_mock().await;
524 serve(
525 l,
526 vec![
527 (
528 402,
529 r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
530 .to_string(),
531 ),
532 (
533 429,
534 r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
535 ),
536 (400, r#"{"code":"SomethingNew"}"#.to_string()),
537 (400, "not json at all".to_string()),
538 ],
539 );
540
541 let client = client_for(port);
542
543 let err = client
544 .sessions()
545 .create(CreateSessionOptions::new().captcha(true))
546 .await
547 .unwrap_err();
548 assert_eq!(err.status(), Some(402));
549 assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
550 assert!(err.to_string().contains("captcha requires a paid plan"));
551
552 let err = client
553 .sessions()
554 .create(CreateSessionOptions::default())
555 .await
556 .unwrap_err();
557 assert_eq!(err.status(), Some(429));
558 assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
559
560 let err = client
562 .sessions()
563 .create(CreateSessionOptions::default())
564 .await
565 .unwrap_err();
566 assert_eq!(
567 err.code(),
568 Some(&SolariErrorCode::Other("SomethingNew".to_string()))
569 );
570
571 let err = client
573 .sessions()
574 .create(CreateSessionOptions::default())
575 .await
576 .unwrap_err();
577 assert_eq!(err.status(), Some(400));
578 assert_eq!(err.code(), None);
579 }
580
581 #[tokio::test]
583 async fn profiles_list_parses() {
584 let (l, port) = bind_mock().await;
585 let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
586 let captured = serve(l, vec![(200, body.to_string())]);
587
588 let client = client_for(port);
589 let profiles = client.profiles().list().await.unwrap();
590
591 assert_eq!(profiles.len(), 2);
592 assert_eq!(profiles[0].id, "p_1");
593 assert_eq!(profiles[0].name, "amazon");
594 assert_eq!(
596 profiles[0].extra.get("createdAt").unwrap(),
597 "2026-01-01T00:00:00Z"
598 );
599 assert_eq!(profiles[1].id, "p_2");
600 assert!(profiles[1].extra.is_empty());
601
602 let (head, _) = split_req(&captured.lock().unwrap()[0]);
603 assert!(head.starts_with("GET /profiles "), "head: {head}");
604 }
605
606 #[tokio::test]
608 async fn profiles_create_and_save() {
609 let (l, port) = bind_mock().await;
610 let captured = serve(
611 l,
612 vec![
613 (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
614 (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
615 (200, "{}".to_string()), ],
617 );
618
619 let client = client_for(port);
620 let profile = client.profiles().create("shopify").await.unwrap();
621 assert_eq!(profile.id, "p_9");
622 assert_eq!(profile.name, "shopify");
623
624 let state = StorageState {
625 cookies: Some(vec![Cookie {
626 name: "sid".into(),
627 value: "abc".into(),
628 domain: Some(".shopify.com".into()),
629 same_site: Some(SameSite::Lax),
630 ..Default::default()
631 }]),
632 ..Default::default()
633 };
634 let saved = client.profiles().save("p_9", &state).await.unwrap();
635 assert_eq!(saved.version, 3);
636 assert_eq!(saved.size_bytes, 2048);
637
638 let saved = client.profiles().save("p_9", &state).await.unwrap();
640 assert_eq!(saved.version, 0);
641 assert_eq!(saved.size_bytes, 0);
642
643 let reqs = captured.lock().unwrap().clone();
644 let (head, body) = split_req(&reqs[0]);
645 assert!(head.starts_with("POST /profiles "), "head: {head}");
646 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
647 assert_eq!(json.get("name").unwrap(), "shopify");
648
649 let (head, body) = split_req(&reqs[1]);
651 assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
652 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
653 let cookie = &json["storageState"]["cookies"][0];
654 assert_eq!(cookie.get("name").unwrap(), "sid");
655 assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
656 assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
657 assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
659 }
660
661 #[tokio::test]
663 async fn delete_tolerates_404() {
664 let (l, port) = bind_mock().await;
665 serve(
666 l,
667 vec![
668 (404, r#"{"error":"Not Found"}"#.to_string()),
669 (404, r#"{"error":"Not Found"}"#.to_string()),
670 (500, r#"{"error":"boom"}"#.to_string()),
671 ],
672 );
673
674 let client = client_for(port);
675 client.sessions().release("s_gone").await.unwrap();
676 client.profiles().delete("p_gone").await.unwrap();
677 let err = client.sessions().release("s_bad").await.unwrap_err();
679 assert_eq!(err.status(), Some(500));
680 }
681
682 #[tokio::test]
684 async fn replay_url_defaults() {
685 let (l, port) = bind_mock().await;
686 let captured = serve(
687 l,
688 vec![
689 (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
690 (
691 200,
692 r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
693 .to_string(),
694 ),
695 (200, r#"{"expiresInSeconds":600}"#.to_string()),
696 ],
697 );
698
699 let client = client_for(port);
700
701 let replay = client.sessions().replay_url("s_1").await.unwrap();
702 assert_eq!(replay.url, "https://s3.example.com/r1");
703 assert_eq!(replay.expires_in_seconds, 0);
704 assert_eq!(replay.content_encoding, "gzip");
705
706 let replay = client.sessions().replay_url("s_2").await.unwrap();
707 assert_eq!(replay.expires_in_seconds, 600);
708 assert_eq!(replay.content_encoding, "identity");
709
710 let err = client.sessions().replay_url("s_3").await.unwrap_err();
712 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
713
714 let (head, _) = split_req(&captured.lock().unwrap()[0]);
715 assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
716 }
717
718 #[tokio::test]
720 async fn proxy_countries() {
721 let (l, port) = bind_mock().await;
722 let captured = serve(
723 l,
724 vec![(
725 200,
726 r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
727 )],
728 );
729
730 let client = client_for(port);
731 let res = client.proxy().countries().await.unwrap();
732 assert!(res.enabled);
733 assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
734
735 let (head, _) = split_req(&captured.lock().unwrap()[0]);
736 assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
737 }
738
739 #[tokio::test]
741 async fn session_get_parses() {
742 let (l, port) = bind_mock().await;
743 serve(
744 l,
745 vec![(
746 200,
747 r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
748 .to_string(),
749 )],
750 );
751
752 let client = client_for(port);
753 let view = client.sessions().get("s_1").await.unwrap();
754 assert_eq!(view.session_id.as_deref(), Some("s_1"));
755 assert_eq!(view.status.as_deref(), Some("busy"));
756 assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
757 assert_eq!(view.extra.get("slot").unwrap(), 3);
758 }
759
760 #[test]
762 fn cdp_derivation() {
763 assert_eq!(
764 derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
765 "wss://api.getsolari.com/cdp/abc.def"
766 );
767 assert_eq!(
768 derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
769 "ws://127.0.0.1:3000/cdp/s_1?token=x"
770 );
771 assert_eq!(
773 derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
774 "wss://api.getsolari.com/other/abc"
775 );
776 assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
778 }
779
780 #[tokio::test]
782 async fn create_session_derives_cdp() {
783 let (l, port) = bind_mock().await;
784 let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
785 serve(l, vec![(201, body.to_string())]);
786
787 let client = client_for(port);
788 let session = client
789 .sessions()
790 .create(CreateSessionOptions::default())
791 .await
792 .unwrap();
793
794 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
795 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
796 assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
798 assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
799 }
800
801 #[tokio::test]
803 async fn create_session_rejects_bad_response() {
804 let (l, port) = bind_mock().await;
805 serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
806
807 let client = client_for(port);
808 let err = client
809 .sessions()
810 .create(CreateSessionOptions::default())
811 .await
812 .unwrap_err();
813 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
814 }
815
816 #[tokio::test]
818 async fn storage_state_fetched_from_presigned_url() {
819 let (l, port) = bind_mock().await;
820 let create = format!(
821 r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
822 );
823 let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
824 serve(l, vec![(201, create), (200, state.to_string())]);
825
826 let client = client_for(port);
827 let session = client
828 .sessions()
829 .create(CreateSessionOptions::new().profile_id("p_1"))
830 .await
831 .unwrap();
832
833 let state = session
834 .storage_state
835 .expect("profile attached → Some")
836 .expect("presigned state → Some");
837 let cookies = state.cookies.unwrap();
838 assert_eq!(cookies[0].name, "sid");
839 assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
840 let origins = state.origins.unwrap();
841 assert_eq!(origins[0].origin, "https://example.com");
842 assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
843 }
844
845 #[tokio::test]
847 async fn storage_state_absent_vs_empty() {
848 let (l, port) = bind_mock().await;
849 serve(
850 l,
851 vec![
852 (
854 201,
855 r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
856 .to_string(),
857 ),
858 (
860 201,
861 r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
862 ),
863 (
865 201,
866 r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
867 .to_string(),
868 ),
869 (
871 201,
872 r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
873 ),
874 ],
875 );
876
877 let client = client_for(port);
878
879 let s = client
880 .sessions()
881 .create(CreateSessionOptions::new().profile_id("p_1"))
882 .await
883 .unwrap();
884 assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
885
886 let s = client
887 .sessions()
888 .create(CreateSessionOptions::new().profile_id("p_1"))
889 .await
890 .unwrap();
891 assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
892
893 let s = client
894 .sessions()
895 .create(CreateSessionOptions::new().profile_id("p_1"))
896 .await
897 .unwrap();
898 let inline = s.storage_state.expect("Some").expect("Some");
899 assert_eq!(inline.cookies.unwrap().len(), 0);
900
901 let s = client
902 .sessions()
903 .create(CreateSessionOptions::default())
904 .await
905 .unwrap();
906 assert!(s.storage_state.is_none(), "no profile → None");
907 }
908
909 #[test]
911 fn client_options_and_defaults() {
912 let opts = ClientOptions::default_region("k");
913 assert_eq!(opts.base_url, "https://api.getsolari.com");
914 assert_eq!(opts.max_attempts, 2);
915 assert_eq!(opts.backoff_ms, 500);
916 assert_eq!(opts.timeout_ms, 90_000);
917
918 assert_eq!(
919 ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
920 "https://api.getsolari.com"
921 );
922
923 let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
925 assert_eq!(c.base_url(), "https://gw.example.com");
926
927 let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
929 assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
930 }
931
932 #[test]
935 fn iso8601_formatting() {
936 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
937 assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
938 assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
939 assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
941 assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
943 assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
945 assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
947 }
948
949 #[test]
951 fn error_code_round_trip() {
952 for code in [
953 SolariErrorCode::FeatureRequiresPlan,
954 SolariErrorCode::ConcurrencyLimitExceeded,
955 SolariErrorCode::PlanLimitExceeded,
956 SolariErrorCode::BrowserUnhealthy,
957 ] {
958 assert_eq!(SolariErrorCode::from(code.as_str()), code);
959 }
960 assert_eq!(
961 SolariErrorCode::from("BrowserUnhealthy"),
962 SolariErrorCode::BrowserUnhealthy
963 );
964 assert_eq!(
965 SolariErrorCode::Other("Nope".into()).to_string(),
966 "Nope".to_string()
967 );
968 }
969
970 #[cfg(feature = "connect")]
976 #[tokio::test]
977 async fn launch_retries_create_itself_on_transient_exhaustion() {
978 let (l, port) = bind_mock().await;
979 let captured = serve(
980 l,
981 vec![
982 (503, "{}".to_string()),
985 (503, "{}".to_string()),
986 (400, r#"{"error":"bad request"}"#.to_string()),
990 ],
991 );
992
993 let client = client_for(port);
994 let result = client
995 .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
996 .await;
997 let Err(err) = result else {
998 panic!("expected second create()'s 400 to end the loop, got Ok");
999 };
1000
1001 assert_eq!(err.status(), Some(400));
1002 assert_eq!(captured.lock().unwrap().len(), 3);
1006 }
1007}