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 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"},{"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[1].id, "p_2");
881 assert!(profiles[1].extra.is_empty());
882
883 let (head, _) = split_req(&captured.lock().unwrap()[0]);
884 assert!(head.starts_with("GET /profiles "), "head: {head}");
885 }
886
887 #[tokio::test]
889 async fn profiles_create_and_save() {
890 let (l, port) = bind_mock().await;
891 let captured = serve(
892 l,
893 vec![
894 (201, r#"{"id":"p_9","name":"shopify"}"#.to_string()),
895 (200, r#"{"version":3,"sizeBytes":2048}"#.to_string()),
896 (200, "{}".to_string()), ],
898 );
899
900 let client = client_for(port);
901 let profile = client.profiles().create("shopify").await.unwrap();
902 assert_eq!(profile.id, "p_9");
903 assert_eq!(profile.name, "shopify");
904
905 let state = StorageState {
906 cookies: Some(vec![Cookie {
907 name: "sid".into(),
908 value: "abc".into(),
909 domain: Some(".shopify.com".into()),
910 same_site: Some(SameSite::Lax),
911 ..Default::default()
912 }]),
913 ..Default::default()
914 };
915 let saved = client.profiles().save("p_9", &state).await.unwrap();
916 assert_eq!(saved.version, 3);
917 assert_eq!(saved.size_bytes, 2048);
918
919 let saved = client.profiles().save("p_9", &state).await.unwrap();
921 assert_eq!(saved.version, 0);
922 assert_eq!(saved.size_bytes, 0);
923
924 let reqs = captured.lock().unwrap().clone();
925 let (head, body) = split_req(&reqs[0]);
926 assert!(head.starts_with("POST /profiles "), "head: {head}");
927 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
928 assert_eq!(json.get("name").unwrap(), "shopify");
929
930 let (head, body) = split_req(&reqs[1]);
932 assert!(head.starts_with("POST /profiles/p_9/save "), "head: {head}");
933 let json: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
934 let cookie = &json["storageState"]["cookies"][0];
935 assert_eq!(cookie.get("name").unwrap(), "sid");
936 assert_eq!(cookie.get("domain").unwrap(), ".shopify.com");
937 assert_eq!(cookie.get("sameSite").unwrap(), "Lax");
938 assert!(cookie.get("httpOnly").is_none(), "cookie: {cookie}");
940 }
941
942 #[tokio::test]
944 async fn delete_tolerates_404() {
945 let (l, port) = bind_mock().await;
946 serve(
947 l,
948 vec![
949 (404, r#"{"error":"Not Found"}"#.to_string()),
950 (404, r#"{"error":"Not Found"}"#.to_string()),
951 (500, r#"{"error":"boom"}"#.to_string()),
952 ],
953 );
954
955 let client = client_for(port);
956 client.sessions().release("s_gone").await.unwrap();
957 client.profiles().delete("p_gone").await.unwrap();
958 let err = client.sessions().release("s_bad").await.unwrap_err();
960 assert_eq!(err.status(), Some(500));
961 }
962
963 #[tokio::test]
965 async fn replay_url_defaults() {
966 let (l, port) = bind_mock().await;
967 let captured = serve(
968 l,
969 vec![
970 (200, r#"{"url":"https://s3.example.com/r1"}"#.to_string()),
971 (
972 200,
973 r#"{"url":"https://s3.example.com/r2","expiresInSeconds":600,"contentEncoding":"identity"}"#
974 .to_string(),
975 ),
976 (200, r#"{"expiresInSeconds":600}"#.to_string()),
977 ],
978 );
979
980 let client = client_for(port);
981
982 let replay = client.sessions().replay_url("s_1").await.unwrap();
983 assert_eq!(replay.url, "https://s3.example.com/r1");
984 assert_eq!(replay.expires_in_seconds, 0);
985 assert_eq!(replay.content_encoding, "gzip");
986
987 let replay = client.sessions().replay_url("s_2").await.unwrap();
988 assert_eq!(replay.expires_in_seconds, 600);
989 assert_eq!(replay.content_encoding, "identity");
990
991 let err = client.sessions().replay_url("s_3").await.unwrap_err();
993 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
994
995 let (head, _) = split_req(&captured.lock().unwrap()[0]);
996 assert!(head.starts_with("GET /sessions/s_1/replay-url "), "head: {head}");
997 }
998
999 #[tokio::test]
1001 async fn proxy_countries() {
1002 let (l, port) = bind_mock().await;
1003 let captured = serve(
1004 l,
1005 vec![(
1006 200,
1007 r#"{"enabled":true,"countries":["br","de","gb","us"]}"#.to_string(),
1008 )],
1009 );
1010
1011 let client = client_for(port);
1012 let res = client.proxy().countries().await.unwrap();
1013 assert!(res.enabled);
1014 assert_eq!(res.countries, vec!["br", "de", "gb", "us"]);
1015
1016 let (head, _) = split_req(&captured.lock().unwrap()[0]);
1017 assert!(head.starts_with("GET /proxy/countries "), "head: {head}");
1018 }
1019
1020 #[tokio::test]
1022 async fn session_get_parses() {
1023 let (l, port) = bind_mock().await;
1024 serve(
1025 l,
1026 vec![(
1027 200,
1028 r#"{"sessionId":"s_1","status":"busy","expiresAt":"2026-01-01T00:00:00Z","slot":3}"#
1029 .to_string(),
1030 )],
1031 );
1032
1033 let client = client_for(port);
1034 let view = client.sessions().get("s_1").await.unwrap();
1035 assert_eq!(view.session_id.as_deref(), Some("s_1"));
1036 assert_eq!(view.status.as_deref(), Some("busy"));
1037 assert_eq!(view.expires_at.as_deref(), Some("2026-01-01T00:00:00Z"));
1038 assert_eq!(view.extra.get("slot").unwrap(), 3);
1039 }
1040
1041 #[test]
1043 fn cdp_derivation() {
1044 assert_eq!(
1045 derive_cdp_from_ws("wss://api.getsolari.com/ws/abc.def"),
1046 "wss://api.getsolari.com/cdp/abc.def"
1047 );
1048 assert_eq!(
1049 derive_cdp_from_ws("ws://127.0.0.1:3000/ws/s_1?token=x"),
1050 "ws://127.0.0.1:3000/cdp/s_1?token=x"
1051 );
1052 assert_eq!(
1054 derive_cdp_from_ws("wss://api.getsolari.com/other/abc"),
1055 "wss://api.getsolari.com/other/abc"
1056 );
1057 assert_eq!(derive_cdp_from_ws("not a url"), "not a url");
1059 }
1060
1061 #[tokio::test]
1063 async fn create_session_derives_cdp() {
1064 let (l, port) = bind_mock().await;
1065 let body = r#"{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1"}"#;
1066 serve(l, vec![(201, body.to_string())]);
1067
1068 let client = client_for(port);
1069 let session = client
1070 .sessions()
1071 .create(CreateSessionOptions::default())
1072 .await
1073 .unwrap();
1074
1075 assert_eq!(session.ws_endpoint, "wss://gw.example.com/ws/s_1");
1076 assert_eq!(session.cdp_endpoint, "wss://gw.example.com/cdp/s_1");
1077 assert!(session.expires_at.ends_with('Z'), "{}", session.expires_at);
1079 assert_eq!(session.expires_at.len(), 24, "{}", session.expires_at);
1080 }
1081
1082 #[tokio::test]
1084 async fn create_session_rejects_bad_response() {
1085 let (l, port) = bind_mock().await;
1086 serve(l, vec![(201, r#"{"wsEndpoint":"wss://x/ws/1"}"#.to_string())]);
1087
1088 let client = client_for(port);
1089 let err = client
1090 .sessions()
1091 .create(CreateSessionOptions::default())
1092 .await
1093 .unwrap_err();
1094 assert!(matches!(err, SolariError::Protocol { .. }), "{err:?}");
1095 }
1096
1097 #[tokio::test]
1099 async fn storage_state_fetched_from_presigned_url() {
1100 let (l, port) = bind_mock().await;
1101 let create = format!(
1102 r#"{{"sessionId":"s_1","wsEndpoint":"wss://gw.example.com/ws/s_1","storageStateUrl":{{"url":"http://127.0.0.1:{port}/presigned","expiresInSeconds":60}}}}"#
1103 );
1104 let state = r#"{"cookies":[{"name":"sid","value":"abc","domain":".example.com","sameSite":"Lax"}],"origins":[{"origin":"https://example.com","localStorage":[{"name":"k","value":"v"}]}]}"#;
1105 serve(l, vec![(201, create), (200, state.to_string())]);
1106
1107 let client = client_for(port);
1108 let session = client
1109 .sessions()
1110 .create(CreateSessionOptions::new().profile_id("p_1"))
1111 .await
1112 .unwrap();
1113
1114 let state = session
1115 .storage_state
1116 .expect("profile attached → Some")
1117 .expect("presigned state → Some");
1118 let cookies = state.cookies.unwrap();
1119 assert_eq!(cookies[0].name, "sid");
1120 assert_eq!(cookies[0].same_site, Some(SameSite::Lax));
1121 let origins = state.origins.unwrap();
1122 assert_eq!(origins[0].origin, "https://example.com");
1123 assert_eq!(origins[0].local_storage.as_ref().unwrap()[0].value, "v");
1124 }
1125
1126 #[tokio::test]
1128 async fn storage_state_absent_vs_empty() {
1129 let (l, port) = bind_mock().await;
1130 serve(
1131 l,
1132 vec![
1133 (
1135 201,
1136 r#"{"sessionId":"s_1","wsEndpoint":"wss://x/ws/s_1","storageStateUrl":{"url":null}}"#
1137 .to_string(),
1138 ),
1139 (
1141 201,
1142 r#"{"sessionId":"s_2","wsEndpoint":"wss://x/ws/s_2"}"#.to_string(),
1143 ),
1144 (
1146 201,
1147 r#"{"sessionId":"s_3","wsEndpoint":"wss://x/ws/s_3","storageState":{"cookies":[]}}"#
1148 .to_string(),
1149 ),
1150 (
1152 201,
1153 r#"{"sessionId":"s_4","wsEndpoint":"wss://x/ws/s_4"}"#.to_string(),
1154 ),
1155 ],
1156 );
1157
1158 let client = client_for(port);
1159
1160 let s = client
1161 .sessions()
1162 .create(CreateSessionOptions::new().profile_id("p_1"))
1163 .await
1164 .unwrap();
1165 assert!(matches!(s.storage_state, Some(None)), "null url → Some(None)");
1166
1167 let s = client
1168 .sessions()
1169 .create(CreateSessionOptions::new().profile_id("p_1"))
1170 .await
1171 .unwrap();
1172 assert!(matches!(s.storage_state, Some(None)), "no url → Some(None)");
1173
1174 let s = client
1175 .sessions()
1176 .create(CreateSessionOptions::new().profile_id("p_1"))
1177 .await
1178 .unwrap();
1179 let inline = s.storage_state.expect("Some").expect("Some");
1180 assert_eq!(inline.cookies.unwrap().len(), 0);
1181
1182 let s = client
1183 .sessions()
1184 .create(CreateSessionOptions::default())
1185 .await
1186 .unwrap();
1187 assert!(s.storage_state.is_none(), "no profile → None");
1188 }
1189
1190 #[test]
1192 fn client_options_and_defaults() {
1193 let opts = ClientOptions::default_region("k");
1194 assert_eq!(opts.base_url, "https://api.getsolari.com");
1195 assert_eq!(opts.max_attempts, 2);
1196 assert_eq!(opts.backoff_ms, 500);
1197 assert_eq!(opts.timeout_ms, 90_000);
1198
1199 assert_eq!(
1200 ClientOptions::for_region("k", SolariRegion::UsWest).base_url,
1201 "https://api.getsolari.com"
1202 );
1203
1204 let c = Client::new(ClientOptions::new("k", "https://gw.example.com/")).unwrap();
1206 assert_eq!(c.base_url(), "https://gw.example.com");
1207
1208 let err = Client::new(ClientOptions::new("", "https://gw.example.com")).unwrap_err();
1210 assert!(matches!(err, SolariError::Config { .. }), "{err:?}");
1211 }
1212
1213 #[test]
1216 fn iso8601_formatting() {
1217 assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00.000Z");
1218 assert_eq!(iso8601_utc(1_768_000_000), "2026-01-09T23:06:40.000Z");
1219 assert_eq!(iso8601_utc(1_000_000_000), "2001-09-09T01:46:40.000Z");
1220 assert_eq!(iso8601_utc(1_709_208_000), "2024-02-29T12:00:00.000Z");
1222 assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00.000Z");
1224 assert_eq!(iso8601_utc(4_102_444_800), "2100-01-01T00:00:00.000Z");
1226 assert_eq!(iso8601_utc(-1), "1969-12-31T23:59:59.000Z");
1228 }
1229
1230 #[test]
1232 fn error_code_round_trip() {
1233 for code in [
1234 SolariErrorCode::FeatureRequiresPlan,
1235 SolariErrorCode::ConcurrencyLimitExceeded,
1236 SolariErrorCode::PlanLimitExceeded,
1237 SolariErrorCode::BrowserUnhealthy,
1238 ] {
1239 assert_eq!(SolariErrorCode::from(code.as_str()), code);
1240 }
1241 assert_eq!(
1242 SolariErrorCode::from("BrowserUnhealthy"),
1243 SolariErrorCode::BrowserUnhealthy
1244 );
1245 assert_eq!(
1246 SolariErrorCode::Other("Nope".into()).to_string(),
1247 "Nope".to_string()
1248 );
1249 }
1250
1251 #[cfg(feature = "connect")]
1257 #[tokio::test]
1258 async fn launch_retries_create_itself_on_transient_exhaustion() {
1259 let (l, port) = bind_mock().await;
1260 let captured = serve(
1261 l,
1262 vec![
1263 (503, "{}".to_string()),
1266 (503, "{}".to_string()),
1267 (400, r#"{"error":"bad request"}"#.to_string()),
1271 ],
1272 );
1273
1274 let client = client_for(port);
1275 let result = client
1276 .launch(LaunchOptions::new(CreateSessionOptions::default()).retries(1))
1277 .await;
1278 let Err(err) = result else {
1279 panic!("expected second create()'s 400 to end the loop, got Ok");
1280 };
1281
1282 assert_eq!(err.status(), Some(400));
1283 assert_eq!(captured.lock().unwrap().len(), 3);
1287 }
1288}