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