Skip to main content

agent_first_http/cli/cmd/
fetch.rs

1//! `afhttp fetch` subcommand.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::cli::connect::{Connection, Resolved};
7use crate::cli::output;
8use crate::cli::token_source;
9use crate::host::bootstrap::BrowserChoice;
10use crate::sdk::fetch::{FetchCookie, NetworkBodies, RenderMode, Wait};
11use crate::sdk::{Client, InlineConfig};
12use crate::shared::artifacts::Artifact;
13use crate::shared::error::{Error, ErrorCode};
14use crate::shared::ids::TabId;
15
16/// One fetch, already narrowed to a registered shape: `data` and `form` are
17/// never both set, and `takeover` never coexists with [`RenderMode::None`].
18#[derive(Debug)]
19pub struct Args {
20    pub url: String,
21    pub connection: Connection,
22    pub browser: BrowserChoice,
23    pub browser_bin: Option<PathBuf>,
24    pub render: RenderMode,
25    pub tab: String,
26    pub takeover: bool,
27    pub profile: Option<String>,
28    pub wait: String,
29    pub headers: Vec<String>,
30    pub cookies: Vec<String>,
31    pub user_agent: Option<String>,
32    pub evaluate_after_wait: Vec<String>,
33    pub want: Vec<String>,
34    pub method: String,
35    pub data: Option<String>,
36    pub form: Vec<String>,
37    pub network_bodies: NetworkBodies,
38    pub network_body_max_bytes: u64,
39    pub readiness_idle_ms: u64,
40    pub readiness_stable_ms: u64,
41    pub readiness_min_text_bytes: u64,
42    pub no_network_redact: bool,
43    pub out: Option<PathBuf>,
44    pub cookie_jar: Option<PathBuf>,
45    pub no_cookie_jar: bool,
46    pub observe_main_wait_ms: u64,
47    pub max_response_bytes: u64,
48    pub retry: u32,
49    pub backoff_ms: u64,
50    pub proxy: Option<String>,
51    pub ca_cert: Option<PathBuf>,
52    pub tls_insecure: bool,
53    pub timeout_ms: u64,
54    pub capture_ws: bool,
55    pub capture_sse: bool,
56}
57
58pub async fn run(args: Args) -> Result<(), Error> {
59    match run_inner(args).await {
60        Ok(()) => Ok(()),
61        Err(FetchRunError::Plain(err)) => {
62            let _ = crate::shared::afdata::emit_process_error(&err);
63            Err(err)
64        }
65        Err(FetchRunError::Emitted(err)) => Err(err),
66    }
67}
68
69async fn run_inner(args: Args) -> Result<(), FetchRunError> {
70    let render = args.render;
71    let endpoint_was_preconfigured = args.connection.is_explicit();
72    // Resolve the host profile: explicit --profile wins; otherwise --takeover
73    // derives the URL's registrable domain (eTLD+1) for per-domain isolation.
74    let explicit_profile = args.profile.clone();
75    let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
76        Some(p)
77    } else if args.takeover {
78        Some(default_profile_for_url(&args.url)?)
79    } else {
80        None
81    };
82    // A takeover needs a host a person can see, a profile needs one whose
83    // browser state persists, and neither is what the inline ephemeral browser
84    // is. Both used to be the caller's job to supply; now only naming a
85    // *different* host is.
86    let host = if args.takeover {
87        prepare_host(
88            &args.connection,
89            true,
90            resolved_profile.as_deref(),
91            |token| async move {
92                crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
93            },
94        )
95        .await?
96    } else {
97        prepare_host(
98            &args.connection,
99            false,
100            resolved_profile.as_deref(),
101            |token| async move {
102                crate::cli::cmd::container::discover_default_local_host(token.as_deref()).await
103            },
104        )
105        .await?
106    };
107    let takeover = args.takeover;
108    let takeover_endpoint = host.as_ref().map(|host| host.endpoint.clone());
109    let recommended_endpoint =
110        takeover_recommended_endpoint(takeover_endpoint.as_deref(), endpoint_was_preconfigured);
111    let takeover_token = host.as_ref().and_then(|host| host.token.clone());
112    let wait = Wait::parse(&args.wait)?;
113    let timeout = Duration::from_millis(args.timeout_ms);
114    let network_bodies = args.network_bodies;
115    let network_redact = !args.no_network_redact;
116
117    let body_bytes = resolve_body(&args).await?;
118    let want = resolve_want(&args.want);
119    let mut client = build_client(&args, host.as_ref(), render).await?;
120    if let Some(profile) = &resolved_profile {
121        client = client.with_profile(profile.clone());
122    }
123
124    let mut builder = client
125        .fetch(args.url.clone())
126        .render(render)
127        .wait(wait)
128        .timeout(timeout)
129        .network_bodies(network_bodies)
130        .network_body_max_bytes(args.network_body_max_bytes)
131        .readiness_idle_ms(args.readiness_idle_ms)
132        .readiness_stable_ms(args.readiness_stable_ms)
133        .readiness_min_text_bytes(args.readiness_min_text_bytes)
134        .network_redact(network_redact)
135        .method(args.method);
136    if let Some(want) = want {
137        builder = builder.want(want);
138    }
139    if let Some(bytes) = body_bytes {
140        builder = builder.body(bytes);
141    }
142    for raw in &args.form {
143        let (k, v) = raw.split_once('=').ok_or_else(|| {
144            Error::new(
145                ErrorCode::InvalidArgument,
146                format!("--form: expected key=value, got {raw:?}"),
147            )
148        })?;
149        builder = builder.form_field(k, v);
150    }
151    for raw in args.headers {
152        let (name, value) = parse_header_arg(&raw)?;
153        builder = builder.header(name, value);
154    }
155    for raw in args.cookies {
156        builder = builder.cookie_full(parse_cookie_arg(&raw)?);
157    }
158    if let Some(user_agent) = args.user_agent {
159        builder = builder.user_agent(user_agent);
160    }
161    for js in args.evaluate_after_wait {
162        builder = builder.evaluate_after_wait(js);
163    }
164    if args.tab != "new" {
165        builder = builder.tab(TabId::new(args.tab));
166    }
167    if takeover {
168        // Keep the prepared tab open so a human can take it over.
169        builder = builder.keep_tab_open(true);
170    }
171    if let Some(out) = args.out {
172        builder = builder.out_dir(out);
173    }
174    builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
175    builder = builder.max_response_bytes(args.max_response_bytes);
176    builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
177    if let Some(url) = args.proxy {
178        builder = builder.proxy(url);
179    }
180    if let Some(path) = args.ca_cert {
181        builder = builder.ca_cert(path);
182    }
183    if args.tls_insecure {
184        builder = builder.tls_insecure(true);
185    }
186    if args.capture_ws {
187        builder = builder.capture_ws(true);
188    }
189    if args.capture_sse {
190        builder = builder.capture_sse(true);
191    }
192    if args.no_cookie_jar {
193        builder = builder.no_cookie_jar();
194    } else {
195        let cookie_jar = args.cookie_jar.or_else(|| {
196            std::env::var_os("AFHTTP_COOKIE_JAR")
197                .filter(|v| !v.is_empty())
198                .map(PathBuf::from)
199        });
200        if let Some(jar) = cookie_jar {
201            builder = builder.cookie_jar(jar);
202        }
203    }
204
205    match builder.send_detailed().await {
206        Ok(mut result) => {
207            if takeover
208                && result.next_action.is_some()
209                && let Some(endpoint) = takeover_endpoint.as_deref()
210            {
211                let mut handoff_client = Client::connect(endpoint)?;
212                if let Some(token) = &takeover_token {
213                    handoff_client = handoff_client.with_token(token.expose_secret());
214                }
215                let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
216                let handoff = handoff_client
217                    .takeover_handoff(None, tab_id.as_deref())
218                    .await?;
219                result.attach_takeover_with_context(
220                    handoff.takeover_url_secret,
221                    Some(handoff.takeover_url_expires_at_rfc3339),
222                    Some(handoff.takeover_url_ttl_s),
223                    Some(handoff.takeover_url_scope),
224                    recommended_endpoint,
225                    explicit_profile.as_deref(),
226                );
227            }
228            if takeover {
229                Ok(output::emit_revealing_takeover("fetch", &result)?)
230            } else {
231                Ok(output::emit("fetch", &result)?)
232            }
233        }
234        Err(err) => {
235            let trace = serde_json::to_value(&err.trace).map_err(|e| {
236                Error::new(
237                    ErrorCode::InternalError,
238                    format!("serialize fetch error trace: {e}"),
239                )
240            })?;
241            let err = err.into_error();
242            crate::shared::afdata::emit_process_error_with(
243                err.error_code.as_str(),
244                &err.detail,
245                serde_json::json!({"retryable": err.retryable}),
246                trace,
247            )?;
248            Err(FetchRunError::Emitted(err))
249        }
250    }
251}
252
253/// The host this fetch talks to, or `None` when it needs none — a plain fetch
254/// with no profile and no takeover runs against the inline ephemeral browser,
255/// which is the fast path and stays the default.
256///
257/// `discover` is only called when a host is needed and the caller named none.
258/// An explicit `--token-secret` outranks the discovered host's own token: a
259/// caller who supplies a credential means it.
260///
261/// That `--takeover` requires a browser render is not checked here: the
262/// registry's takeover shapes pin `--render` to the modes that reach one, so an
263/// unusable mix never becomes an invocation.
264async fn prepare_host<D, Fut>(
265    connection: &Connection,
266    takeover: bool,
267    profile: Option<&str>,
268    discover: D,
269) -> Result<Option<Resolved>, Error>
270where
271    D: FnOnce(Option<String>) -> Fut,
272    Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalHost, Error>>,
273{
274    let token = connection
275        .token
276        .as_ref()
277        .map(token_source::read)
278        .transpose()?;
279    if let Some(endpoint) = connection.endpoint.clone() {
280        return Ok(Some(Resolved { endpoint, token }));
281    }
282    if !takeover && profile.is_none() {
283        return Ok(None);
284    }
285    let discovered = discover(
286        token
287            .as_ref()
288            .map(|token| token.expose_secret().to_string()),
289    )
290    .await?;
291    Ok(Some(Resolved {
292        endpoint: discovered.endpoint,
293        token: token.or(discovered.token_secret),
294    }))
295}
296
297fn takeover_recommended_endpoint(
298    endpoint: Option<&str>,
299    endpoint_was_preconfigured: bool,
300) -> Option<&str> {
301    if endpoint_was_preconfigured {
302        endpoint
303    } else {
304        None
305    }
306}
307
308/// Derive a default host profile from a URL's registrable domain (eTLD+1), so
309/// `fetch --takeover` gives each site its own isolated browser profile when
310/// `--profile` is omitted. Falls back to the full host for public-suffix
311/// tenants (e.g. `foo.github.io`) and bare IPs.
312fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
313    let parsed = url::Url::parse(raw_url).map_err(|e| {
314        Error::new(
315            ErrorCode::InvalidArgument,
316            format!(
317                "--takeover default profile needs a valid URL with a host; \
318                 could not parse {raw_url:?}: {e}; pass --profile <name>"
319            ),
320        )
321    })?;
322    let host = parsed.host().ok_or_else(|| {
323        Error::new(
324            ErrorCode::InvalidArgument,
325            format!(
326                "--takeover default profile needs URL {raw_url:?} to include a host; \
327                 pass --profile <name>"
328            ),
329        )
330    })?;
331    let (normalized_host, dns_name) = match host {
332        url::Host::Domain(domain) => (normalize_profile_host(domain), true),
333        url::Host::Ipv4(addr) => (addr.to_string(), false),
334        url::Host::Ipv6(addr) => (addr.to_string(), false),
335    };
336    let profile = if dns_name {
337        psl::domain_str(&normalized_host)
338            .unwrap_or(&normalized_host)
339            .to_string()
340    } else {
341        normalized_host.clone()
342    };
343    crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
344        Error::new(
345            e.error_code,
346            format!(
347                "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
348                 is invalid: {}; pass --profile <name>",
349                e.detail
350            ),
351        )
352    })?;
353    Ok(profile)
354}
355
356fn normalize_profile_host(host: &str) -> String {
357    host.trim_end_matches('.').to_ascii_lowercase()
358}
359
360enum FetchRunError {
361    Plain(Error),
362    Emitted(Error),
363}
364
365impl From<Error> for FetchRunError {
366    fn from(err: Error) -> Self {
367        Self::Plain(err)
368    }
369}
370
371/// Resolve the request body from `--data` (literal, or `@path` to read a file).
372///
373/// `--form` fields are wired separately by the caller, and the registry's three
374/// body shapes already guarantee the two never arrive together.
375async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
376    if let Some(data) = &args.data {
377        if let Some(path) = data.strip_prefix('@') {
378            return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
379                Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
380            })?));
381        }
382        return Ok(Some(data.as_bytes().to_vec()));
383    }
384    Ok(None)
385}
386
387/// Parse the `--want` tokens into an artifact set; `None` means use the
388/// render-mode-aware default. The registry declares `--want` as a closed value
389/// set, so every token that reaches here names an artifact.
390fn resolve_want(want: &[String]) -> Option<std::collections::BTreeSet<Artifact>> {
391    if want.is_empty() {
392        return None;
393    }
394    Some(
395        want.iter()
396            .filter_map(|token| parse_artifact(token))
397            .collect(),
398    )
399}
400
401/// Construct the SDK client for this fetch: a remote connection when
402/// `--endpoint-url` is set, the HTTP-only client for `--render none`, or an
403/// inline ephemeral host otherwise (lazy for `auto`, eager for `always`).
404async fn build_client(
405    args: &Args,
406    host: Option<&Resolved>,
407    render: RenderMode,
408) -> Result<Client, Error> {
409    match host {
410        Some(host) => host.client(),
411        None if matches!(render, RenderMode::None) => Client::http_only(),
412        None => {
413            let cfg = InlineConfig {
414                browser: args.browser.clone(),
415                browser_bin: args.browser_bin.clone(),
416            };
417            if matches!(render, RenderMode::Auto) {
418                Client::inline_ephemeral_lazy(cfg).await
419            } else {
420                Client::inline_ephemeral_with(cfg).await
421            }
422        }
423    }
424}
425
426fn parse_artifact(token: &str) -> Option<Artifact> {
427    Some(match token {
428        "body" => Artifact::Body,
429        "rendered_html" => Artifact::RenderedHtml,
430        "text" => Artifact::Text,
431        "content" => Artifact::Content,
432        "content_json" => Artifact::ContentJson,
433        "screenshot" => Artifact::Screenshot,
434        "network" => Artifact::Network,
435        "console" => Artifact::Console,
436        "observation" => Artifact::Observation,
437        "storage" => Artifact::Storage,
438        _ => return None,
439    })
440}
441
442fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
443    let (name, value) = raw.split_once(':').ok_or_else(|| {
444        Error::new(
445            ErrorCode::InvalidArgument,
446            format!("--header: expected K:V, got {raw:?}"),
447        )
448    })?;
449    let name = name.trim();
450    if name.is_empty() {
451        return Err(Error::new(
452            ErrorCode::InvalidArgument,
453            format!("--header: header name must not be empty in {raw:?}"),
454        ));
455    }
456    Ok((name.to_string(), value.trim_start().to_string()))
457}
458
459fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
460    if !raw.contains('=') {
461        return Err(Error::new(
462            ErrorCode::InvalidArgument,
463            format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
464        ));
465    }
466    let cookie = FetchCookie::parse(raw.to_string())
467        .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
468        .into_owned();
469    if cookie.name().trim().is_empty() {
470        return Err(Error::new(
471            ErrorCode::InvalidArgument,
472            format!("--cookie: cookie name must not be empty in {raw:?}"),
473        ));
474    }
475    Ok(cookie)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    /// A resolved token is a `SecretString`, so a test that asserts on one has
483    /// to say so — the same friction production code gets.
484    fn exposed(token: &Option<agent_first_data::value_source::SecretString>) -> Option<&str> {
485        token
486            .as_ref()
487            .map(agent_first_data::value_source::SecretString::expose_secret)
488    }
489
490    fn discovered(token_secret: Option<&str>) -> crate::cli::cmd::container::LocalHost {
491        crate::cli::cmd::container::LocalHost {
492            endpoint: "ws://127.0.0.1:9222".into(),
493            token_secret: token_secret.map(agent_first_data::value_source::SecretString::new),
494        }
495    }
496
497    /// The unreached case: a plain fetch has no host, which is what keeps the
498    /// inline ephemeral browser the default rather than a container.
499    #[tokio::test]
500    async fn a_plain_fetch_needs_no_host_and_discovers_nothing() {
501        let host = prepare_host(&Connection::default(), false, None, |_| async {
502            panic!("a plain fetch must not discover a host");
503        })
504        .await
505        .expect("no host is not an error");
506        assert!(host.is_none());
507    }
508
509    #[tokio::test]
510    async fn autodiscovery_fills_missing_endpoint_and_token() {
511        let host = prepare_host(
512            &Connection::default(),
513            true,
514            Some("contabo.com"),
515            |token| async move {
516                assert_eq!(token, None);
517                Ok(discovered(Some("secret")))
518            },
519        )
520        .await
521        .expect("discovery succeeds")
522        .expect("takeover needs a host");
523
524        assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
525        assert_eq!(exposed(&host.token), Some("secret"));
526    }
527
528    /// A profile switches the host's active browser state, so it needs a host
529    /// just as a takeover does — this used to be an error telling the caller to
530    /// pass `--endpoint-url`.
531    #[tokio::test]
532    async fn a_profile_alone_discovers_a_host() {
533        let host = prepare_host(&Connection::default(), false, Some("work"), |_| async {
534            Ok(discovered(Some("secret")))
535        })
536        .await
537        .expect("discovery succeeds")
538        .expect("a profile needs a host");
539        assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
540    }
541
542    #[tokio::test]
543    async fn autodiscovery_preserves_an_explicit_token() {
544        let connection = Connection::new(
545            None,
546            Some(agent_first_data::ValueSource::Literal("argv-token".into())),
547        );
548        let host = prepare_host(&connection, true, Some("contabo.com"), |token| async move {
549            assert_eq!(token.as_deref(), Some("argv-token"));
550            Ok(discovered(Some("container-token")))
551        })
552        .await
553        .expect("discovery succeeds")
554        .expect("takeover needs a host");
555
556        assert_eq!(host.endpoint, "ws://127.0.0.1:9222");
557        assert_eq!(exposed(&host.token), Some("argv-token"));
558    }
559
560    #[tokio::test]
561    async fn autodiscovery_surfaces_failure() {
562        let err = prepare_host(
563            &Connection::default(),
564            true,
565            Some("contabo.com"),
566            |_| async {
567                Err(Error::new(
568                    ErrorCode::InvalidArgument,
569                    "default local container `afhttp-host` is not running",
570                ))
571            },
572        )
573        .await
574        .expect_err("a missing container is an error");
575
576        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
577        assert!(err.detail.contains("afhttp-host"));
578    }
579
580    /// A named endpoint is used as given, and nothing is discovered behind it.
581    #[tokio::test]
582    async fn a_named_endpoint_is_never_second_guessed() {
583        let connection = Connection::new(Some("ws://box:9222".into()), None);
584        let host = prepare_host(&connection, true, Some("contabo.com"), |_| async {
585            panic!("an explicit endpoint must not discover");
586        })
587        .await
588        .expect("explicit host resolves")
589        .expect("takeover needs a host");
590        assert_eq!(host.endpoint, "ws://box:9222");
591        assert!(host.token.is_none());
592    }
593
594    /// Every value the registry lets `--want` carry names a real artifact, so
595    /// the projection can be total.
596    #[test]
597    fn every_registry_want_value_names_an_artifact() {
598        for token in crate::cli::spec::ARTIFACTS {
599            assert!(parse_artifact(token).is_some(), "{token}");
600        }
601        let wanted = resolve_want(&["body".to_string(), "network".to_string()])
602            .expect("an explicit want is a set");
603        assert!(wanted.contains(&Artifact::Body));
604        assert!(wanted.contains(&Artifact::Network));
605        assert!(resolve_want(&[]).is_none(), "no want means the default set");
606    }
607
608    #[test]
609    fn takeover_recommendation_omits_auto_discovered_endpoint() {
610        assert_eq!(
611            takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), false),
612            None
613        );
614    }
615
616    #[test]
617    fn takeover_recommendation_keeps_preconfigured_endpoint() {
618        assert_eq!(
619            takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), true),
620            Some("ws://127.0.0.1:9222")
621        );
622    }
623
624    #[test]
625    fn default_profile_uses_registrable_domain() {
626        assert_eq!(
627            default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
628            "court.gov.cn"
629        );
630        assert_eq!(
631            default_profile_for_url("https://accounts.google.com/foo").unwrap(),
632            "google.com"
633        );
634        assert_eq!(
635            default_profile_for_url("https://contabo.com").unwrap(),
636            "contabo.com"
637        );
638    }
639
640    #[test]
641    fn default_profile_keeps_public_suffix_tenants_isolated() {
642        assert_eq!(
643            default_profile_for_url("https://foo.github.io/x").unwrap(),
644            "foo.github.io"
645        );
646        assert_eq!(
647            default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
648            "tenant.vercel.app"
649        );
650    }
651
652    #[test]
653    fn default_profile_normalizes_case_and_trailing_dot() {
654        assert_eq!(
655            default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
656            "example.com"
657        );
658    }
659
660    #[test]
661    fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
662        assert_eq!(
663            default_profile_for_url("http://localhost:8080/foo").unwrap(),
664            "localhost"
665        );
666        assert_eq!(
667            default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
668            "127.0.0.1"
669        );
670    }
671
672    #[test]
673    fn default_profile_errors_when_host_is_missing() {
674        assert!(default_profile_for_url("file:///tmp/page.html").is_err());
675    }
676
677    #[test]
678    fn header_arg_accepts_colon_separator() {
679        assert_eq!(
680            parse_header_arg("X-Test: yes").unwrap(),
681            ("X-Test".to_string(), "yes".to_string())
682        );
683    }
684
685    #[test]
686    fn header_arg_rejects_missing_colon() {
687        let err = parse_header_arg("X-Test").err().unwrap();
688        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
689    }
690
691    #[test]
692    fn cookie_arg_accepts_equals_separator() {
693        let cookie = parse_cookie_arg("sid=abc=def").unwrap();
694        assert_eq!(cookie.name_value(), ("sid", "abc=def"));
695    }
696
697    #[test]
698    fn cookie_arg_accepts_full_set_cookie_attributes() {
699        let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
700        assert_eq!(cookie.name_value(), ("sid", "abc"));
701        assert_eq!(cookie.path(), Some("/"));
702        assert_eq!(cookie.secure(), Some(true));
703        assert_eq!(cookie.http_only(), Some(true));
704        assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
705    }
706
707    #[test]
708    fn cookie_arg_rejects_missing_equals() {
709        let err = parse_cookie_arg("sid").err().unwrap();
710        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
711    }
712}