1mod client;
56mod error;
57mod profile_editor;
58mod http;
59mod types;
60
61#[cfg(feature = "connect")]
62mod connect;
63
64#[cfg(feature = "connect")]
65mod session;
66
67pub use client::{
68 Client, ClientOptions, CreateSessionOptions, ListSessionsOptions, Profiles, Proxy, Sessions,
69};
70pub use profile_editor::{EditorState, EditorStatus, ProfileEditor};
71pub use error::{ApiErrorBody, SolariError, SolariErrorCode};
72pub use types::{
73 Cookie, LocalStorageEntry, Profile, ProfileSaveResult, ProxyCountries, ProxyRequest, ProxySpec,
74 ProxyTier, ReplayUrl, ResolvedProxyConfig, SameSite, Session, SessionList, SessionSummary,
75 SessionView, SolariRegion,
76 StorageOrigin, StorageState,
77};
78
79#[cfg(feature = "connect")]
80pub use connect::{connect, connect_endpoint, ConnectedBrowser};
81
82#[cfg(feature = "connect")]
83pub use session::{LaunchOptions, LaunchedSession};
84
85#[cfg(test)]
86mod tests {
87 use super::client::{derive_cdp_from_ws, iso8601_utc};
88 use super::*;
89 use std::sync::{Arc, Mutex};
90 use tokio::io::{AsyncReadExt, AsyncWriteExt};
91 use tokio::net::TcpListener;
92
93 async fn bind_mock() -> (TcpListener, u16) {
99 let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
100 let port = l.local_addr().unwrap().port();
101 (l, port)
102 }
103
104 fn serve(listener: TcpListener, responses: Vec<(u16, String)>) -> Arc<Mutex<Vec<String>>> {
106 let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
107 let cap = captured.clone();
108 tokio::spawn(async move {
109 for (status, body) in responses {
110 let Ok((mut sock, _)) = listener.accept().await else {
111 return;
112 };
113 let mut buf = Vec::new();
115 let mut tmp = [0u8; 1024];
116 while let Ok(n) = sock.read(&mut tmp).await {
117 if n == 0 {
118 break; }
120 buf.extend_from_slice(&tmp[..n]);
121 let Some(pos) = find_subslice(&buf, b"\r\n\r\n") else {
122 continue; };
124 let head = String::from_utf8_lossy(&buf[..pos]).to_string();
125 let clen = content_length(&head).unwrap_or(0);
126 let body_start = pos + 4;
127 while buf.len() < body_start + clen {
128 match sock.read(&mut tmp).await {
129 Ok(0) | Err(_) => break,
130 Ok(n) => buf.extend_from_slice(&tmp[..n]),
131 }
132 }
133 break;
134 }
135 cap.lock()
136 .unwrap()
137 .push(String::from_utf8_lossy(&buf).to_string());
138
139 let reason = match status {
140 200 => "OK",
141 201 => "Created",
142 204 => "No Content",
143 400 => "Bad Request",
144 402 => "Payment Required",
145 404 => "Not Found",
146 429 => "Too Many Requests",
147 503 => "Service Unavailable",
148 _ => "Status",
149 };
150 let resp = format!(
151 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
152 body.len(),
153 body
154 );
155 let _ = sock.write_all(resp.as_bytes()).await;
156 let _ = sock.flush().await;
157 }
158 });
159 captured
160 }
161
162 fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
163 haystack.windows(needle.len()).position(|w| w == needle)
164 }
165
166 fn content_length(head: &str) -> Option<usize> {
167 for line in head.lines() {
168 if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
169 return v.trim().parse().ok();
170 }
171 }
172 None
173 }
174
175 fn split_req(raw: &str) -> (String, String) {
176 match raw.split_once("\r\n\r\n") {
177 Some((h, b)) => (h.to_string(), b.to_string()),
178 None => (raw.to_string(), String::new()),
179 }
180 }
181
182 fn client_for(port: u16) -> Client {
183 Client::new(
184 ClientOptions::new("slr_live_id_secret", format!("http://127.0.0.1:{port}"))
185 .backoff_ms(0),
186 )
187 .unwrap()
188 }
189
190 #[tokio::test]
200 async fn release_tolerates_a_bare_404() {
201 let (l, port) = bind_mock().await;
202 let _ = serve(l, vec![(404, r#"{"error":"Not Found"}"#.to_string())]);
203 let client = client_for(port);
204 assert!(client.sessions().release("s_1").await.is_ok());
205 }
206
207 #[tokio::test]
208 async fn release_tolerates_a_404_with_an_unrelated_code() {
209 let (l, port) = bind_mock().await;
210 let _ = serve(
211 l,
212 vec![(404, r#"{"error":"Not Found","code":"SomethingElse"}"#.to_string())],
213 );
214 let client = client_for(port);
215 assert!(client.sessions().release("s_1").await.is_ok());
216 }
217
218 #[tokio::test]
219 async fn release_fails_on_404_invalid_session_id() {
220 let (l, port) = bind_mock().await;
221 let _ = serve(
222 l,
223 vec![(
224 404,
225 r#"{"error":"Not Found","code":"InvalidSessionId"}"#.to_string(),
226 )],
227 );
228 let client = client_for(port);
229 let err = client
230 .sessions()
231 .release("s_1")
232 .await
233 .expect_err("a refused session id released nothing and must not look like success");
234 assert_eq!(err.status(), Some(404));
235 assert_eq!(err.code(), Some(&SolariErrorCode::InvalidSessionId));
236 }
237
238 #[tokio::test]
239 async fn invalid_session_id_round_trips_through_the_wire_string() {
240 assert_eq!(
241 SolariErrorCode::from("InvalidSessionId"),
242 SolariErrorCode::InvalidSessionId
243 );
244 assert_eq!(SolariErrorCode::InvalidSessionId.as_str(), "InvalidSessionId");
245 }
246
247 #[tokio::test]
250 async fn create_session_request_shape() {
251 let (l, port) = bind_mock().await;
252 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"}"#;
253 let captured = serve(l, vec![(201, body.to_string())]);
254
255 let client = client_for(port);
256 let session = client
257 .sessions()
258 .create(
259 CreateSessionOptions::new()
260 .stealth(true)
261 .proxy(ProxySpec::country("us")),
262 )
263 .await
264 .unwrap();
265
266 assert_eq!(session.id, "s_1");
267 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
268 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
269 assert_eq!(session.expires_at, "2026-01-01T00:00:00Z");
270 assert!(session.storage_state.is_none());
272 assert!(session.proxy.is_none());
273
274 let raw = captured.lock().unwrap()[0].clone();
275 let (head, body) = split_req(&raw);
276 assert!(head.starts_with("POST /sessions "), "head: {head}");
277 let lower = head.to_lowercase();
278 assert!(
279 lower.contains("authorization: bearer slr_live_id_secret"),
280 "head: {head}"
281 );
282 assert!(lower.contains("content-type: application/json"), "head: {head}");
283
284 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
285 assert_eq!(json.get("stealth").unwrap(), true);
286 assert_eq!(json.get("proxy").unwrap(), "us");
287 assert!(json.get("recording").is_none(), "body: {body}");
289 assert!(json.get("captcha").is_none(), "body: {body}");
290 assert!(json.get("profileId").is_none(), "body: {body}");
291 }
292
293 #[tokio::test]
295 async fn create_session_sends_timeout_keep_alive_and_user_metadata() {
296 let (l, port) = bind_mock().await;
297 let body = r#"{"sessionId":"s_9","wsEndpoint":"wss://gw.example.com/ws/s_9","userMetadata":{"job":"j-7"}}"#;
298 let captured = serve(l, vec![(201, body.to_string())]);
299
300 let mut meta = serde_json::Map::new();
301 meta.insert("job".into(), serde_json::json!("j-7"));
302 let session = client_for(port)
303 .sessions()
304 .create(
305 CreateSessionOptions::new()
306 .session_timeout_ms(120_000)
307 .keep_alive(true)
308 .user_metadata(meta),
309 )
310 .await
311 .unwrap();
312
313 let raw = captured.lock().unwrap()[0].clone();
314 let (_, body) = split_req(&raw);
315 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
316 assert_eq!(json["timeoutSeconds"], 120);
317 assert_eq!(json["keepAlive"], true);
318 assert_eq!(json["userMetadata"]["job"], "j-7");
319 assert_eq!(session.user_metadata.unwrap()["job"], "j-7");
320 }
321
322 #[test]
325 fn session_timeout_ms_converts_to_wire_seconds() {
326 use crate::client::session_timeout_seconds as conv;
327 assert_eq!(conv(None, None).unwrap(), None);
328 assert_eq!(conv(Some(0), None).unwrap(), None);
329 assert_eq!(conv(Some(1000), None).unwrap(), Some(1));
330 assert_eq!(conv(Some(1001), None).unwrap(), Some(2));
331 assert_eq!(conv(Some(59_999), None).unwrap(), Some(60));
332 assert_eq!(conv(Some(60_000), None).unwrap(), Some(60));
333 assert_eq!(conv(None, Some(90)).unwrap(), Some(90));
334 assert_eq!(conv(None, Some(0)).unwrap(), None);
335 assert_eq!(conv(Some(120_000), Some(90)).unwrap(), Some(120), "the new name wins");
336 assert!(conv(Some(u64::MAX), None).is_err(), "too large for the wire field");
337 }
338
339 #[tokio::test]
341 async fn session_timeout_ms_is_sent_as_timeout_seconds() {
342 let (l, port) = bind_mock().await;
343 let captured = serve(
344 l,
345 vec![(201, r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#.to_string())],
346 );
347 client_for(port)
348 .sessions()
349 .create(CreateSessionOptions::new().session_timeout_ms(61_001))
350 .await
351 .unwrap();
352 let raw = captured.lock().unwrap()[0].clone();
353 let (_, body) = split_req(&raw);
354 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
355 assert_eq!(json["timeoutSeconds"], 62);
356 assert!(json.get("sessionTimeoutMs").is_none() && json.get("session_timeout_ms").is_none());
357 }
358
359 #[allow(deprecated)]
361 #[tokio::test]
362 async fn deprecated_timeout_seconds_still_works() {
363 let (l, port) = bind_mock().await;
364 let captured = serve(
365 l,
366 vec![(201, r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#.to_string())],
367 );
368 client_for(port)
369 .sessions()
370 .create(CreateSessionOptions::new().timeout_seconds(90))
371 .await
372 .unwrap();
373 let raw = captured.lock().unwrap()[0].clone();
374 let (_, body) = split_req(&raw);
375 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
376 assert_eq!(json["timeoutSeconds"], 90);
377 }
378
379 #[tokio::test]
381 async fn new_create_options_are_omitted_when_unset() {
382 let (l, port) = bind_mock().await;
383 let captured = serve(
384 l,
385 vec![(201, r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#.to_string())],
386 );
387 client_for(port)
388 .sessions()
389 .create(CreateSessionOptions::default())
390 .await
391 .unwrap();
392 let raw = captured.lock().unwrap()[0].clone();
393 let (_, body) = split_req(&raw);
394 assert!(body.trim().is_empty(), "body: {body}");
395 }
396
397 #[tokio::test]
399 async fn get_returns_the_gateway_view() {
400 let (l, port) = bind_mock().await;
401 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"}"#;
402 let _ = serve(l, vec![(200, body.to_string())]);
403 let v = client_for(port).sessions().get("s_1").await.unwrap();
404 assert_eq!(v.id.as_deref(), Some("s_1"));
405 assert_eq!(v.kind.as_deref(), Some("stealth"));
406 assert_eq!(v.org.as_deref(), Some("o1"));
407 assert_eq!(v.user_metadata.unwrap()["a"], "b");
408 assert!(v.cdp_endpoint.is_some() && v.ws_endpoint.is_some());
409 }
410
411 #[tokio::test]
412 async fn list_sends_paging_and_the_metadata_filter_and_parses_the_page() {
413 let (l, port) = bind_mock().await;
414 let body = r#"{"sessions":[{"id":"s1","status":"active","kind":"fast","createdAt":"c","expiresAt":"e","userMetadata":{"team":"x"}},{"id":"s2","status":"released"}],"nextCursor":"cur2"}"#;
415 let captured = serve(l, vec![(200, body.to_string())]);
416
417 let mut opts = ListSessionsOptions::default();
418 opts.limit = Some(2);
419 opts.cursor = Some("cur1".into());
420 opts.metadata.insert("team".into(), "x".into());
421 opts.metadata.insert("env".into(), "prod".into());
422 let page = client_for(port).sessions().list(opts).await.unwrap();
423
424 let raw = captured.lock().unwrap()[0].clone();
425 let (head, _) = split_req(&raw);
426 assert!(head.starts_with("GET /sessions?"), "head: {head}");
427 assert!(head.contains("limit=2"), "head: {head}");
428 assert!(head.contains("cursor=cur1"), "head: {head}");
429 assert!(
431 head.contains("q=user_metadata%5B%27env%27%5D%3A%27prod%27%20AND%20user_metadata%5B%27team%27%5D%3A%27x%27"),
432 "head: {head}"
433 );
434 assert_eq!(page.sessions.len(), 2);
435 assert_eq!(page.sessions[0].id, "s1");
436 assert_eq!(page.sessions[0].user_metadata.as_ref().unwrap()["team"], "x");
437 assert_eq!(page.sessions[1].status, "released");
438 assert_eq!(page.next_cursor.as_deref(), Some("cur2"));
439 }
440
441 #[tokio::test]
442 async fn list_with_default_options_requests_a_bare_path_and_handles_an_empty_page() {
443 let (l, port) = bind_mock().await;
444 let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
445 let page = client_for(port)
446 .sessions()
447 .list(ListSessionsOptions::default())
448 .await
449 .unwrap();
450 let raw = captured.lock().unwrap()[0].clone();
451 let (head, _) = split_req(&raw);
452 assert!(head.starts_with("GET /sessions HTTP/1.1"), "head: {head}");
453 assert!(page.sessions.is_empty());
454 assert!(page.next_cursor.is_none());
455 }
456
457 #[tokio::test]
459 async fn list_refuses_an_unexpressible_filter_without_a_request() {
460 let (l, port) = bind_mock().await;
461 let captured = serve(l, vec![(200, r#"{"sessions":[]}"#.to_string())]);
462 let client = client_for(port);
463
464 for (k, v) in [("k", "it's"), ("k'", "v"), ("", "v")] {
465 let mut opts = ListSessionsOptions::default();
466 opts.metadata.insert(k.into(), v.into());
467 assert!(client.sessions().list(opts).await.is_err(), "{k:?}={v:?}");
468 }
469 let mut many = ListSessionsOptions::default();
470 for i in 0..11 {
471 many.metadata.insert(format!("k{i}"), "v".into());
472 }
473 assert!(client.sessions().list(many).await.is_err());
474 assert!(captured.lock().unwrap().is_empty(), "no request may be sent");
475 }
476
477 #[tokio::test]
479 async fn list_surfaces_a_gateway_validation_error() {
480 let (l, port) = bind_mock().await;
481 let _ = serve(
482 l,
483 vec![(400, r#"{"error":"Invalid q","code":"ValidationError","field":"q"}"#.to_string())],
484 );
485 let err = client_for(port)
486 .sessions()
487 .list(ListSessionsOptions::default())
488 .await
489 .unwrap_err();
490 assert_eq!(err.status(), Some(400));
491 }
492
493 #[tokio::test]
495 async fn create_session_omits_empty_body() {
496 let (l, port) = bind_mock().await;
497 let body = r#"{"sessionId":"s_2","wsEndpoint":"wss://gw.example.com/ws/s_2"}"#;
498 let captured = serve(l, vec![(201, body.to_string())]);
499
500 let client = client_for(port);
501 client
502 .sessions()
503 .create(CreateSessionOptions::default())
504 .await
505 .unwrap();
506
507 let raw = captured.lock().unwrap()[0].clone();
508 let (head, body) = split_req(&raw);
509 assert!(body.trim().is_empty(), "expected no body, got: {body}");
510 assert!(
511 !head.to_lowercase().contains("content-length: 2"),
512 "head: {head}"
513 );
514 }
515
516 #[tokio::test]
518 async fn create_session_proxy_request_shape() {
519 let (l, port) = bind_mock().await;
520 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"}}"#;
521 let captured = serve(l, vec![(201, body.to_string())]);
522
523 let client = client_for(port);
524 let session = client
525 .sessions()
526 .create(CreateSessionOptions::new().stealth(true).proxy(
527 ProxyRequest::country("us")
528 .tier(ProxyTier::Mobile)
529 .session("warm-1")
530 .session_duration(15),
531 ))
532 .await
533 .unwrap();
534
535 let proxy = session.proxy.expect("resolved proxy");
536 assert_eq!(proxy.country, "us");
537 assert_eq!(proxy.tier, Some(ProxyTier::Mobile));
538 assert_eq!(proxy.timezone_id, "America/Los_Angeles");
539
540 let raw = captured.lock().unwrap()[0].clone();
541 let (_, body) = split_req(&raw);
542 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
543 let p = json.get("proxy").unwrap();
544 assert_eq!(p.get("country").unwrap(), "us");
545 assert_eq!(p.get("tier").unwrap(), "mobile");
546 assert_eq!(p.get("session").unwrap(), "warm-1");
547 assert_eq!(p.get("sessionDuration").unwrap(), 15);
548 assert!(p.get("asn").is_none());
549 assert!(p.get("city").is_none());
550 }
551
552 #[tokio::test]
556 async fn retryable_hint_retries_idempotent_get() {
557 let (l, port) = bind_mock().await;
558 let pending =
559 r#"{"error":"replay still uploading","code":"ReplayPending","retryable":true}"#;
560 let ok = r#"{"url":"https://s3/replay.ndjson.gz"}"#;
561 let captured = serve(
562 l,
563 vec![(404, pending.to_string()), (200, ok.to_string())],
564 );
565
566 let client = client_for(port);
567 let link = client.sessions().replay_url("sess-1").await.unwrap();
568
569 assert_eq!(link.url, "https://s3/replay.ndjson.gz");
570 assert_eq!(captured.lock().unwrap().len(), 2);
572 }
573
574 #[tokio::test]
577 async fn no_hint_means_no_retry() {
578 let (l, port) = bind_mock().await;
579 let terminal = r#"{"error":"no replay","code":"ReplayUnavailable"}"#;
580 let captured = serve(
581 l,
582 vec![
583 (404, terminal.to_string()),
584 (200, r#"{"url":"never reached"}"#.to_string()),
585 ],
586 );
587
588 let client = client_for(port);
589 assert!(client.sessions().replay_url("sess-1").await.is_err());
590 assert_eq!(captured.lock().unwrap().len(), 1);
591 }
592
593 #[tokio::test]
602 async fn retryable_hint_honoured_on_keyed_create() {
603 let (l, port) = bind_mock().await;
604 let captured = serve(
605 l,
606 vec![
607 (409, r#"{"error":"in progress","retryable":true}"#.to_string()),
608 (201, r#"{"sessionId":"s_ok","wsEndpoint":"wss://x/ws/s_ok"}"#.to_string()),
609 ],
610 );
611
612 let client = client_for(port);
613 assert!(client
614 .sessions()
615 .create(CreateSessionOptions::default())
616 .await
617 .is_ok());
618 let reqs = captured.lock().unwrap();
619 assert_eq!(reqs.len(), 2, "the hint must be honoured on a keyed create");
620
621 fn idempotency_key(head: &str) -> Option<String> {
626 for line in head.lines() {
627 if let Some(v) = line.to_lowercase().strip_prefix("idempotency-key:") {
628 return Some(v.trim().to_string());
629 }
630 }
631 None
632 }
633 let (head1, _) = split_req(&reqs[0]);
634 let (head2, _) = split_req(&reqs[1]);
635 let key1 = idempotency_key(&head1).expect("first attempt must carry a key");
636 let key2 = idempotency_key(&head2).expect("second attempt must carry a key");
637 assert_eq!(key1, key2, "retries must reuse the SAME key, not mint a fresh one");
638 }
639
640 #[tokio::test]
642 async fn idempotency_keys_differ_between_calls() {
643 assert_ne!(
644 crate::http::new_idempotency_key(),
645 crate::http::new_idempotency_key()
646 );
647 }
648
649 #[tokio::test]
651 async fn retries_on_503() {
652 let (l, port) = bind_mock().await;
653 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
654 let captured = serve(
655 l,
656 vec![
657 (503, r#"{"error":"no capacity"}"#.to_string()),
658 (201, ok.to_string()),
659 ],
660 );
661
662 let client = client_for(port);
663 let session = client
664 .sessions()
665 .create(CreateSessionOptions::default())
666 .await
667 .unwrap();
668
669 assert_eq!(session.id, "s_ok");
670 assert_eq!(captured.lock().unwrap().len(), 2);
672 }
673
674 #[tokio::test]
677 async fn pool_response_unparseable_502_is_not_retried() {
678 let (l, port) = bind_mock().await;
679 let ok = r#"{"sessionId":"s_ok","wsEndpoint":"wss://gw.example.com/ws/s_ok"}"#;
680 let captured = serve(
681 l,
682 vec![
683 (
684 502,
685 r#"{"error":"Pool returned an invalid response","code":"PoolResponseUnparseable","retryable":false}"#.to_string(),
686 ),
687 (201, ok.to_string()),
688 ],
689 );
690
691 let client = client_for(port);
692 let err = client
693 .sessions()
694 .create(CreateSessionOptions::default())
695 .await
696 .unwrap_err();
697
698 assert_eq!(err.status(), Some(502));
699 assert_eq!(captured.lock().unwrap().len(), 1, "retryable:false must not be retried");
700 }
701
702 #[tokio::test]
704 async fn retries_exhausted_on_persistent_503() {
705 let (l, port) = bind_mock().await;
706 let captured = serve(
707 l,
708 vec![
709 (503, "{}".to_string()),
710 (503, "{}".to_string()),
711 (201, "{}".to_string()), ],
713 );
714
715 let client = client_for(port);
716 let err = client
717 .sessions()
718 .create(CreateSessionOptions::default())
719 .await
720 .unwrap_err();
721
722 assert!(matches!(err, SolariError::Api { .. }), "{err:?}");
723 assert_eq!(err.status(), Some(503));
724 assert_eq!(captured.lock().unwrap().len(), 2);
725 }
726
727 #[tokio::test]
729 async fn final_5xx_keeps_its_code() {
730 let (l, port) = bind_mock().await;
731 let captured = serve(
732 l,
733 vec![
734 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
735 (503, r#"{"error":"unhealthy","code":"BrowserUnhealthy"}"#.to_string()),
736 ],
737 );
738
739 let client = client_for(port);
740 let err = client
741 .sessions()
742 .create(CreateSessionOptions::default())
743 .await
744 .unwrap_err();
745
746 assert_eq!(err.status(), Some(503));
747 assert_eq!(err.code(), Some(&SolariErrorCode::BrowserUnhealthy));
748 assert_eq!(captured.lock().unwrap().len(), 2);
749 }
750
751 #[tokio::test]
753 async fn transport_failure_exhausts_as_transport_error() {
754 let (l, port) = bind_mock().await;
755 drop(l); let client = client_for(port);
757 let err = client
758 .sessions()
759 .create(CreateSessionOptions::default())
760 .await
761 .unwrap_err();
762
763 assert!(matches!(err, SolariError::Transport { .. }), "{err:?}");
764 assert!(err.to_string().contains("exhausted 2 attempts"), "{err}");
765 }
766
767 #[tokio::test]
769 async fn does_not_retry_on_400() {
770 let (l, port) = bind_mock().await;
771 let captured = serve(
772 l,
773 vec![
774 (400, r#"{"error":"bad proxy country"}"#.to_string()),
775 (201, "{}".to_string()), ],
777 );
778
779 let client = client_for(port);
780 let err = client
781 .sessions()
782 .create(CreateSessionOptions::default())
783 .await
784 .unwrap_err();
785
786 assert_eq!(err.status(), Some(400));
787 assert!(err.to_string().contains("bad proxy country"), "{err}");
788 assert_eq!(captured.lock().unwrap().len(), 1);
790 }
791
792 #[tokio::test]
795 async fn parses_error_codes() {
796 let (l, port) = bind_mock().await;
797 serve(
798 l,
799 vec![
800 (
801 402,
802 r#"{"code":"FeatureRequiresPlan","error":"captcha requires a paid plan"}"#
803 .to_string(),
804 ),
805 (
806 429,
807 r#"{"code":"ConcurrencyLimitExceeded"}"#.to_string(),
808 ),
809 (400, r#"{"code":"SomethingNew"}"#.to_string()),
810 (400, "not json at all".to_string()),
811 ],
812 );
813
814 let client = client_for(port);
815
816 let err = client
817 .sessions()
818 .create(CreateSessionOptions::new().captcha(true))
819 .await
820 .unwrap_err();
821 assert_eq!(err.status(), Some(402));
822 assert_eq!(err.code(), Some(&SolariErrorCode::FeatureRequiresPlan));
823 assert!(err.to_string().contains("captcha requires a paid plan"));
824
825 let err = client
826 .sessions()
827 .create(CreateSessionOptions::default())
828 .await
829 .unwrap_err();
830 assert_eq!(err.status(), Some(429));
831 assert_eq!(err.code(), Some(&SolariErrorCode::ConcurrencyLimitExceeded));
832
833 let err = client
835 .sessions()
836 .create(CreateSessionOptions::default())
837 .await
838 .unwrap_err();
839 assert_eq!(
840 err.code(),
841 Some(&SolariErrorCode::Other("SomethingNew".to_string()))
842 );
843
844 let err = client
846 .sessions()
847 .create(CreateSessionOptions::default())
848 .await
849 .unwrap_err();
850 assert_eq!(err.status(), Some(400));
851 assert_eq!(err.code(), None);
852 }
853
854 #[tokio::test]
856 async fn profiles_list_parses() {
857 let (l, port) = bind_mock().await;
858 let body = r#"[{"id":"p_1","name":"amazon","createdAt":"2026-01-01T00:00:00Z"},{"id":"p_2","name":"gmail"}]"#;
859 let captured = serve(l, vec![(200, body.to_string())]);
860
861 let client = client_for(port);
862 let profiles = client.profiles().list().await.unwrap();
863
864 assert_eq!(profiles.len(), 2);
865 assert_eq!(profiles[0].id, "p_1");
866 assert_eq!(profiles[0].name, "amazon");
867 assert_eq!(
869 profiles[0].extra.get("createdAt").unwrap(),
870 "2026-01-01T00:00:00Z"
871 );
872 assert_eq!(profiles[1].id, "p_2");
873 assert!(profiles[1].extra.is_empty());
874
875 let (head, _) = split_req(&captured.lock().unwrap()[0]);
876 assert!(head.starts_with("GET /profiles "), "head: {head}");
877 }
878
879 #[tokio::test]
881 async fn profiles_create_and_save() {
882 let (l, port) = bind_mock().await;
883 let captured = serve(
884 l,
885 vec![
886 (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
887 (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
888 (200, "{}".to_string()), ],
890 );
891
892 let client = client_for(port);
893 let profile = client.profiles().create("shopify").await.unwrap();
894 assert_eq!(profile.id, "p_9");
895 assert_eq!(profile.name, "shopify");
896
897 let state = StorageState {
898 cookies: Some(vec![Cookie {
899 name: "sid".into(),
900 value: "abc".into(),
901 domain: Some(".shopify.com".into()),
902 same_site: Some(SameSite::Lax),
903 ..Default::default()
904 }]),
905 ..Default::default()
906 };
907 let saved = client.profiles().save("p_9", &state).await.unwrap();
908 assert_eq!(saved.version, 3);
909 assert_eq!(saved.size_bytes, 2048);
910
911 let saved = client.profiles().save("p_9", &state).await.unwrap();
913 assert_eq!(saved.version, 0);
914 assert_eq!(saved.size_bytes, 0);
915
916 let reqs = captured.lock().unwrap().clone();
917 let (head, body) = split_req(&reqs[0]);
918 assert!(head.starts_with("POST /profiles "), "head: {head}");
919 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
920 assert_eq!(json.get("name").unwrap(), "shopify");
921
922 let (head, body) = split_req(&reqs[1]);
924 assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
925 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
926 let cookie = &json["storageState"]["cookies"][0];
927 assert_eq!(cookie.get("name").unwrap(), "sid");
928 assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
929 assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
930 assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
932 }
933
934 #[tokio::test]
936 async fn delete_tolerates_404() {
937 let (l, port) = bind_mock().await;
938 serve(
939 l,
940 vec![
941 (404, r#"{"error":"Not Found"}"#.to_string()),
942 (404, r#"{"error":"Not Found"}"#.to_string()),
943 (500, r#"{"error":"boom"}"#.to_string()),
944 ],
945 );
946
947 let client = client_for(port);
948 client.sessions().release("s_gone").await.unwrap();
949 client.profiles().delete("p_gone").await.unwrap();
950 let err = client.sessions().release("s_bad").await.unwrap_err();
952 assert_eq!(err.status(), Some(500));
953 }
954
955 #[tokio::test]
957 async fn replay_url_defaults() {
958 let (l, port) = bind_mock().await;
959 let captured = serve(
960 l,
961 vec![
962 (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
963 (
964 200,
965 r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
966 .to_string(),
967 ),
968 (200, r#"{"expiresInSeconds":600}"#.to_string()),
969 ],
970 );
971
972 let client = client_for(port);
973
974 let replay = client.sessions().replay_url("s_1").await.unwrap();
975 assert_eq!(replay.url, "https://s3.example.com/r1");
976 assert_eq!(replay.expires_in_seconds, 0);
977 assert_eq!(replay.content_encoding, "gzip");
978
979 let replay = client.sessions().replay_url("s_2").await.unwrap();
980 assert_eq!(replay.expires_in_seconds, 600);
981 assert_eq!(replay.content_encoding, "identity");
982
983 let err = client.sessions().replay_url("s_3").await.unwrap_err();
985 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
986
987 let (head, _) = split_req(&captured.lock().unwrap()[0]);
988 assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
989 }
990
991 #[tokio::test]
993 async fn proxy_countries() {
994 let (l, port) = bind_mock().await;
995 let captured = serve(
996 l,
997 vec![(
998 200,
999 r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
1000 )],
1001 );
1002
1003 let client = client_for(port);
1004 let res = client.proxy().countries().await.unwrap();
1005 assert!(res.enabled);
1006 assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
1007
1008 let (head, _) = split_req(&captured.lock().unwrap()[0]);
1009 assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
1010 }
1011
1012 #[tokio::test]
1014 async fn session_get_parses() {
1015 let (l, port) = bind_mock().await;
1016 serve(
1017 l,
1018 vec![(
1019 200,
1020 r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
1021 .to_string(),
1022 )],
1023 );
1024
1025 let client = client_for(port);
1026 let view = client.sessions().get("s_1").await.unwrap();
1027 assert_eq!(view.session_id.as_deref(), Some("s_1"));
1028 assert_eq!(view.status.as_deref(), Some("busy"));
1029 assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
1030 assert_eq!(view.extra.get("slot").unwrap(), 3);
1031 }
1032
1033 #[test]
1035 fn cdp_derivation() {
1036 assert_eq!(
1037 derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
1038 "wss://api.getsolari.com/cdp/abc.def"
1039 );
1040 assert_eq!(
1041 derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
1042 "ws://127.0.0.1:3000/cdp/s_1?token=x"
1043 );
1044 assert_eq!(
1046 derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
1047 "wss://api.getsolari.com/other/abc"
1048 );
1049 assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
1051 }
1052
1053 #[tokio::test]
1055 async fn create_session_derives_cdp() {
1056 let (l, port) = bind_mock().await;
1057 let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
1058 serve(l, vec![(201, body.to_string())]);
1059
1060 let client = client_for(port);
1061 let session = client
1062 .sessions()
1063 .create(CreateSessionOptions::default())
1064 .await
1065 .unwrap();
1066
1067 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
1068 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
1069 assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
1071 assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
1072 }
1073
1074 #[tokio::test]
1076 async fn create_session_rejects_bad_response() {
1077 let (l, port) = bind_mock().await;
1078 serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
1079
1080 let client = client_for(port);
1081 let err = client
1082 .sessions()
1083 .create(CreateSessionOptions::default())
1084 .await
1085 .unwrap_err();
1086 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
1087 }
1088
1089 #[tokio::test]
1091 async fn storage_state_fetched_from_presigned_url() {
1092 let (l, port) = bind_mock().await;
1093 let create = format!(
1094 r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
1095 );
1096 let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
1097 serve(l, vec![(201, create), (200, state.to_string())]);
1098
1099 let client = client_for(port);
1100 let session = client
1101 .sessions()
1102 .create(CreateSessionOptions::new().profile_id("p_1"))
1103 .await
1104 .unwrap();
1105
1106 let state = session
1107 .storage_state
1108 .expect("profile attached → Some")
1109 .expect("presigned state → Some");
1110 let cookies = state.cookies.unwrap();
1111 assert_eq!(cookies[0].name, "sid");
1112 assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
1113 let origins = state.origins.unwrap();
1114 assert_eq!(origins[0].origin, "https://example.com");
1115 assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
1116 }
1117
1118 #[tokio::test]
1120 async fn storage_state_absent_vs_empty() {
1121 let (l, port) = bind_mock().await;
1122 serve(
1123 l,
1124 vec![
1125 (
1127 201,
1128 r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
1129 .to_string(),
1130 ),
1131 (
1133 201,
1134 r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
1135 ),
1136 (
1138 201,
1139 r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
1140 .to_string(),
1141 ),
1142 (
1144 201,
1145 r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
1146 ),
1147 ],
1148 );
1149
1150 let client = client_for(port);
1151
1152 let s = client
1153 .sessions()
1154 .create(CreateSessionOptions::new().profile_id("p_1"))
1155 .await
1156 .unwrap();
1157 assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
1158
1159 let s = client
1160 .sessions()
1161 .create(CreateSessionOptions::new().profile_id("p_1"))
1162 .await
1163 .unwrap();
1164 assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
1165
1166 let s = client
1167 .sessions()
1168 .create(CreateSessionOptions::new().profile_id("p_1"))
1169 .await
1170 .unwrap();
1171 let inline = s.storage_state.expect("Some").expect("Some");
1172 assert_eq!(inline.cookies.unwrap().len(), 0);
1173
1174 let s = client
1175 .sessions()
1176 .create(CreateSessionOptions::default())
1177 .await
1178 .unwrap();
1179 assert!(s.storage_state.is_none(), "no profile → None");
1180 }
1181
1182 #[test]
1184 fn client_options_and_defaults() {
1185 let opts = ClientOptions::default_region("k");
1186 assert_eq!(opts.base_url, "https://api.getsolari.com");
1187 assert_eq!(opts.max_attempts, 2);
1188 assert_eq!(opts.backoff_ms, 500);
1189 assert_eq!(opts.timeout_ms, 90_000);
1190
1191 assert_eq!(
1192 ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
1193 "https://api.getsolari.com"
1194 );
1195
1196 let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
1198 assert_eq!(c.base_url(), "https://gw.example.com");
1199
1200 let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
1202 assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
1203 }
1204
1205 #[test]
1208 fn iso8601_formatting() {
1209 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1210 assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1211 assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1212 assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1214 assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1216 assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1218 assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1220 }
1221
1222 #[test]
1224 fn error_code_round_trip() {
1225 for code in [
1226 SolariErrorCode::FeatureRequiresPlan,
1227 SolariErrorCode::ConcurrencyLimitExceeded,
1228 SolariErrorCode::PlanLimitExceeded,
1229 SolariErrorCode::BrowserUnhealthy,
1230 ] {
1231 assert_eq!(SolariErrorCode::from(code.as_str()), code);
1232 }
1233 assert_eq!(
1234 SolariErrorCode::from("BrowserUnhealthy"),
1235 SolariErrorCode::BrowserUnhealthy
1236 );
1237 assert_eq!(
1238 SolariErrorCode::Other("Nope".into()).to_string(),
1239 "Nope".to_string()
1240 );
1241 }
1242
1243 #[cfg(feature = "connect")]
1249 #[tokio::test]
1250 async fn launch_retries_create_itself_on_transient_exhaustion() {
1251 let (l, port) = bind_mock().await;
1252 let captured = serve(
1253 l,
1254 vec![
1255 (503, "{}".to_string()),
1258 (503, "{}".to_string()),
1259 (400, r#"{"error":"bad request"}"#.to_string()),
1263 ],
1264 );
1265
1266 let client = client_for(port);
1267 let result = client
1268 .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1269 .await;
1270 let Err(err) = result else {
1271 panic!("expected second create()'s 400 to end the loop, got Ok");
1272 };
1273
1274 assert_eq!(err.status(), Some(400));
1275 assert_eq!(captured.lock().unwrap().len(), 3);
1279 }
1280}