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::{
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 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 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 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; }
118 buf.extend_from_slice(&tmp[..n]);
119 let Some(pos) = find_subslice(&buf, b"\r\n\r\n") else {
120 continue; };
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 #[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 #[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 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 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 #[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 .timeout_seconds(120)
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 #[tokio::test]
322 async fn new_create_options_are_omitted_when_unset() {
323 let (l, port) = bind_mock().await;
324 let captured = serve(
325 l,
326 vec![(201, r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#.to_string())],
327 );
328 client_for(port)
329 .sessions()
330 .create(CreateSessionOptions::default())
331 .await
332 .unwrap();
333 let raw = captured.lock().unwrap()[0].clone();
334 let (_, body) = split_req(&raw);
335 assert!(body.trim().is_empty(), "body: {body}");
336 }
337
338 #[tokio::test]
340 async fn get_returns_the_gateway_view() {
341 let (l, port) = bind_mock().await;
342 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"}"#;
343 let _ = serve(l, vec![(200, body.to_string())]);
344 let v = client_for(port).sessions().get("s_1").await.unwrap();
345 assert_eq!(v.id.as_deref(), Some("s_1"));
346 assert_eq!(v.kind.as_deref(), Some("stealth"));
347 assert_eq!(v.org.as_deref(), Some("o1"));
348 assert_eq!(v.user_metadata.unwrap()["a"], "b");
349 assert!(v.cdp_endpoint.is_some() && v.ws_endpoint.is_some());
350 }
351
352 #[tokio::test]
353 async fn list_sends_paging_and_the_metadata_filter_and_parses_the_page() {
354 let (l, port) = bind_mock().await;
355 let body = r#"{"sessions":[{"id":"s1","status":"active","kind":"fast","createdAt":"c","expiresAt":"e","userMetadata":{"team":"x"}},{"id":"s2","status":"released"}],"nextCursor":"cur2"}"#;
356 let captured = serve(l, vec![(200, body.to_string())]);
357
358 let mut opts = ListSessionsOptions::default();
359 opts.limit = Some(2);
360 opts.cursor = Some("cur1".into());
361 opts.metadata.insert("team".into(), "x".into());
362 opts.metadata.insert("env".into(), "prod".into());
363 let page = client_for(port).sessions().list(opts).await.unwrap();
364
365 let raw = captured.lock().unwrap()[0].clone();
366 let (head, _) = split_req(&raw);
367 assert!(head.starts_with("GET /sessions?"), "head: {head}");
368 assert!(head.contains("limit=2"), "head: {head}");
369 assert!(head.contains("cursor=cur1"), "head: {head}");
370 assert!(
372 head.contains("q=user_metadata%5B%27env%27%5D%3A%27prod%27%20AND%20user_metadata%5B%27team%27%5D%3A%27x%27"),
373 "head: {head}"
374 );
375 assert_eq!(page.sessions.len(), 2);
376 assert_eq!(page.sessions[0].id, "s1");
377 assert_eq!(page.sessions[0].user_metadata.as_ref().unwrap()["team"], "x");
378 assert_eq!(page.sessions[1].status, "released");
379 assert_eq!(page.next_cursor.as_deref(), Some("cur2"));
380 }
381
382 #[tokio::test]
383 async fn list_with_default_options_requests_a_bare_path_and_handles_an_empty_page() {
384 let (l, port) = bind_mock().await;
385 let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
386 let page = client_for(port)
387 .sessions()
388 .list(ListSessionsOptions::default())
389 .await
390 .unwrap();
391 let raw = captured.lock().unwrap()[0].clone();
392 let (head, _) = split_req(&raw);
393 assert!(head.starts_with("GET /sessions HTTP/1.1"), "head: {head}");
394 assert!(page.sessions.is_empty());
395 assert!(page.next_cursor.is_none());
396 }
397
398 #[tokio::test]
400 async fn list_refuses_an_unexpressible_filter_without_a_request() {
401 let (l, port) = bind_mock().await;
402 let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
403 let client = client_for(port);
404
405 for (k, v) in [("k", "it's"), ("k'", "v"), ("", "v")] {
406 let mut opts = ListSessionsOptions::default();
407 opts.metadata.insert(k.into(), v.into());
408 assert!(client.sessions().list(opts).await.is_err(), "{k:?}={v:?}");
409 }
410 let mut many = ListSessionsOptions::default();
411 for i in 0..11 {
412 many.metadata.insert(format!("k{i}"), "v".into());
413 }
414 assert!(client.sessions().list(many).await.is_err());
415 assert!(captured.lock().unwrap().is_empty(), "no request may be sent");
416 }
417
418 #[tokio::test]
420 async fn list_surfaces_a_gateway_validation_error() {
421 let (l, port) = bind_mock().await;
422 let _ = serve(
423 l,
424 vec![(400, r#"{"error":"Invalid q","code":"ValidationError","field":"q"}"#.to_string())],
425 );
426 let err = client_for(port)
427 .sessions()
428 .list(ListSessionsOptions::default())
429 .await
430 .unwrap_err();
431 assert_eq!(err.status(), Some(400));
432 }
433
434 #[tokio::test]
436 async fn create_session_omits_empty_body() {
437 let (l, port) = bind_mock().await;
438 let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
439 let captured = serve(l, vec![(201, body.to_string())]);
440
441 let client = client_for(port);
442 client
443 .sessions()
444 .create(CreateSessionOptions::default())
445 .await
446 .unwrap();
447
448 let raw = captured.lock().unwrap()[0].clone();
449 let (head, body) = split_req(&raw);
450 assert!(body.trim().is_empty(), "expected no body, got: {body}");
451 assert!(
452 !head.to_lowercase().contains("content-length: 2"),
453 "head: {head}"
454 );
455 }
456
457 #[tokio::test]
459 async fn create_session_proxy_request_shape() {
460 let (l, port) = bind_mock().await;
461 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"}}"#;
462 let captured = serve(l, vec![(201, body.to_string())]);
463
464 let client = client_for(port);
465 let session = client
466 .sessions()
467 .create(CreateSessionOptions::new().stealth(true).proxy(
468 ProxyRequest::country("us")
469 .tier(ProxyTier::Mobile)
470 .session("warm-1")
471 .session_duration(15),
472 ))
473 .await
474 .unwrap();
475
476 let proxy = session.proxy.expect("resolved proxy");
477 assert_eq!(proxy.country, "us");
478 assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
479 assert_eq!(proxy.timezone_id, "America/Los_Angeles");
480
481 let raw = captured.lock().unwrap()[0].clone();
482 let (_, body) = split_req(&raw);
483 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
484 let p = json.get("proxy").unwrap();
485 assert_eq!(p.get("country").unwrap(), "us");
486 assert_eq!(p.get("tier").unwrap(), "mobile");
487 assert_eq!(p.get("session").unwrap(), "warm-1");
488 assert_eq!(p.get("sessionDuration").unwrap(), 15);
489 assert!(p.get("asn").is_none());
490 assert!(p.get("city").is_none());
491 }
492
493 #[tokio::test]
497 async fn retryable_hint_retries_idempotent_get() {
498 let (l, port) = bind_mock().await;
499 let pending =
500 r#"{"error":"replay still uploading","code":"ReplayPending","retryable":true}"#;
501 let ok = r#"{"url":"https://s3/replay.ndjson.gz"}"#;
502 let captured = serve(
503 l,
504 vec![(404, pending.to_string()), (200, ok.to_string())],
505 );
506
507 let client = client_for(port);
508 let link = client.sessions().replay_url("sess-1").await.unwrap();
509
510 assert_eq!(link.url, "https://s3/replay.ndjson.gz");
511 assert_eq!(captured.lock().unwrap().len(), 2);
513 }
514
515 #[tokio::test]
518 async fn no_hint_means_no_retry() {
519 let (l, port) = bind_mock().await;
520 let terminal = r#"{"error":"no replay","code":"ReplayUnavailable"}"#;
521 let captured = serve(
522 l,
523 vec![
524 (404, terminal.to_string()),
525 (200, r#"{"url":"never reached"}"#.to_string()),
526 ],
527 );
528
529 let client = client_for(port);
530 assert!(client.sessions().replay_url("sess-1").await.is_err());
531 assert_eq!(captured.lock().unwrap().len(), 1);
532 }
533
534 #[tokio::test]
543 async fn retryable_hint_honoured_on_keyed_create() {
544 let (l, port) = bind_mock().await;
545 let captured = serve(
546 l,
547 vec![
548 (409, r#"{"error":"in progress","retryable":true}"#.to_string()),
549 (201, r#"{"sessionId":"s_ok","wsEndpoint":"wss://x/ws/s_ok"}"#.to_string()),
550 ],
551 );
552
553 let client = client_for(port);
554 assert!(client
555 .sessions()
556 .create(CreateSessionOptions::default())
557 .await
558 .is_ok());
559 let reqs = captured.lock().unwrap();
560 assert_eq!(reqs.len(), 2, "the hint must be honoured on a keyed create");
561
562 fn idempotency_key(head: &str) -> Option<String> {
567 for line in head.lines() {
568 if let Some(v) = line.to_lowercase().strip_prefix("idempotency-key:") {
569 return Some(v.trim().to_string());
570 }
571 }
572 None
573 }
574 let (head1, _) = split_req(&reqs[0]);
575 let (head2, _) = split_req(&reqs[1]);
576 let key1 = idempotency_key(&head1).expect("first attempt must carry a key");
577 let key2 = idempotency_key(&head2).expect("second attempt must carry a key");
578 assert_eq!(key1, key2, "retries must reuse the SAME key, not mint a fresh one");
579 }
580
581 #[tokio::test]
583 async fn idempotency_keys_differ_between_calls() {
584 assert_ne!(
585 crate::http::new_idempotency_key(),
586 crate::http::new_idempotency_key()
587 );
588 }
589
590 #[tokio::test]
592 async fn retries_on_503() {
593 let (l, port) = bind_mock().await;
594 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
595 let captured = serve(
596 l,
597 vec![
598 (503, r#"{"error":"no capacity"}"#.to_string()),
599 (201, ok.to_string()),
600 ],
601 );
602
603 let client = client_for(port);
604 let session = client
605 .sessions()
606 .create(CreateSessionOptions::default())
607 .await
608 .unwrap();
609
610 assert_eq!(session.id, "s_ok");
611 assert_eq!(captured.lock().unwrap().len(), 2);
613 }
614
615 #[tokio::test]
618 async fn pool_response_unparseable_502_is_not_retried() {
619 let (l, port) = bind_mock().await;
620 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
621 let captured = serve(
622 l,
623 vec![
624 (
625 502,
626 r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#.to_string(),
627 ),
628 (201, ok.to_string()),
629 ],
630 );
631
632 let client = client_for(port);
633 let err = client
634 .sessions()
635 .create(CreateSessionOptions::default())
636 .await
637 .unwrap_err();
638
639 assert_eq!(err.status(), Some(502));
640 assert_eq!(captured.lock().unwrap().len(), 1, "retryable:false must not be retried");
641 }
642
643 #[tokio::test]
645 async fn retries_exhausted_on_persistent_503() {
646 let (l, port) = bind_mock().await;
647 let captured = serve(
648 l,
649 vec![
650 (503, "{}".to_string()),
651 (503, "{}".to_string()),
652 (201, "{}".to_string()), ],
654 );
655
656 let client = client_for(port);
657 let err = client
658 .sessions()
659 .create(CreateSessionOptions::default())
660 .await
661 .unwrap_err();
662
663 assert!(matches!(err, SolariError::Api { .. }), "{err:?}");
664 assert_eq!(err.status(), Some(503));
665 assert_eq!(captured.lock().unwrap().len(), 2);
666 }
667
668 #[tokio::test]
670 async fn final_5xx_keeps_its_code() {
671 let (l, port) = bind_mock().await;
672 let captured = serve(
673 l,
674 vec![
675 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
676 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
677 ],
678 );
679
680 let client = client_for(port);
681 let err = client
682 .sessions()
683 .create(CreateSessionOptions::default())
684 .await
685 .unwrap_err();
686
687 assert_eq!(err.status(), Some(503));
688 assert_eq!(err.code(), Some(&SolariErrorCode::BrowserUnhealthy));
689 assert_eq!(captured.lock().unwrap().len(), 2);
690 }
691
692 #[tokio::test]
694 async fn transport_failure_exhausts_as_transport_error() {
695 let (l, port) = bind_mock().await;
696 drop(l); let client = client_for(port);
698 let err = client
699 .sessions()
700 .create(CreateSessionOptions::default())
701 .await
702 .unwrap_err();
703
704 assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
705 assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
706 }
707
708 #[tokio::test]
710 async fn does_not_retry_on_400() {
711 let (l, port) = bind_mock().await;
712 let captured = serve(
713 l,
714 vec![
715 (400, r#"{"error":"bad proxy country"}"#.to_string()),
716 (201, "{}".to_string()), ],
718 );
719
720 let client = client_for(port);
721 let err = client
722 .sessions()
723 .create(CreateSessionOptions::default())
724 .await
725 .unwrap_err();
726
727 assert_eq!(err.status(), Some(400));
728 assert!(err.to_string().contains("bad proxy country"), "{err}");
729 assert_eq!(captured.lock().unwrap().len(), 1);
731 }
732
733 #[tokio::test]
736 async fn parses_error_codes() {
737 let (l, port) = bind_mock().await;
738 serve(
739 l,
740 vec![
741 (
742 402,
743 r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
744 .to_string(),
745 ),
746 (
747 429,
748 r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
749 ),
750 (400, r#"{"code":"SomethingNew"}"#.to_string()),
751 (400, "not json at all".to_string()),
752 ],
753 );
754
755 let client = client_for(port);
756
757 let err = client
758 .sessions()
759 .create(CreateSessionOptions::new().captcha(true))
760 .await
761 .unwrap_err();
762 assert_eq!(err.status(), Some(402));
763 assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
764 assert!(err.to_string().contains("captcha requires a paid plan"));
765
766 let err = client
767 .sessions()
768 .create(CreateSessionOptions::default())
769 .await
770 .unwrap_err();
771 assert_eq!(err.status(), Some(429));
772 assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
773
774 let err = client
776 .sessions()
777 .create(CreateSessionOptions::default())
778 .await
779 .unwrap_err();
780 assert_eq!(
781 err.code(),
782 Some(&SolariErrorCode::Other("SomethingNew".to_string()))
783 );
784
785 let err = client
787 .sessions()
788 .create(CreateSessionOptions::default())
789 .await
790 .unwrap_err();
791 assert_eq!(err.status(), Some(400));
792 assert_eq!(err.code(), None);
793 }
794
795 #[tokio::test]
797 async fn profiles_list_parses() {
798 let (l, port) = bind_mock().await;
799 let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
800 let captured = serve(l, vec![(200, body.to_string())]);
801
802 let client = client_for(port);
803 let profiles = client.profiles().list().await.unwrap();
804
805 assert_eq!(profiles.len(), 2);
806 assert_eq!(profiles[0].id, "p_1");
807 assert_eq!(profiles[0].name, "amazon");
808 assert_eq!(
810 profiles[0].extra.get("createdAt").unwrap(),
811 "2026-01-01T00:00:00Z"
812 );
813 assert_eq!(profiles[1].id, "p_2");
814 assert!(profiles[1].extra.is_empty());
815
816 let (head, _) = split_req(&captured.lock().unwrap()[0]);
817 assert!(head.starts_with("GET /profiles "), "head: {head}");
818 }
819
820 #[tokio::test]
822 async fn profiles_create_and_save() {
823 let (l, port) = bind_mock().await;
824 let captured = serve(
825 l,
826 vec![
827 (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
828 (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
829 (200, "{}".to_string()), ],
831 );
832
833 let client = client_for(port);
834 let profile = client.profiles().create("shopify").await.unwrap();
835 assert_eq!(profile.id, "p_9");
836 assert_eq!(profile.name, "shopify");
837
838 let state = StorageState {
839 cookies: Some(vec![Cookie {
840 name: "sid".into(),
841 value: "abc".into(),
842 domain: Some(".shopify.com".into()),
843 same_site: Some(SameSite::Lax),
844 ..Default::default()
845 }]),
846 ..Default::default()
847 };
848 let saved = client.profiles().save("p_9", &state).await.unwrap();
849 assert_eq!(saved.version, 3);
850 assert_eq!(saved.size_bytes, 2048);
851
852 let saved = client.profiles().save("p_9", &state).await.unwrap();
854 assert_eq!(saved.version, 0);
855 assert_eq!(saved.size_bytes, 0);
856
857 let reqs = captured.lock().unwrap().clone();
858 let (head, body) = split_req(&reqs[0]);
859 assert!(head.starts_with("POST /profiles "), "head: {head}");
860 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
861 assert_eq!(json.get("name").unwrap(), "shopify");
862
863 let (head, body) = split_req(&reqs[1]);
865 assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
866 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
867 let cookie = &json["storageState"]["cookies"][0];
868 assert_eq!(cookie.get("name").unwrap(), "sid");
869 assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
870 assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
871 assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
873 }
874
875 #[tokio::test]
877 async fn delete_tolerates_404() {
878 let (l, port) = bind_mock().await;
879 serve(
880 l,
881 vec![
882 (404, r#"{"error":"Not Found"}"#.to_string()),
883 (404, r#"{"error":"Not Found"}"#.to_string()),
884 (500, r#"{"error":"boom"}"#.to_string()),
885 ],
886 );
887
888 let client = client_for(port);
889 client.sessions().release("s_gone").await.unwrap();
890 client.profiles().delete("p_gone").await.unwrap();
891 let err = client.sessions().release("s_bad").await.unwrap_err();
893 assert_eq!(err.status(), Some(500));
894 }
895
896 #[tokio::test]
898 async fn replay_url_defaults() {
899 let (l, port) = bind_mock().await;
900 let captured = serve(
901 l,
902 vec![
903 (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
904 (
905 200,
906 r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
907 .to_string(),
908 ),
909 (200, r#"{"expiresInSeconds":600}"#.to_string()),
910 ],
911 );
912
913 let client = client_for(port);
914
915 let replay = client.sessions().replay_url("s_1").await.unwrap();
916 assert_eq!(replay.url, "https://s3.example.com/r1");
917 assert_eq!(replay.expires_in_seconds, 0);
918 assert_eq!(replay.content_encoding, "gzip");
919
920 let replay = client.sessions().replay_url("s_2").await.unwrap();
921 assert_eq!(replay.expires_in_seconds, 600);
922 assert_eq!(replay.content_encoding, "identity");
923
924 let err = client.sessions().replay_url("s_3").await.unwrap_err();
926 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
927
928 let (head, _) = split_req(&captured.lock().unwrap()[0]);
929 assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
930 }
931
932 #[tokio::test]
934 async fn proxy_countries() {
935 let (l, port) = bind_mock().await;
936 let captured = serve(
937 l,
938 vec![(
939 200,
940 r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
941 )],
942 );
943
944 let client = client_for(port);
945 let res = client.proxy().countries().await.unwrap();
946 assert!(res.enabled);
947 assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
948
949 let (head, _) = split_req(&captured.lock().unwrap()[0]);
950 assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
951 }
952
953 #[tokio::test]
955 async fn session_get_parses() {
956 let (l, port) = bind_mock().await;
957 serve(
958 l,
959 vec![(
960 200,
961 r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
962 .to_string(),
963 )],
964 );
965
966 let client = client_for(port);
967 let view = client.sessions().get("s_1").await.unwrap();
968 assert_eq!(view.session_id.as_deref(), Some("s_1"));
969 assert_eq!(view.status.as_deref(), Some("busy"));
970 assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
971 assert_eq!(view.extra.get("slot").unwrap(), 3);
972 }
973
974 #[test]
976 fn cdp_derivation() {
977 assert_eq!(
978 derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
979 "wss://api.getsolari.com/cdp/abc.def"
980 );
981 assert_eq!(
982 derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
983 "ws://127.0.0.1:3000/cdp/s_1?token=x"
984 );
985 assert_eq!(
987 derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
988 "wss://api.getsolari.com/other/abc"
989 );
990 assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
992 }
993
994 #[tokio::test]
996 async fn create_session_derives_cdp() {
997 let (l, port) = bind_mock().await;
998 let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
999 serve(l, vec![(201, body.to_string())]);
1000
1001 let client = client_for(port);
1002 let session = client
1003 .sessions()
1004 .create(CreateSessionOptions::default())
1005 .await
1006 .unwrap();
1007
1008 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
1009 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
1010 assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
1012 assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
1013 }
1014
1015 #[tokio::test]
1017 async fn create_session_rejects_bad_response() {
1018 let (l, port) = bind_mock().await;
1019 serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
1020
1021 let client = client_for(port);
1022 let err = client
1023 .sessions()
1024 .create(CreateSessionOptions::default())
1025 .await
1026 .unwrap_err();
1027 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
1028 }
1029
1030 #[tokio::test]
1032 async fn storage_state_fetched_from_presigned_url() {
1033 let (l, port) = bind_mock().await;
1034 let create = format!(
1035 r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
1036 );
1037 let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
1038 serve(l, vec![(201, create), (200, state.to_string())]);
1039
1040 let client = client_for(port);
1041 let session = client
1042 .sessions()
1043 .create(CreateSessionOptions::new().profile_id("p_1"))
1044 .await
1045 .unwrap();
1046
1047 let state = session
1048 .storage_state
1049 .expect("profile attached → Some")
1050 .expect("presigned state → Some");
1051 let cookies = state.cookies.unwrap();
1052 assert_eq!(cookies[0].name, "sid");
1053 assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
1054 let origins = state.origins.unwrap();
1055 assert_eq!(origins[0].origin, "https://example.com");
1056 assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
1057 }
1058
1059 #[tokio::test]
1061 async fn storage_state_absent_vs_empty() {
1062 let (l, port) = bind_mock().await;
1063 serve(
1064 l,
1065 vec![
1066 (
1068 201,
1069 r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
1070 .to_string(),
1071 ),
1072 (
1074 201,
1075 r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
1076 ),
1077 (
1079 201,
1080 r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
1081 .to_string(),
1082 ),
1083 (
1085 201,
1086 r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
1087 ),
1088 ],
1089 );
1090
1091 let client = client_for(port);
1092
1093 let s = client
1094 .sessions()
1095 .create(CreateSessionOptions::new().profile_id("p_1"))
1096 .await
1097 .unwrap();
1098 assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
1099
1100 let s = client
1101 .sessions()
1102 .create(CreateSessionOptions::new().profile_id("p_1"))
1103 .await
1104 .unwrap();
1105 assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
1106
1107 let s = client
1108 .sessions()
1109 .create(CreateSessionOptions::new().profile_id("p_1"))
1110 .await
1111 .unwrap();
1112 let inline = s.storage_state.expect("Some").expect("Some");
1113 assert_eq!(inline.cookies.unwrap().len(), 0);
1114
1115 let s = client
1116 .sessions()
1117 .create(CreateSessionOptions::default())
1118 .await
1119 .unwrap();
1120 assert!(s.storage_state.is_none(), "no profile → None");
1121 }
1122
1123 #[test]
1125 fn client_options_and_defaults() {
1126 let opts = ClientOptions::default_region("k");
1127 assert_eq!(opts.base_url, "https://api.getsolari.com");
1128 assert_eq!(opts.max_attempts, 2);
1129 assert_eq!(opts.backoff_ms, 500);
1130 assert_eq!(opts.timeout_ms, 90_000);
1131
1132 assert_eq!(
1133 ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
1134 "https://api.getsolari.com"
1135 );
1136
1137 let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
1139 assert_eq!(c.base_url(), "https://gw.example.com");
1140
1141 let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
1143 assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
1144 }
1145
1146 #[test]
1149 fn iso8601_formatting() {
1150 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1151 assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1152 assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1153 assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1155 assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1157 assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1159 assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1161 }
1162
1163 #[test]
1165 fn error_code_round_trip() {
1166 for code in [
1167 SolariErrorCode::FeatureRequiresPlan,
1168 SolariErrorCode::ConcurrencyLimitExceeded,
1169 SolariErrorCode::PlanLimitExceeded,
1170 SolariErrorCode::BrowserUnhealthy,
1171 ] {
1172 assert_eq!(SolariErrorCode::from(code.as_str()), code);
1173 }
1174 assert_eq!(
1175 SolariErrorCode::from("BrowserUnhealthy"),
1176 SolariErrorCode::BrowserUnhealthy
1177 );
1178 assert_eq!(
1179 SolariErrorCode::Other("Nope".into()).to_string(),
1180 "Nope".to_string()
1181 );
1182 }
1183
1184 #[cfg(feature = "connect")]
1190 #[tokio::test]
1191 async fn launch_retries_create_itself_on_transient_exhaustion() {
1192 let (l, port) = bind_mock().await;
1193 let captured = serve(
1194 l,
1195 vec![
1196 (503, "{}".to_string()),
1199 (503, "{}".to_string()),
1200 (400, r#"{"error":"bad request"}"#.to_string()),
1204 ],
1205 );
1206
1207 let client = client_for(port);
1208 let result = client
1209 .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1210 .await;
1211 let Err(err) = result else {
1212 panic!("expected second create()'s 400 to end the loop, got Ok");
1213 };
1214
1215 assert_eq!(err.status(), Some(400));
1216 assert_eq!(captured.lock().unwrap().len(), 3);
1220 }
1221}