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