1use std::path::PathBuf;
4use std::time::Duration;
5
6use clap::Args as ClapArgs;
7use clap::ValueEnum;
8
9use crate::cli::cmd::argenums::{BrowserArg, RenderArg};
10use crate::cli::output;
11use crate::sdk::fetch::{
12 FetchCookie, NetworkBodies, RenderMode, Wait, DEFAULT_NETWORK_BODY_MAX_BYTES,
13};
14use crate::sdk::{Client, InlineConfig};
15use crate::shared::artifacts::Artifact;
16use crate::shared::error::{Error, ErrorCode};
17use crate::shared::ids::TabId;
18
19#[derive(ValueEnum, Debug, Clone, Copy, Default)]
20pub enum NetworkBodiesArg {
21 #[default]
22 Off,
23 Xhr,
24 All,
25}
26
27impl From<NetworkBodiesArg> for NetworkBodies {
28 fn from(v: NetworkBodiesArg) -> Self {
29 match v {
30 NetworkBodiesArg::Off => NetworkBodies::Off,
31 NetworkBodiesArg::Xhr => NetworkBodies::Xhr,
32 NetworkBodiesArg::All => NetworkBodies::All,
33 }
34 }
35}
36
37impl std::fmt::Display for NetworkBodiesArg {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 f.write_str(match self {
40 Self::Off => "off",
41 Self::Xhr => "xhr",
42 Self::All => "all",
43 })
44 }
45}
46
47#[derive(ClapArgs, Debug)]
48pub struct Args {
49 pub url: String,
51 #[arg(
55 long = "endpoint-url",
56 env = "AFHTTP_ENDPOINT_URL",
57 help_heading = "Connection"
58 )]
59 pub endpoint: Option<String>,
60 #[arg(
63 long = "token-secret",
64 env = "AFHTTP_TOKEN_SECRET",
65 help_heading = "Connection"
66 )]
67 pub token: Option<String>,
68 #[arg(long, default_value = "auto", help_heading = "Inline host")]
71 pub browser: BrowserArg,
72 #[arg(
75 long = "browser-bin",
76 value_name = "PATH",
77 help_heading = "Inline host"
78 )]
79 pub browser_bin: Option<PathBuf>,
80 #[arg(long, default_value = "auto", help_heading = "Rendering")]
83 pub render: RenderArg,
84 #[arg(
88 long,
89 default_value = "new",
90 value_name = "new|<id>",
91 help_heading = "Session"
92 )]
93 pub tab: String,
94 #[arg(long, help_heading = "Session")]
101 pub takeover: bool,
102 #[arg(long, help_heading = "Session")]
108 pub profile: Option<String>,
109 #[arg(long, default_value = "auto", help_heading = "Rendering")]
112 pub wait: String,
113 #[arg(long = "header", value_name = "NAME:VALUE", help_heading = "Request")]
116 pub headers: Vec<String>,
117 #[arg(long = "cookie", value_name = "NAME=VALUE", help_heading = "Request")]
119 pub cookies: Vec<String>,
120 #[arg(long, help_heading = "Request")]
122 pub user_agent: Option<String>,
123 #[arg(long, value_name = "JS", help_heading = "Rendering")]
126 pub evaluate_after_wait: Vec<String>,
127 #[arg(long, value_delimiter = ',', help_heading = "Rendering")]
133 pub want: Vec<String>,
134 #[arg(long, default_value = "GET", help_heading = "Request")]
136 pub method: String,
137 #[arg(long, value_name = "STRING|@FILE", help_heading = "Request")]
140 pub data: Option<String>,
141 #[arg(long = "form", value_name = "NAME=VALUE", help_heading = "Request")]
145 pub form: Vec<String>,
146 #[arg(long, default_value_t = NetworkBodiesArg::Off, help_heading = "Network capture")]
149 pub network_bodies: NetworkBodiesArg,
150 #[arg(long, default_value_t = DEFAULT_NETWORK_BODY_MAX_BYTES, help_heading = "Network capture")]
153 pub network_body_max_bytes: u64,
154 #[arg(long, default_value_t = 800, help_heading = "Readiness tuning")]
156 pub readiness_idle_ms: u64,
157 #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
159 pub readiness_stable_ms: u64,
160 #[arg(long, default_value_t = 32, help_heading = "Readiness tuning")]
162 pub readiness_min_text_bytes: u64,
163 #[arg(long, help_heading = "Network capture")]
167 pub no_network_redact: bool,
168 #[arg(long, help_heading = "Output")]
171 pub out: Option<PathBuf>,
172 #[arg(long, help_heading = "Cookies")]
179 pub cookie_jar: Option<PathBuf>,
180 #[arg(long, help_heading = "Cookies")]
184 pub no_cookie_jar: bool,
185 #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
188 pub observe_main_wait_ms: u64,
189 #[arg(long, default_value_t = 1_073_741_824, help_heading = "HTTP transport")]
196 pub max_response_bytes: u64,
197 #[arg(long, default_value_t = 0, help_heading = "Retry")]
203 pub retry: u32,
204 #[arg(long, default_value_t = 250, help_heading = "Retry")]
206 pub backoff_ms: u64,
207 #[arg(long = "proxy-url", help_heading = "HTTP transport")]
212 pub proxy: Option<String>,
213 #[arg(long, help_heading = "HTTP transport")]
217 pub ca_cert: Option<PathBuf>,
218 #[arg(long, help_heading = "HTTP transport")]
222 pub tls_insecure: bool,
223 #[arg(
226 long = "timeout-ms",
227 default_value_t = 30_000,
228 help_heading = "HTTP transport"
229 )]
230 pub timeout_ms: u64,
231 #[arg(long, help_heading = "Network capture")]
235 pub capture_ws: bool,
236 #[arg(long, help_heading = "Network capture")]
239 pub capture_sse: bool,
240}
241
242pub async fn run(args: Args) -> Result<(), Error> {
243 match run_inner(args).await {
244 Ok(()) => Ok(()),
245 Err(FetchRunError::Plain(err)) => {
246 let stdout = std::io::stdout();
247 let mut handle = stdout.lock();
248 let _ = crate::shared::envelope::emit_error(&mut handle, &err);
249 Err(err)
250 }
251 Err(FetchRunError::Emitted(err)) => Err(err),
252 }
253}
254
255async fn run_inner(mut args: Args) -> Result<(), FetchRunError> {
256 let render: RenderMode = args.render.into();
257 let endpoint_was_preconfigured = args.endpoint.is_some();
258 prepare_takeover_connection(&mut args, render, |token| async move {
259 crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
260 })
261 .await?;
262 let explicit_profile = args.profile.clone();
265 let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
266 Some(p)
267 } else if args.takeover {
268 Some(default_profile_for_url(&args.url)?)
269 } else {
270 None
271 };
272 if resolved_profile.is_some() && args.endpoint.is_none() {
273 return Err(Error::new(
274 ErrorCode::InvalidArgument,
275 "--profile (and --takeover profile derivation) switch the host's active profile and require a host; pass --endpoint-url or set AFHTTP_ENDPOINT_URL",
276 )
277 .into());
278 }
279 let takeover = args.takeover;
280 let takeover_endpoint = args.endpoint.clone();
281 let recommended_endpoint =
282 takeover_recommended_endpoint(takeover_endpoint.as_deref(), endpoint_was_preconfigured);
283 let takeover_token = args.token.clone();
284 let wait = Wait::parse(&args.wait)?;
285 let timeout = Duration::from_millis(args.timeout_ms);
286 let network_bodies = NetworkBodies::from(args.network_bodies);
287 let network_redact = !args.no_network_redact;
288
289 let body_bytes = resolve_body(&args).await?;
290 let want = resolve_want(&args.want)?;
291 let mut client = build_client(&args, render).await?;
292 if let Some(profile) = &resolved_profile {
293 client = client.with_profile(profile.clone());
294 }
295
296 let mut builder = client
297 .fetch(args.url.clone())
298 .render(render)
299 .wait(wait)
300 .timeout(timeout)
301 .network_bodies(network_bodies)
302 .network_body_max_bytes(args.network_body_max_bytes)
303 .readiness_idle_ms(args.readiness_idle_ms)
304 .readiness_stable_ms(args.readiness_stable_ms)
305 .readiness_min_text_bytes(args.readiness_min_text_bytes)
306 .network_redact(network_redact)
307 .method(args.method);
308 if let Some(want) = want {
309 builder = builder.want(want);
310 }
311 if let Some(bytes) = body_bytes {
312 builder = builder.body(bytes);
313 }
314 for raw in &args.form {
315 let (k, v) = raw.split_once('=').ok_or_else(|| {
316 Error::new(
317 ErrorCode::InvalidArgument,
318 format!("--form: expected key=value, got {raw:?}"),
319 )
320 })?;
321 builder = builder.form_field(k, v);
322 }
323 for raw in args.headers {
324 let (name, value) = parse_header_arg(&raw)?;
325 builder = builder.header(name, value);
326 }
327 for raw in args.cookies {
328 builder = builder.cookie_full(parse_cookie_arg(&raw)?);
329 }
330 if let Some(user_agent) = args.user_agent {
331 builder = builder.user_agent(user_agent);
332 }
333 for js in args.evaluate_after_wait {
334 builder = builder.evaluate_after_wait(js);
335 }
336 if args.tab != "new" {
337 builder = builder.tab(TabId::new(args.tab));
338 }
339 if takeover {
340 builder = builder.keep_tab_open(true);
342 }
343 if let Some(out) = args.out {
344 builder = builder.out_dir(out);
345 }
346 builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
347 builder = builder.max_response_bytes(args.max_response_bytes);
348 builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
349 if let Some(url) = args.proxy {
350 builder = builder.proxy(url);
351 }
352 if let Some(path) = args.ca_cert {
353 builder = builder.ca_cert(path);
354 }
355 if args.tls_insecure {
356 builder = builder.tls_insecure(true);
357 }
358 if args.capture_ws {
359 builder = builder.capture_ws(true);
360 }
361 if args.capture_sse {
362 builder = builder.capture_sse(true);
363 }
364 if args.no_cookie_jar {
365 builder = builder.no_cookie_jar();
366 } else {
367 let cookie_jar = args.cookie_jar.or_else(|| {
368 std::env::var_os("AFHTTP_COOKIE_JAR")
369 .filter(|v| !v.is_empty())
370 .map(PathBuf::from)
371 });
372 if let Some(jar) = cookie_jar {
373 builder = builder.cookie_jar(jar);
374 }
375 }
376
377 match builder.send_detailed().await {
378 Ok(mut result) => {
379 if takeover && result.next_action.is_some() {
380 if let Some(endpoint) = takeover_endpoint.as_deref() {
381 let mut handoff_client = Client::connect(endpoint)?;
382 if let Some(token) = takeover_token.as_deref() {
383 handoff_client = handoff_client.with_token(token);
384 }
385 let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
386 let handoff = handoff_client
387 .takeover_handoff(None, tab_id.as_deref())
388 .await?;
389 result.attach_takeover_with_context(
390 handoff.takeover_url,
391 Some(handoff.takeover_url_expires_at_rfc3339),
392 Some(handoff.takeover_url_ttl_s),
393 Some(handoff.takeover_url_scope),
394 recommended_endpoint,
395 explicit_profile.as_deref(),
396 );
397 }
398 }
399 Ok(output::emit("fetch", &result)?)
400 }
401 Err(err) => {
402 output::emit("error", &err)?;
403 Err(FetchRunError::Emitted(err.into_error()))
404 }
405 }
406}
407
408async fn prepare_takeover_connection<D, Fut>(
409 args: &mut Args,
410 render: RenderMode,
411 discover: D,
412) -> Result<(), Error>
413where
414 D: FnOnce(Option<String>) -> Fut,
415 Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalTakeoverHost, Error>>,
416{
417 if !args.takeover {
418 return Ok(());
419 }
420 if matches!(render, RenderMode::None) {
421 return Err(Error::new(
422 ErrorCode::InvalidArgument,
423 "fetch --takeover needs a browser render; use --render auto or always",
424 ));
425 }
426 if args.endpoint.is_none() {
427 let discovered = discover(args.token.clone()).await?;
428 args.endpoint = Some(discovered.endpoint);
429 if args.token.is_none() {
430 args.token = discovered.token_secret;
431 }
432 }
433 Ok(())
434}
435
436fn takeover_recommended_endpoint(
437 endpoint: Option<&str>,
438 endpoint_was_preconfigured: bool,
439) -> Option<&str> {
440 if endpoint_was_preconfigured {
441 endpoint
442 } else {
443 None
444 }
445}
446
447fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
452 let parsed = url::Url::parse(raw_url).map_err(|e| {
453 Error::new(
454 ErrorCode::InvalidArgument,
455 format!(
456 "--takeover default profile needs a valid URL with a host; \
457 could not parse {raw_url:?}: {e}; pass --profile <name>"
458 ),
459 )
460 })?;
461 let host = parsed.host().ok_or_else(|| {
462 Error::new(
463 ErrorCode::InvalidArgument,
464 format!(
465 "--takeover default profile needs URL {raw_url:?} to include a host; \
466 pass --profile <name>"
467 ),
468 )
469 })?;
470 let (normalized_host, dns_name) = match host {
471 url::Host::Domain(domain) => (normalize_profile_host(domain), true),
472 url::Host::Ipv4(addr) => (addr.to_string(), false),
473 url::Host::Ipv6(addr) => (addr.to_string(), false),
474 };
475 let profile = if dns_name {
476 psl::domain_str(&normalized_host)
477 .unwrap_or(&normalized_host)
478 .to_string()
479 } else {
480 normalized_host.clone()
481 };
482 crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
483 Error::new(
484 e.error_code,
485 format!(
486 "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
487 is invalid: {}; pass --profile <name>",
488 e.detail
489 ),
490 )
491 })?;
492 Ok(profile)
493}
494
495fn normalize_profile_host(host: &str) -> String {
496 host.trim_end_matches('.').to_ascii_lowercase()
497}
498
499enum FetchRunError {
500 Plain(Error),
501 Emitted(Error),
502}
503
504impl From<Error> for FetchRunError {
505 fn from(err: Error) -> Self {
506 Self::Plain(err)
507 }
508}
509
510async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
514 if args.data.is_some() && !args.form.is_empty() {
515 return Err(Error::new(
516 ErrorCode::InvalidArgument,
517 "--data and --form are mutually exclusive",
518 ));
519 }
520 if let Some(data) = &args.data {
521 if let Some(path) = data.strip_prefix('@') {
522 return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
523 Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
524 })?));
525 }
526 return Ok(Some(data.as_bytes().to_vec()));
527 }
528 Ok(None)
529}
530
531fn resolve_want(want: &[String]) -> Result<Option<std::collections::BTreeSet<Artifact>>, Error> {
534 if want.is_empty() {
535 return Ok(None);
536 }
537 want.iter()
538 .map(|t| parse_artifact(t))
539 .collect::<Result<std::collections::BTreeSet<_>, _>>()
540 .map(Some)
541}
542
543async fn build_client(args: &Args, render: RenderMode) -> Result<Client, Error> {
547 match args.endpoint.as_deref() {
548 Some(ep) => {
549 let mut c = Client::connect(ep)?;
550 if let Some(t) = args.token.as_deref() {
551 c = c.with_token(t);
552 }
553 Ok(c)
554 }
555 None if matches!(render, RenderMode::None) => Client::http_only(),
556 None => {
557 let cfg = InlineConfig {
558 browser: args.browser.into(),
559 browser_bin: args.browser_bin.clone(),
560 };
561 if matches!(render, RenderMode::Auto) {
562 Client::inline_ephemeral_lazy(cfg).await
563 } else {
564 Client::inline_ephemeral_with(cfg).await
565 }
566 }
567 }
568}
569
570fn parse_artifact(token: &str) -> Result<Artifact, Error> {
571 Ok(match token {
572 "body" => Artifact::Body,
573 "rendered_html" => Artifact::RenderedHtml,
574 "text" => Artifact::Text,
575 "content" => Artifact::Content,
576 "content_json" => Artifact::ContentJson,
577 "screenshot" => Artifact::Screenshot,
578 "network" => Artifact::Network,
579 "console" => Artifact::Console,
580 "observation" => Artifact::Observation,
581 "storage" => Artifact::Storage,
582 other => {
583 return Err(Error::new(
584 ErrorCode::InvalidArgument,
585 format!("--want: unknown artifact {other:?}"),
586 ));
587 }
588 })
589}
590
591fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
592 let (name, value) = raw.split_once(':').ok_or_else(|| {
593 Error::new(
594 ErrorCode::InvalidArgument,
595 format!("--header: expected K:V, got {raw:?}"),
596 )
597 })?;
598 let name = name.trim();
599 if name.is_empty() {
600 return Err(Error::new(
601 ErrorCode::InvalidArgument,
602 format!("--header: header name must not be empty in {raw:?}"),
603 ));
604 }
605 Ok((name.to_string(), value.trim_start().to_string()))
606}
607
608fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
609 if !raw.contains('=') {
610 return Err(Error::new(
611 ErrorCode::InvalidArgument,
612 format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
613 ));
614 }
615 let cookie = FetchCookie::parse(raw.to_string())
616 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
617 .into_owned();
618 if cookie.name().trim().is_empty() {
619 return Err(Error::new(
620 ErrorCode::InvalidArgument,
621 format!("--cookie: cookie name must not be empty in {raw:?}"),
622 ));
623 }
624 Ok(cookie)
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 fn base_args(url: &str) -> Args {
632 Args {
633 url: url.to_string(),
634 endpoint: None,
635 token: None,
636 browser: BrowserArg::Auto,
637 browser_bin: None,
638 render: RenderArg::Auto,
639 tab: "new".into(),
640 takeover: false,
641 profile: None,
642 wait: "auto".into(),
643 headers: Vec::new(),
644 cookies: Vec::new(),
645 user_agent: None,
646 evaluate_after_wait: Vec::new(),
647 want: Vec::new(),
648 method: "GET".into(),
649 data: None,
650 form: Vec::new(),
651 network_bodies: NetworkBodiesArg::Off,
652 network_body_max_bytes: DEFAULT_NETWORK_BODY_MAX_BYTES,
653 readiness_idle_ms: 800,
654 readiness_stable_ms: 500,
655 readiness_min_text_bytes: 32,
656 no_network_redact: false,
657 out: None,
658 cookie_jar: None,
659 no_cookie_jar: false,
660 observe_main_wait_ms: 500,
661 max_response_bytes: 1_073_741_824,
662 retry: 0,
663 backoff_ms: 250,
664 proxy: None,
665 ca_cert: None,
666 tls_insecure: false,
667 timeout_ms: 30_000,
668 capture_ws: false,
669 capture_sse: false,
670 }
671 }
672
673 #[tokio::test]
674 async fn takeover_autodiscovery_fills_missing_endpoint_and_token() {
675 let mut args = base_args("https://contabo.com");
676 args.takeover = true;
677 prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
678 assert_eq!(token, None);
679 Ok(crate::cli::cmd::container::LocalTakeoverHost {
680 endpoint: "ws://127.0.0.1:9222".into(),
681 token_secret: Some("secret".into()),
682 })
683 })
684 .await
685 .unwrap();
686
687 assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
688 assert_eq!(args.token.as_deref(), Some("secret"));
689 }
690
691 #[tokio::test]
692 async fn takeover_autodiscovery_preserves_existing_token() {
693 let mut args = base_args("https://contabo.com");
694 args.takeover = true;
695 args.token = Some("env-token".into());
696 prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
697 assert_eq!(token.as_deref(), Some("env-token"));
698 Ok(crate::cli::cmd::container::LocalTakeoverHost {
699 endpoint: "ws://127.0.0.1:9222".into(),
700 token_secret: Some("container-token".into()),
701 })
702 })
703 .await
704 .unwrap();
705
706 assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
707 assert_eq!(args.token.as_deref(), Some("env-token"));
708 }
709
710 #[tokio::test]
711 async fn takeover_autodiscovery_surfaces_failure() {
712 let mut args = base_args("https://contabo.com");
713 args.takeover = true;
714 let err = prepare_takeover_connection(&mut args, RenderMode::Auto, |_| async {
715 Err(Error::new(
716 ErrorCode::InvalidArgument,
717 "default local container `afhttp-host` is not running",
718 ))
719 })
720 .await
721 .err()
722 .unwrap();
723
724 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
725 assert!(err.detail.contains("afhttp-host"));
726 assert!(args.endpoint.is_none());
727 }
728
729 #[tokio::test]
730 async fn takeover_autodiscovery_rejects_render_none() {
731 let mut args = base_args("https://contabo.com");
732 args.takeover = true;
733 let err = prepare_takeover_connection(&mut args, RenderMode::None, |_| async {
734 Ok(crate::cli::cmd::container::LocalTakeoverHost {
735 endpoint: "ws://127.0.0.1:9222".into(),
736 token_secret: None,
737 })
738 })
739 .await
740 .err()
741 .unwrap();
742
743 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
744 assert!(err.detail.contains("browser render"));
745 }
746
747 #[test]
748 fn takeover_recommendation_omits_auto_discovered_endpoint() {
749 assert_eq!(
750 takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), false),
751 None
752 );
753 }
754
755 #[test]
756 fn takeover_recommendation_keeps_preconfigured_endpoint() {
757 assert_eq!(
758 takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), true),
759 Some("ws://127.0.0.1:9222")
760 );
761 }
762
763 #[test]
764 fn default_profile_uses_registrable_domain() {
765 assert_eq!(
766 default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
767 "court.gov.cn"
768 );
769 assert_eq!(
770 default_profile_for_url("https://accounts.google.com/foo").unwrap(),
771 "google.com"
772 );
773 assert_eq!(
774 default_profile_for_url("https://contabo.com").unwrap(),
775 "contabo.com"
776 );
777 }
778
779 #[test]
780 fn default_profile_keeps_public_suffix_tenants_isolated() {
781 assert_eq!(
782 default_profile_for_url("https://foo.github.io/x").unwrap(),
783 "foo.github.io"
784 );
785 assert_eq!(
786 default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
787 "tenant.vercel.app"
788 );
789 }
790
791 #[test]
792 fn default_profile_normalizes_case_and_trailing_dot() {
793 assert_eq!(
794 default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
795 "example.com"
796 );
797 }
798
799 #[test]
800 fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
801 assert_eq!(
802 default_profile_for_url("http://localhost:8080/foo").unwrap(),
803 "localhost"
804 );
805 assert_eq!(
806 default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
807 "127.0.0.1"
808 );
809 }
810
811 #[test]
812 fn default_profile_errors_when_host_is_missing() {
813 assert!(default_profile_for_url("file:///tmp/page.html").is_err());
814 }
815
816 #[test]
817 fn header_arg_accepts_colon_separator() {
818 assert_eq!(
819 parse_header_arg("X-Test: yes").unwrap(),
820 ("X-Test".to_string(), "yes".to_string())
821 );
822 }
823
824 #[test]
825 fn header_arg_rejects_missing_colon() {
826 let err = parse_header_arg("X-Test").err().unwrap();
827 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
828 }
829
830 #[test]
831 fn cookie_arg_accepts_equals_separator() {
832 let cookie = parse_cookie_arg("sid=abc=def").unwrap();
833 assert_eq!(cookie.name_value(), ("sid", "abc=def"));
834 }
835
836 #[test]
837 fn cookie_arg_accepts_full_set_cookie_attributes() {
838 let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
839 assert_eq!(cookie.name_value(), ("sid", "abc"));
840 assert_eq!(cookie.path(), Some("/"));
841 assert_eq!(cookie.secure(), Some(true));
842 assert_eq!(cookie.http_only(), Some(true));
843 assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
844 }
845
846 #[test]
847 fn cookie_arg_rejects_missing_equals() {
848 let err = parse_cookie_arg("sid").err().unwrap();
849 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
850 }
851}