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