Skip to main content

bobby_browser_client/
http.rs

1use chrono::{Duration as ChronoDuration, Utc};
2use reqwest::{Client, Method};
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6use std::time::Duration;
7use thiserror::Error;
8use uuid::Uuid;
9
10use crate::{
11    CheckpointRequest, CommandEnvelope, CommandOutcome, ContextAskResponse, ContextMissReason,
12    ContextNeighborsResponse, ContextNextStep, ContextSiteResponse, CreateSessionRequest,
13    FormSnapshot, JobId, JobStatusResponse, JobSubmitResponse, OpenPageRequest, PageId, PageState,
14    RecoveryDecision, RecoveryStatus, RuntimeInfo, SessionId, SessionState, SubmitJobRequest,
15    WorkflowCheckpoint, WorkflowId, CURRENT_INTERFACE_VERSION,
16};
17
18/// Client hard bounds for [`BrowserRuntimeClient::artifact`], mirroring the
19/// TypeScript SDK defaults.
20pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
21pub const MAX_SDK_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
22
23/// Errors returned by [`BrowserRuntimeClient`].
24///
25/// Messages are redacted so the bearer token never appears in transport,
26/// HTTP, or protocol error text.
27#[derive(Debug, Error)]
28pub enum ClientError {
29    /// Network or HTTP-client failure before a valid response body.
30    #[error("transport error: {0}")]
31    Transport(String),
32    /// Non-success HTTP status with response body text.
33    #[error("HTTP {status}: {message}")]
34    Http { status: u16, message: String },
35    /// Response body could not be interpreted, or client preconditions failed.
36    #[error("protocol error: {0}")]
37    Protocol(String),
38}
39
40impl ClientError {
41    fn redact(self, bearer: &str) -> Self {
42        match self {
43            Self::Transport(message) => Self::Transport(message.replace(bearer, "")),
44            Self::Http { status, message } => Self::Http {
45                status,
46                message: message.replace(bearer, ""),
47            },
48            Self::Protocol(message) => Self::Protocol(message.replace(bearer, "")),
49        }
50    }
51}
52
53/// Options for a single request.
54#[derive(Debug, Clone, Default)]
55pub struct RequestOptions {
56    /// Request timeout (defaults to 30 seconds).
57    pub timeout: Option<Duration>,
58    /// Value for `x-correlation-id` (generated UUID when omitted).
59    pub correlation_id: Option<String>,
60    /// Value for `idempotency-key` when the operation supports it.
61    pub idempotency_key: Option<String>,
62}
63
64/// Authenticated HTTP client for the Bobby Browser `/v1` runtime interface.
65///
66/// Construct with [`BrowserRuntimeClient::new`]. `base_url` is the runtime
67/// origin; a trailing slash and a trailing `/v1` are stripped.
68#[derive(Debug, Clone)]
69pub struct BrowserRuntimeClient {
70    base_url: String,
71    bearer_token: String,
72    http: Client,
73    default_timeout: Duration,
74    max_artifact_bytes: u64,
75}
76
77impl BrowserRuntimeClient {
78    /// Create a client.
79    ///
80    /// `base_url` is the runtime origin (`http://127.0.0.1:7777` or `…/v1`).
81    /// Returns [`ClientError::Protocol`] when `base_url` or `bearer_token` is empty.
82    pub fn new(
83        base_url: impl Into<String>,
84        bearer_token: impl Into<String>,
85    ) -> Result<Self, ClientError> {
86        Self::with_options(base_url, bearer_token, DEFAULT_MAX_ARTIFACT_BYTES)
87    }
88
89    /// Create a client with an explicit artifact byte cap (at most
90    /// [`MAX_SDK_ARTIFACT_BYTES`]).
91    pub fn with_options(
92        base_url: impl Into<String>,
93        bearer_token: impl Into<String>,
94        max_artifact_bytes: u64,
95    ) -> Result<Self, ClientError> {
96        let bearer_token = bearer_token.into();
97        if bearer_token.is_empty() {
98            return Err(ClientError::Protocol(
99                "bearerToken must not be empty".into(),
100            ));
101        }
102        if max_artifact_bytes == 0 || max_artifact_bytes > MAX_SDK_ARTIFACT_BYTES {
103            return Err(ClientError::Protocol(
104                "maxArtifactBytes must be positive and within the SDK allocation cap".into(),
105            ));
106        }
107        let base_url = normalize_base_url(base_url.into());
108        if base_url.is_empty() {
109            return Err(ClientError::Protocol("baseUrl must not be empty".into()));
110        }
111        let http = Client::builder()
112            .timeout(Duration::from_secs(30))
113            .build()
114            .map_err(|error| ClientError::Transport(error.to_string()))?;
115        Ok(Self {
116            base_url,
117            bearer_token,
118            http,
119            default_timeout: Duration::from_secs(30),
120            max_artifact_bytes,
121        })
122    }
123
124    /// `GET /v1/runtime` — version, capabilities, and load counters.
125    pub async fn runtime_info(
126        &self,
127        options: Option<RequestOptions>,
128    ) -> Result<RuntimeInfo, ClientError> {
129        self.json(Method::GET, "/v1/runtime", None::<()>, options)
130            .await
131    }
132
133    /// `POST /v1/sessions` — create a browser session.
134    pub async fn create_session(
135        &self,
136        input: &CreateSessionRequest,
137        options: Option<RequestOptions>,
138    ) -> Result<SessionState, ClientError> {
139        self.json(Method::POST, "/v1/sessions", Some(input), options)
140            .await
141    }
142
143    /// `GET /v1/sessions` — list active sessions.
144    pub async fn list_sessions(
145        &self,
146        options: Option<RequestOptions>,
147    ) -> Result<Vec<SessionState>, ClientError> {
148        self.json(Method::GET, "/v1/sessions", None::<()>, options)
149            .await
150    }
151
152    /// `DELETE /v1/sessions/{id}` — tear down a session (`204` on success).
153    pub async fn delete_session(
154        &self,
155        session_id: &SessionId,
156        options: Option<RequestOptions>,
157    ) -> Result<(), ClientError> {
158        self.empty(
159            Method::DELETE,
160            &format!("/v1/sessions/{}", session_id.0),
161            options,
162        )
163        .await
164    }
165
166    /// `POST /v1/pages` — open a page in a session.
167    pub async fn open_page(
168        &self,
169        input: &OpenPageRequest,
170        options: Option<RequestOptions>,
171    ) -> Result<PageState, ClientError> {
172        self.json(Method::POST, "/v1/pages", Some(input), options)
173            .await
174    }
175
176    /// `POST /v1/commands` — submit a command envelope (primitive or intent).
177    pub async fn submit(
178        &self,
179        input: &CommandEnvelope,
180        options: Option<RequestOptions>,
181    ) -> Result<CommandOutcome, ClientError> {
182        self.json(Method::POST, "/v1/commands", Some(input), options)
183            .await
184    }
185
186    /// `GET /v1/context/ask` — resolve one described control from retained context.
187    pub async fn context_ask(
188        &self,
189        session_id: &SessionId,
190        page_id: &PageId,
191        description: &str,
192        options: Option<RequestOptions>,
193    ) -> Result<ContextAskResponse, ClientError> {
194        let path = context_query_path("ask", session_id, page_id, description)?;
195        let response: ContextAskResponse =
196            self.json(Method::GET, &path, None::<()>, options).await?;
197        if !valid_context_ask_response(&response) {
198            return Err(ClientError::Protocol(
199                "context ask response violates the hit/miss contract".into(),
200            ));
201        }
202        Ok(response)
203    }
204
205    /// `GET /v1/context/neighbors` — return the remembered form around one control.
206    pub async fn context_neighbors(
207        &self,
208        session_id: &SessionId,
209        page_id: &PageId,
210        description: &str,
211        options: Option<RequestOptions>,
212    ) -> Result<ContextNeighborsResponse, ClientError> {
213        let path = context_query_path("neighbors", session_id, page_id, description)?;
214        let response: ContextNeighborsResponse =
215            self.json(Method::GET, &path, None::<()>, options).await?;
216        if !valid_context_neighbors_response(&response) {
217            return Err(ClientError::Protocol(
218                "context neighbors response violates the hit/miss contract".into(),
219            ));
220        }
221        Ok(response)
222    }
223
224    /// `GET /v1/context/site/{key}` — return retained structure for one site key.
225    pub async fn context_site(
226        &self,
227        site_key: &str,
228        options: Option<RequestOptions>,
229    ) -> Result<ContextSiteResponse, ClientError> {
230        if site_key.is_empty() {
231            return Err(ClientError::Protocol("site key must not be empty".into()));
232        }
233        let mut encoded =
234            url::Url::parse("http://context.invalid/").expect("static context URL must parse");
235        encoded
236            .path_segments_mut()
237            .expect("hierarchical context URL has path segments")
238            .clear()
239            .push(site_key);
240        self.json(
241            Method::GET,
242            &format!(
243                "/v1/context/site/{}",
244                encoded.path().trim_start_matches('/')
245            ),
246            None::<()>,
247            options,
248        )
249        .await
250    }
251
252    /// `POST /v1/jobs` — submit a bounded runtime job.
253    pub async fn submit_job(
254        &self,
255        input: &SubmitJobRequest,
256        options: Option<RequestOptions>,
257    ) -> Result<JobSubmitResponse, ClientError> {
258        input
259            .validate()
260            .map_err(|message| ClientError::Protocol(message.into()))?;
261        self.json_with_status(
262            Method::POST,
263            "/v1/jobs",
264            Some(input),
265            options,
266            Some(reqwest::StatusCode::CREATED),
267        )
268        .await
269    }
270
271    /// `GET /v1/jobs/{job_id}` — read the authenticated principal's job.
272    pub async fn job_status(
273        &self,
274        job_id: &JobId,
275        options: Option<RequestOptions>,
276    ) -> Result<JobStatusResponse, ClientError> {
277        let response: JobStatusResponse = self
278            .json(
279                Method::GET,
280                &format!("/v1/jobs/{job_id}"),
281                None::<()>,
282                options,
283            )
284            .await?;
285        response
286            .validate()
287            .map_err(|message| ClientError::Protocol(message.into()))?;
288        Ok(response)
289    }
290
291    /// `DELETE /v1/jobs/{job_id}` — cancel the authenticated principal's job.
292    pub async fn cancel_job(
293        &self,
294        job_id: &JobId,
295        options: Option<RequestOptions>,
296    ) -> Result<(), ClientError> {
297        self.empty_with_status(
298            Method::DELETE,
299            &format!("/v1/jobs/{job_id}"),
300            options,
301            Some(reqwest::StatusCode::NO_CONTENT),
302        )
303        .await
304    }
305
306    async fn empty(
307        &self,
308        method: Method,
309        path: &str,
310        options: Option<RequestOptions>,
311    ) -> Result<(), ClientError> {
312        self.empty_with_status(method, path, options, None).await
313    }
314
315    async fn empty_with_status(
316        &self,
317        method: Method,
318        path: &str,
319        options: Option<RequestOptions>,
320        expected_status: Option<reqwest::StatusCode>,
321    ) -> Result<(), ClientError> {
322        let options = options.unwrap_or_default();
323        let timeout = options.timeout.unwrap_or(self.default_timeout);
324        let deadline =
325            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
326        let correlation = options
327            .correlation_id
328            .unwrap_or_else(|| Uuid::new_v4().to_string());
329
330        let mut request = self
331            .http
332            .request(method, format!("{}{path}", self.base_url))
333            .timeout(timeout)
334            .header("authorization", format!("Bearer {}", self.bearer_token))
335            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
336            .header("x-correlation-id", correlation)
337            .header("x-deadline", deadline.to_rfc3339());
338        if let Some(key) = options.idempotency_key {
339            request = request.header("idempotency-key", key);
340        }
341
342        let response = request.send().await.map_err(|error| {
343            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
344        })?;
345        let status = response.status();
346        if expected_status.map_or_else(|| status.is_success(), |expected| status == expected) {
347            return Ok(());
348        }
349        let text = response.text().await.unwrap_or_default();
350        if status.is_success() {
351            return Err(ClientError::Protocol(format!(
352                "unexpected HTTP status {}",
353                status.as_u16()
354            )));
355        }
356        Err(ClientError::Http {
357            status: status.as_u16(),
358            message: text,
359        }
360        .redact(&self.bearer_token))
361    }
362
363    async fn json<B, T>(
364        &self,
365        method: Method,
366        path: &str,
367        body: Option<B>,
368        options: Option<RequestOptions>,
369    ) -> Result<T, ClientError>
370    where
371        B: Serialize,
372        T: DeserializeOwned,
373    {
374        self.json_with_status(method, path, body, options, None)
375            .await
376    }
377
378    async fn json_with_status<B, T>(
379        &self,
380        method: Method,
381        path: &str,
382        body: Option<B>,
383        options: Option<RequestOptions>,
384        expected_status: Option<reqwest::StatusCode>,
385    ) -> Result<T, ClientError>
386    where
387        B: Serialize,
388        T: DeserializeOwned,
389    {
390        let options = options.unwrap_or_default();
391        let timeout = options.timeout.unwrap_or(self.default_timeout);
392        let deadline =
393            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
394        let correlation = options
395            .correlation_id
396            .unwrap_or_else(|| Uuid::new_v4().to_string());
397
398        let mut request = self
399            .http
400            .request(method, format!("{}{path}", self.base_url))
401            .timeout(timeout)
402            .header("authorization", format!("Bearer {}", self.bearer_token))
403            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
404            .header("x-correlation-id", correlation)
405            .header("x-deadline", deadline.to_rfc3339());
406        if let Some(key) = options.idempotency_key {
407            request = request.header("idempotency-key", key);
408        }
409        if let Some(body) = body {
410            request = request
411                .header("content-type", "application/json")
412                .json(&body);
413        }
414
415        let response = request.send().await.map_err(|error| {
416            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
417        })?;
418        let status = response.status();
419        let text = response.text().await.map_err(|error| {
420            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
421        })?;
422        if !status.is_success() {
423            return Err(ClientError::Http {
424                status: status.as_u16(),
425                message: text,
426            }
427            .redact(&self.bearer_token));
428        }
429        if expected_status.is_some_and(|expected| status != expected) {
430            return Err(ClientError::Protocol(format!(
431                "unexpected HTTP status {}",
432                status.as_u16()
433            )));
434        }
435        serde_json::from_str(&text).map_err(|error| {
436            ClientError::Protocol(format!("invalid JSON body: {error}")).redact(&self.bearer_token)
437        })
438    }
439
440    /// Raw-bytes GET for binary endpoints (`GET /v1/artifacts/{id}`): no JSON
441    /// decoding, the response body is the artifact content. Returns the body
442    /// plus the response's media-type essence and `Content-Length` for callers
443    /// that verify the body against a reference.
444    async fn bytes_with_headers(
445        &self,
446        path: &str,
447        options: Option<RequestOptions>,
448    ) -> Result<(Vec<u8>, (String, Option<u64>)), ClientError> {
449        let options = options.unwrap_or_default();
450        let timeout = options.timeout.unwrap_or(self.default_timeout);
451        let deadline =
452            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
453        let correlation = options
454            .correlation_id
455            .unwrap_or_else(|| Uuid::new_v4().to_string());
456        let response = self
457            .http
458            .get(format!("{}{path}", self.base_url))
459            .timeout(timeout)
460            .header("authorization", format!("Bearer {}", self.bearer_token))
461            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
462            .header("x-correlation-id", correlation)
463            .header("x-deadline", deadline.to_rfc3339())
464            .send()
465            .await
466            .map_err(|error| {
467                ClientError::Transport(error.to_string()).redact(&self.bearer_token)
468            })?;
469        let status = response.status();
470        if !status.is_success() {
471            let text = response.text().await.unwrap_or_default();
472            return Err(ClientError::Http {
473                status: status.as_u16(),
474                message: text,
475            }
476            .redact(&self.bearer_token));
477        }
478        let content_type = response
479            .headers()
480            .get(reqwest::header::CONTENT_TYPE)
481            .and_then(|value| value.to_str().ok())
482            .map(|value| {
483                value
484                    .split(';')
485                    .next()
486                    .unwrap_or_default()
487                    .trim()
488                    .to_ascii_lowercase()
489            })
490            .unwrap_or_default();
491        let content_length = response
492            .headers()
493            .get(reqwest::header::CONTENT_LENGTH)
494            .and_then(|value| value.to_str().ok())
495            .and_then(|value| value.parse::<u64>().ok());
496        let body = response.bytes().await.map_err(|error| {
497            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
498        })?;
499        Ok((body.to_vec(), (content_type, content_length)))
500    }
501
502    /// `GET /v1/sessions/{session}/pages/{page}/forms` — semantic form snapshot.
503    ///
504    /// `max_controls` bounds the inventory (1..=512), mirroring the
505    /// TypeScript `formSnapshot(sessionId, pageId, { maxControls })`.
506    pub async fn form_snapshot(
507        &self,
508        session_id: &SessionId,
509        page_id: &PageId,
510        max_controls: Option<u32>,
511        options: Option<RequestOptions>,
512    ) -> Result<FormSnapshot, ClientError> {
513        if let Some(bound) = max_controls {
514            if !(1..=512).contains(&bound) {
515                return Err(ClientError::Protocol(
516                    "maxControls must be between 1 and 512".into(),
517                ));
518            }
519        }
520        let query = max_controls
521            .map(|bound| format!("?maxControls={bound}"))
522            .unwrap_or_default();
523        self.json(
524            Method::GET,
525            &format!(
526                "/v1/sessions/{}/pages/{}/forms{query}",
527                session_id.0, page_id.0
528            ),
529            None::<()>,
530            options,
531        )
532        .await
533    }
534
535    /// `POST /v1/checkpoints` — persist a workflow checkpoint.
536    pub async fn checkpoint(
537        &self,
538        input: &CheckpointRequest,
539        options: Option<RequestOptions>,
540    ) -> Result<WorkflowCheckpoint, ClientError> {
541        self.json(Method::POST, "/v1/checkpoints", Some(input), options)
542            .await
543    }
544
545    /// `GET /v1/recovery/{workflow}` — current recovery status for a workflow.
546    pub async fn recovery_status(
547        &self,
548        workflow_id: &WorkflowId,
549        options: Option<RequestOptions>,
550    ) -> Result<RecoveryStatus, ClientError> {
551        self.json(
552            Method::GET,
553            &format!("/v1/recovery/{}", workflow_id.0),
554            None::<()>,
555            options,
556        )
557        .await
558    }
559
560    /// `POST /v1/recovery/{workflow}` — resume, reconcile, or restart a
561    /// workflow. A `needsReconciliation` decision answers HTTP `409`, which
562    /// this method surfaces as `ClientError::Http` carrying the decision body.
563    pub async fn recover(
564        &self,
565        workflow_id: &WorkflowId,
566        options: Option<RequestOptions>,
567    ) -> Result<RecoveryDecision, ClientError> {
568        self.json(
569            Method::POST,
570            &format!("/v1/recovery/{}", workflow_id.0),
571            None::<()>,
572            options,
573        )
574        .await
575    }
576
577    /// `GET /v1/artifacts/{id}` — fetch an artifact and verify it against its
578    /// reference before any bytes reach the caller.
579    ///
580    /// Checks the reference bounds (artifact id shape, lowercase SHA-256
581    /// digest, byte cap), then the response `Content-Type` essence and
582    /// `Content-Length`, then the SHA-256 digest of the buffered body.
583    pub async fn artifact(
584        &self,
585        reference: &ArtifactReference,
586        options: Option<RequestOptions>,
587    ) -> Result<Vec<u8>, ClientError> {
588        validate_artifact_reference(reference, self.max_artifact_bytes)?;
589        let (bytes, (content_type, content_length)) = self
590            .bytes_with_headers(&format!("/v1/artifacts/{}", reference.artifact_id), options)
591            .await?;
592        if content_type != reference.media_type_essence() {
593            return Err(ClientError::Protocol(
594                "artifact media type does not match its reference".into(),
595            ));
596        }
597        if content_length.is_none_or(|length| length != reference.bytes)
598            || bytes.len() as u64 != reference.bytes
599        {
600            return Err(ClientError::Protocol(
601                "artifact content length does not match its reference".into(),
602            ));
603        }
604        let digest = hex::encode(Sha256::digest(&bytes));
605        if digest != reference.sha256 {
606            return Err(ClientError::Protocol("artifact verification failed".into()));
607        }
608        Ok(bytes)
609    }
610}
611
612/// Typed description of an artifact as issued by the runtime (evidence
613/// `artifactId`/`mediaType`/`bytes`/`sha256` fields). The client verifies a
614/// fetched body against it before returning any bytes.
615#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct ArtifactReference {
617    pub artifact_id: String,
618    pub sha256: String,
619    pub bytes: u64,
620    pub media_type: String,
621}
622
623impl ArtifactReference {
624    /// Lowercase media-type essence (`text/plain` from `text/plain; charset=utf-8`).
625    ///
626    /// Returns the empty string when the media type has no valid essence —
627    /// [`validate_artifact_reference`] rejects such references before fetch.
628    pub fn media_type_essence(&self) -> String {
629        media_type_essence(&self.media_type).unwrap_or_default()
630    }
631}
632
633fn validate_artifact_reference(
634    reference: &ArtifactReference,
635    max_artifact_bytes: u64,
636) -> Result<(), ClientError> {
637    let artifact_id_ok = !reference.artifact_id.is_empty()
638        && reference.artifact_id.len() <= 128
639        && reference
640            .artifact_id
641            .bytes()
642            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-');
643    let digest_ok = reference.sha256.len() == 64
644        && reference
645            .sha256
646            .bytes()
647            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
648    let media_type_ok = media_type_essence(&reference.media_type).is_some();
649    if !artifact_id_ok || !digest_ok || !media_type_ok || reference.bytes > max_artifact_bytes {
650        return Err(ClientError::Protocol(
651            "artifact reference is outside the client hard bound".into(),
652        ));
653    }
654    Ok(())
655}
656
657/// Lowercased media-type essence, `None` when the value has no valid
658/// `type/subtype` form (RFC 9110 token characters only).
659fn media_type_essence(value: &str) -> Option<String> {
660    const TOKEN_EXTRA: &[u8] = b"!#$%&'*+.^_`|~-";
661    let essence = value.split(';').next()?.trim();
662    let (type_part, subtype_part) = essence.split_once('/')?;
663    let valid = |part: &str| {
664        !part.is_empty()
665            && part
666                .bytes()
667                .all(|byte| byte.is_ascii_alphanumeric() || TOKEN_EXTRA.contains(&byte))
668    };
669    if !valid(type_part) || !valid(subtype_part) {
670        return None;
671    }
672    Some(essence.to_ascii_lowercase())
673}
674
675fn context_query_path(
676    kind: &str,
677    session_id: &SessionId,
678    page_id: &PageId,
679    description: &str,
680) -> Result<String, ClientError> {
681    if !(1..=256).contains(&description.len()) {
682        return Err(ClientError::Protocol(
683            "description must contain between 1 and 256 bytes".into(),
684        ));
685    }
686    let mut query = url::form_urlencoded::Serializer::new(String::new());
687    query.append_pair("sessionId", &session_id.0.to_string());
688    query.append_pair("pageId", &page_id.0.to_string());
689    query.append_pair("description", description);
690    Ok(format!("/v1/context/{kind}?{}", query.finish()))
691}
692
693fn valid_context_ask_response(response: &ContextAskResponse) -> bool {
694    if response.hit {
695        response.answer.is_some() && response.reason.is_none() && response.next_step.is_none()
696    } else {
697        response.answer.is_none()
698            && response.reason == Some(ContextMissReason::NotRemembered)
699            && response.next_step == Some(ContextNextStep::A11ySnapshot)
700    }
701}
702
703fn valid_context_neighbors_response(response: &ContextNeighborsResponse) -> bool {
704    if response.hit {
705        response.neighbors.is_some() && response.reason.is_none() && response.next_step.is_none()
706    } else {
707        response.neighbors.is_none()
708            && response.reason == Some(ContextMissReason::NotRemembered)
709            && response.next_step == Some(ContextNextStep::A11ySnapshot)
710    }
711}
712
713fn normalize_base_url(value: String) -> String {
714    let trimmed = value.trim_end_matches('/').to_string();
715    if let Some(stripped) = trimmed.strip_suffix("/v1") {
716        stripped.trim_end_matches('/').to_string()
717    } else {
718        trimmed
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::{
726        AttemptId, CheckpointId, CommandId, JobId, JobPriority, JobStatus, SubmitJobRequest,
727    };
728    use axum::http::HeaderMap;
729    use axum::http::StatusCode;
730    use axum::response::IntoResponse;
731    use axum::routing::get;
732    use axum::Router;
733    use serde_json::json;
734    use std::sync::Arc;
735    use tokio::net::TcpListener;
736
737    async fn runtime_handler(headers: HeaderMap) -> impl IntoResponse {
738        assert!(headers
739            .get("authorization")
740            .and_then(|v| v.to_str().ok())
741            .is_some_and(|v| v == "Bearer test-token"));
742        assert_eq!(
743            headers
744                .get("x-interface-version")
745                .and_then(|v| v.to_str().ok()),
746            Some(CURRENT_INTERFACE_VERSION)
747        );
748        assert!(headers.get("x-correlation-id").is_some());
749        assert!(headers.get("x-deadline").is_some());
750        (
751            StatusCode::OK,
752            [(axum::http::header::CONTENT_TYPE, "application/json")],
753            json!({
754                "version": env!("CARGO_PKG_VERSION"),
755                "capabilities": ["session:read"],
756                "active_sessions": 0,
757                "queued_jobs": 0,
758                "uptime_ms": 1,
759            })
760            .to_string(),
761        )
762    }
763
764    #[tokio::test]
765    async fn runtime_info_sends_required_headers() {
766        let app = Router::new().route("/v1/runtime", get(runtime_handler));
767        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
768        let addr = listener.local_addr().unwrap();
769        tokio::spawn(async move {
770            axum::serve(listener, app).await.unwrap();
771        });
772
773        let client = BrowserRuntimeClient::new(format!("http://{addr}/v1"), "test-token").unwrap();
774        let info = client.runtime_info(None).await.unwrap();
775        assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
776        assert_eq!(info.active_sessions, 0);
777    }
778
779    #[tokio::test]
780    async fn normalize_strips_v1_suffix() {
781        assert_eq!(
782            normalize_base_url("http://127.0.0.1:7777/v1/".into()),
783            "http://127.0.0.1:7777"
784        );
785    }
786
787    #[tokio::test]
788    async fn rejects_empty_bearer() {
789        let err = BrowserRuntimeClient::new("http://127.0.0.1:7777", "").unwrap_err();
790        assert!(matches!(err, ClientError::Protocol(_)));
791    }
792
793    #[tokio::test]
794    async fn rejects_out_of_cap_max_artifact_bytes() {
795        let err = BrowserRuntimeClient::with_options(
796            "http://127.0.0.1:7777",
797            "test-token",
798            MAX_SDK_ARTIFACT_BYTES + 1,
799        )
800        .unwrap_err();
801        assert!(matches!(err, ClientError::Protocol(_)));
802        let err = BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 0)
803            .unwrap_err();
804        assert!(matches!(err, ClientError::Protocol(_)));
805    }
806
807    async fn spawn(app: Router) -> String {
808        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
809        let addr = listener.local_addr().unwrap();
810        tokio::spawn(async move {
811            axum::serve(listener, app).await.unwrap();
812        });
813        format!("http://{addr}")
814    }
815
816    async fn capture_uri(
817        path: &str,
818        response: impl IntoResponse + Clone + Send + Sync + 'static,
819    ) -> (String, tokio::sync::mpsc::Receiver<String>) {
820        let (tx, rx) = tokio::sync::mpsc::channel(8);
821        let response = Arc::new(response);
822        let app = Router::new().route(
823            path,
824            axum::routing::any(move |uri: axum::http::Uri| {
825                let response = Arc::clone(&response);
826                let tx = tx.clone();
827                async move {
828                    let _ = tx.send(uri.to_string()).await;
829                    (*response).clone()
830                }
831            }),
832        );
833        let base = spawn(app).await;
834        (base, rx)
835    }
836
837    #[tokio::test]
838    async fn form_snapshot_builds_bounded_query() {
839        let (base, mut rx) = capture_uri(
840            "/v1/sessions/{session}/pages/{page}/forms",
841            axum::Json(json!({
842                "schemaVersion": 1,
843                "pageId": "00000000-0000-4000-8000-000000000009",
844                "forms": [],
845                "unownedControls": [],
846                "truncated": false,
847            })),
848        )
849        .await;
850        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
851        let session = SessionId::new();
852        let page = PageId::new();
853        let snapshot = client
854            .form_snapshot(&session, &page, Some(7), None)
855            .await
856            .unwrap();
857        assert!(!snapshot.truncated);
858        assert_eq!(
859            rx.recv().await.unwrap(),
860            format!(
861                "/v1/sessions/{}/pages/{}/forms?maxControls=7",
862                session.0, page.0
863            )
864        );
865        assert!(client
866            .form_snapshot(&session, &page, Some(0), None)
867            .await
868            .is_err());
869        assert!(client
870            .form_snapshot(&session, &page, Some(513), None)
871            .await
872            .is_err());
873    }
874
875    #[tokio::test]
876    async fn context_queries_encode_inputs_and_validate_responses() {
877        let answer = json!({
878            "target": { "role": "button", "accessibleName": "Continue" },
879            "confidence": 0.9,
880            "observedAt": { "kind": "persisted" },
881            "source": "observed",
882        });
883        let (base, mut ask_uri) = capture_uri(
884            "/v1/context/ask",
885            axum::Json(json!({ "answer": answer, "hit": true })),
886        )
887        .await;
888        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
889        let session = SessionId::new();
890        let page = PageId::new();
891        let response = client
892            .context_ask(&session, &page, "Continue & review", None)
893            .await
894            .unwrap();
895        assert!(response.hit);
896        assert_eq!(response.answer.unwrap().target.accessible_name, "Continue");
897        let uri = ask_uri.recv().await.unwrap();
898        let query = uri.split_once('?').unwrap().1;
899        let pairs = url::form_urlencoded::parse(query.as_bytes())
900            .into_owned()
901            .collect::<std::collections::BTreeMap<_, _>>();
902        assert_eq!(pairs.get("sessionId"), Some(&session.0.to_string()));
903        assert_eq!(pairs.get("pageId"), Some(&page.0.to_string()));
904        assert_eq!(
905            pairs.get("description").map(String::as_str),
906            Some("Continue & review")
907        );
908
909        let (base, mut neighbors_uri) = capture_uri(
910            "/v1/context/neighbors",
911            axum::Json(json!({
912                "neighbors": null,
913                "hit": false,
914                "reason": "notRemembered",
915                "nextStep": "a11y_snapshot"
916            })),
917        )
918        .await;
919        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
920        let response = client
921            .context_neighbors(&session, &page, "Continue", None)
922            .await
923            .unwrap();
924        assert!(!response.hit);
925        assert!(response.neighbors.is_none());
926        assert!(neighbors_uri
927            .recv()
928            .await
929            .unwrap()
930            .starts_with("/v1/context/neighbors?"));
931
932        let (base, _) = capture_uri(
933            "/v1/context/neighbors",
934            axum::Json(json!({ "neighbors": null, "hit": true })),
935        )
936        .await;
937        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
938        assert!(matches!(
939            client
940                .context_neighbors(&session, &page, "Continue", None)
941                .await,
942            Err(ClientError::Protocol(_))
943        ));
944        assert!(matches!(
945            client.context_ask(&session, &page, "", None).await,
946            Err(ClientError::Protocol(_))
947        ));
948        assert!(matches!(
949            client
950                .context_ask(&session, &page, &"x".repeat(257), None)
951                .await,
952            Err(ClientError::Protocol(_))
953        ));
954    }
955
956    #[tokio::test]
957    async fn context_site_encodes_one_path_segment() {
958        let (base, mut uri) = capture_uri(
959            "/v1/context/site/{key}",
960            axum::Json(json!({ "site": null })),
961        )
962        .await;
963        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
964        let response = client
965            .context_site("https://example.test/account settings", None)
966            .await
967            .unwrap();
968        assert!(response.site.is_none());
969        assert_eq!(
970            uri.recv().await.unwrap(),
971            "/v1/context/site/https:%2F%2Fexample.test%2Faccount%20settings"
972        );
973        assert!(matches!(
974            client.context_site("", None).await,
975            Err(ClientError::Protocol(_))
976        ));
977    }
978
979    #[tokio::test]
980    async fn submit_job_posts_the_public_wire_shape() {
981        let job_id = JobId::new();
982        let response_job_id = job_id.clone();
983        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
984        let app = Router::new().route(
985            "/v1/jobs",
986            axum::routing::post(move |body: String| {
987                let tx = tx.clone();
988                let response_job_id = response_job_id.clone();
989                async move {
990                    let _ = tx.send(body).await;
991                    (
992                        StatusCode::CREATED,
993                        axum::Json(json!({
994                            "jobId": response_job_id,
995                            "status": "pending",
996                        })),
997                    )
998                }
999            }),
1000        );
1001        let client = BrowserRuntimeClient::new(spawn(app).await, "test-token").unwrap();
1002        let response = client
1003            .submit_job(
1004                &SubmitJobRequest {
1005                    name: "fetch-report".into(),
1006                    payload: Some(json!({ "reportId": "weekly" })),
1007                    priority: Some(JobPriority::High),
1008                    max_retries: Some(2),
1009                    timeout_ms: Some(5_000),
1010                },
1011                None,
1012            )
1013            .await
1014            .unwrap();
1015
1016        assert_eq!(response.job_id, job_id);
1017        assert_eq!(response.status, JobStatus::Pending);
1018        let body: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
1019        assert_eq!(
1020            body,
1021            json!({
1022                "name": "fetch-report",
1023                "payload": { "reportId": "weekly" },
1024                "priority": "high",
1025                "maxRetries": 2,
1026                "timeoutMs": 5_000,
1027            })
1028        );
1029    }
1030
1031    #[tokio::test]
1032    async fn job_status_and_cancel_use_the_runtime_job_identifier() {
1033        let job_id = JobId::new();
1034        let (base, mut status_rx) = capture_uri(
1035            "/v1/jobs/{job}",
1036            axum::Json(json!({
1037                "id": job_id,
1038                "name": "fetch-report",
1039                "priority": "normal",
1040                "status": "running",
1041                "payload": null,
1042                "createdAt": "2026-09-19T00:00:00Z",
1043                "startedAt": "2026-09-19T00:00:01Z",
1044                "completedAt": null,
1045                "retryCount": 0,
1046                "maxRetries": 3,
1047                "result": null,
1048                "error": null,
1049                "timeoutMs": null,
1050                "correlationId": null,
1051            })),
1052        )
1053        .await;
1054        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1055        let status = client.job_status(&job_id, None).await.unwrap();
1056        assert_eq!(status.id, job_id);
1057        assert_eq!(status.status, JobStatus::Running);
1058        assert_eq!(
1059            status_rx.recv().await.unwrap(),
1060            format!("/v1/jobs/{job_id}")
1061        );
1062
1063        let (base, mut cancel_rx) = capture_uri("/v1/jobs/{job}", StatusCode::NO_CONTENT).await;
1064        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1065        client.cancel_job(&job_id, None).await.unwrap();
1066        assert_eq!(
1067            cancel_rx.recv().await.unwrap(),
1068            format!("/v1/jobs/{job_id}")
1069        );
1070    }
1071
1072    #[tokio::test]
1073    async fn submit_job_rejects_an_empty_name_before_transport() {
1074        let client = BrowserRuntimeClient::new("http://127.0.0.1:1", "test-token").unwrap();
1075        let error = client
1076            .submit_job(
1077                &SubmitJobRequest {
1078                    name: "  ".into(),
1079                    payload: None,
1080                    priority: None,
1081                    max_retries: None,
1082                    timeout_ms: None,
1083                },
1084                None,
1085            )
1086            .await
1087            .unwrap_err();
1088        assert!(matches!(error, ClientError::Protocol(_)));
1089    }
1090
1091    #[tokio::test]
1092    async fn job_helpers_require_their_exact_success_statuses() {
1093        let job_id = JobId::new();
1094        let app = Router::new().route(
1095            "/v1/jobs",
1096            axum::routing::post({
1097                let job_id = job_id.clone();
1098                move || async move { axum::Json(json!({ "jobId": job_id, "status": "pending" })) }
1099            }),
1100        );
1101        let client = BrowserRuntimeClient::new(spawn(app).await, "test-token").unwrap();
1102        let submit_error = client
1103            .submit_job(
1104                &SubmitJobRequest {
1105                    name: "fetch-report".into(),
1106                    payload: None,
1107                    priority: None,
1108                    max_retries: None,
1109                    timeout_ms: None,
1110                },
1111                None,
1112            )
1113            .await
1114            .unwrap_err();
1115        assert!(matches!(submit_error, ClientError::Protocol(_)));
1116
1117        let (base, _) = capture_uri("/v1/jobs/{job}", StatusCode::OK).await;
1118        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1119        let cancel_error = client.cancel_job(&job_id, None).await.unwrap_err();
1120        assert!(matches!(cancel_error, ClientError::Protocol(_)));
1121    }
1122
1123    #[tokio::test]
1124    async fn checkpoint_posts_the_request_body() {
1125        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
1126        let app = Router::new().route(
1127            "/v1/checkpoints",
1128            axum::routing::post(move |body: String| {
1129                let tx = tx.clone();
1130                async move {
1131                    let _ = tx.send(body).await;
1132                    (
1133                        StatusCode::OK,
1134                        axum::Json(json!({
1135                            "schemaVersion": 1,
1136                            "checkpointId": CheckpointId::new(),
1137                            "workflowId": WorkflowId::new(),
1138                            "attemptId": AttemptId::new(),
1139                            "sessionId": SessionId::new(),
1140                            "pageId": PageId::new(),
1141                            "restartUrl": "https://example.test",
1142                            "currentUrl": "https://example.test",
1143                            "recoveryClass": "replayable",
1144                            "invariants": [],
1145                            "replayableInputs": [],
1146                            "evidence": [],
1147                            "createdAt": "2026-09-02T00:00:00Z",
1148                        })),
1149                    )
1150                }
1151            }),
1152        );
1153        let base = spawn(app).await;
1154        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1155        let request = CheckpointRequest {
1156            checkpoint: serde_json::from_value(json!({
1157                "schemaVersion": 1,
1158                "checkpointId": CheckpointId::new(),
1159                "workflowId": WorkflowId::new(),
1160                "attemptId": AttemptId::new(),
1161                "sessionId": SessionId::new(),
1162                "pageId": PageId::new(),
1163                "restartUrl": "https://example.test",
1164                "currentUrl": "https://example.test",
1165                "recoveryClass": "replayable",
1166                "invariants": [],
1167                "replayableInputs": [],
1168                "evidence": [],
1169                "createdAt": "2026-09-02T00:00:00Z",
1170            }))
1171            .unwrap(),
1172            evidence_refs: vec![CommandId::new()],
1173        };
1174        let checkpoint = client.checkpoint(&request, None).await.unwrap();
1175        assert_eq!(
1176            checkpoint.schema_version,
1177            WorkflowCheckpoint::SCHEMA_VERSION
1178        );
1179        let sent: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
1180        assert_eq!(sent["evidenceRefs"].as_array().map(Vec::len), Some(1));
1181    }
1182
1183    #[tokio::test]
1184    async fn recovery_status_gets_the_workflow_path() {
1185        let workflow = WorkflowId::new();
1186        let (base, mut rx) = capture_uri(
1187            "/v1/recovery/{workflow}",
1188            axum::Json(json!({
1189                "workflowId": workflow.0,
1190                "checkpoint": null,
1191                "receipts": [],
1192            })),
1193        )
1194        .await;
1195        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1196        // `checkpoint: null` violates the wire contract, so the request fails
1197        // client-side decoding — the point of this test is the URI shape.
1198        assert!(client.recovery_status(&workflow, None).await.is_err());
1199        assert_eq!(
1200            rx.recv().await.unwrap(),
1201            format!("/v1/recovery/{}", workflow.0)
1202        );
1203    }
1204
1205    #[tokio::test]
1206    async fn recover_posts_the_workflow_path() {
1207        let workflow = WorkflowId::new();
1208        let (base, mut rx) = capture_uri(
1209            "/v1/recovery/{workflow}",
1210            axum::Json(json!({
1211                "status": "resumed",
1212                "checkpointId": CheckpointId::new(),
1213                "attemptId": AttemptId::new(),
1214                "evidence": [],
1215            })),
1216        )
1217        .await;
1218        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1219        let decision = client.recover(&workflow, None).await.unwrap();
1220        match decision {
1221            RecoveryDecision::Resumed { .. } => {}
1222            other => panic!("unexpected decision: {other:?}"),
1223        }
1224        assert_eq!(
1225            rx.recv().await.unwrap(),
1226            format!("/v1/recovery/{}", workflow.0)
1227        );
1228    }
1229
1230    #[tokio::test]
1231    async fn recover_surfaces_needs_reconciliation_409_body() {
1232        let workflow = WorkflowId::new();
1233        let (base, mut rx) = capture_uri(
1234            "/v1/recovery/{workflow}",
1235            (
1236                StatusCode::CONFLICT,
1237                axum::Json(json!({
1238                    "status": "needsReconciliation",
1239                    "checkpointId": CheckpointId::new(),
1240                    "attemptId": AttemptId::new(),
1241                    "reason": "diverged",
1242                    "evidence": [],
1243                })),
1244            ),
1245        )
1246        .await;
1247        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1248        let err = client.recover(&workflow, None).await.unwrap_err();
1249        assert!(matches!(err, ClientError::Http { status: 409, .. }));
1250        assert_eq!(
1251            rx.recv().await.unwrap(),
1252            format!("/v1/recovery/{}", workflow.0)
1253        );
1254    }
1255
1256    const ARTIFACT_BODY: &[u8] = b"artifact";
1257    // sha256("artifact")
1258    const ARTIFACT_DIGEST: &str =
1259        "c7c5c1d70c5dec4416ab6158afd0b223ef40c29b1dc1f97ed9428b94d4cadb1c";
1260
1261    fn artifact_reference(sha256: &str, bytes: u64, media_type: &str) -> ArtifactReference {
1262        ArtifactReference {
1263            artifact_id: CommandId::new().0.to_string(),
1264            sha256: sha256.into(),
1265            bytes,
1266            media_type: media_type.into(),
1267        }
1268    }
1269
1270    async fn spawn_artifact_server(
1271        media_type: &'static str,
1272        body: &'static [u8],
1273        extra_headers: bool,
1274    ) -> String {
1275        let mut headers = axum::http::HeaderMap::new();
1276        headers.insert(
1277            axum::http::header::CONTENT_TYPE,
1278            media_type.parse().unwrap(),
1279        );
1280        if extra_headers {
1281            headers.insert(
1282                axum::http::header::CONTENT_LENGTH,
1283                body.len().to_string().parse().unwrap(),
1284            );
1285        }
1286        let app = Router::new().route(
1287            "/v1/artifacts/{id}",
1288            get(move || {
1289                let headers = headers.clone();
1290                async move {
1291                    if extra_headers {
1292                        axum::response::IntoResponse::into_response((StatusCode::OK, headers, body))
1293                    } else {
1294                        // Chunked transfer: no Content-Length reaches the client.
1295                        let chunks = vec![Ok::<Vec<u8>, std::io::Error>(body.to_vec())];
1296                        axum::response::IntoResponse::into_response((
1297                            StatusCode::OK,
1298                            headers,
1299                            axum::body::Body::from_stream(futures_util::stream::iter(chunks)),
1300                        ))
1301                    }
1302                }
1303            }),
1304        );
1305        spawn(app).await
1306    }
1307
1308    #[tokio::test]
1309    async fn artifact_verifies_digest_length_and_media_type() {
1310        let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, true).await;
1311        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1312        let reference = artifact_reference(
1313            ARTIFACT_DIGEST,
1314            ARTIFACT_BODY.len() as u64,
1315            "application/octet-stream",
1316        );
1317        let body = client.artifact(&reference, None).await.unwrap();
1318        assert_eq!(body, ARTIFACT_BODY);
1319
1320        let bad_digest = artifact_reference(
1321            &"00".repeat(32),
1322            ARTIFACT_BODY.len() as u64,
1323            "application/octet-stream",
1324        );
1325        let err = client.artifact(&bad_digest, None).await.unwrap_err();
1326        assert!(
1327            matches!(err, ClientError::Protocol(message) if message.contains("verification failed"))
1328        );
1329
1330        let bad_media =
1331            artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "text/plain");
1332        let err = client.artifact(&bad_media, None).await.unwrap_err();
1333        assert!(matches!(err, ClientError::Protocol(message) if message.contains("media type")));
1334
1335        let bad_length = artifact_reference(
1336            ARTIFACT_DIGEST,
1337            ARTIFACT_BODY.len() as u64 + 1,
1338            "application/octet-stream",
1339        );
1340        let err = client.artifact(&bad_length, None).await.unwrap_err();
1341        assert!(
1342            matches!(err, ClientError::Protocol(message) if message.contains("content length"))
1343        );
1344
1345        // A mixed-case media type still parses to the lowercase essence, so
1346        // validation passes — but it must never equal a differing server
1347        // type. A truly invalid media type (no type/subtype) is rejected
1348        // before any request is issued.
1349        let mixed_case = ArtifactReference {
1350            media_type: "Application/Octet-Stream".into(),
1351            ..artifact_reference(
1352                ARTIFACT_DIGEST,
1353                ARTIFACT_BODY.len() as u64,
1354                "application/octet-stream",
1355            )
1356        };
1357        assert_eq!(mixed_case.media_type_essence(), "application/octet-stream");
1358        let empty_media = artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "");
1359        assert!(matches!(
1360            validate_artifact_reference(&empty_media, DEFAULT_MAX_ARTIFACT_BYTES),
1361            Err(ClientError::Protocol(message)) if message.contains("hard bound")
1362        ));
1363        let garbage_media = artifact_reference(
1364            ARTIFACT_DIGEST,
1365            ARTIFACT_BODY.len() as u64,
1366            "not a media type",
1367        );
1368        assert!(matches!(
1369            validate_artifact_reference(&garbage_media, DEFAULT_MAX_ARTIFACT_BYTES),
1370            Err(ClientError::Protocol(message)) if message.contains("hard bound")
1371        ));
1372        // An explicit lowercase essence with parameters matches fine.
1373        let with_params = ArtifactReference {
1374            media_type: "application/octet-stream; charset=binary".into(),
1375            ..artifact_reference(
1376                ARTIFACT_DIGEST,
1377                ARTIFACT_BODY.len() as u64,
1378                "application/octet-stream",
1379            )
1380        };
1381        assert_eq!(with_params.media_type_essence(), "application/octet-stream");
1382    }
1383
1384    #[tokio::test]
1385    async fn artifact_rejects_missing_content_length() {
1386        let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, false).await;
1387        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1388        let reference = artifact_reference(
1389            ARTIFACT_DIGEST,
1390            ARTIFACT_BODY.len() as u64,
1391            "application/octet-stream",
1392        );
1393        let err = client.artifact(&reference, None).await.unwrap_err();
1394        assert!(
1395            matches!(err, ClientError::Protocol(message) if message.contains("content length"))
1396        );
1397    }
1398
1399    #[tokio::test]
1400    async fn artifact_reference_rejects_out_of_cap_before_any_request() {
1401        let client =
1402            BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 1).unwrap();
1403        let reference = artifact_reference(ARTIFACT_DIGEST, 2, "application/octet-stream");
1404        let err = client.artifact(&reference, None).await.unwrap_err();
1405        assert!(matches!(err, ClientError::Protocol(_)));
1406    }
1407
1408    #[tokio::test]
1409    async fn artifact_reference_rejects_bad_shapes_before_any_request() {
1410        for reference in [
1411            artifact_reference(ARTIFACT_DIGEST, 8, ""),
1412            artifact_reference("ZZ", 8, "application/octet-stream"),
1413            artifact_reference("AB".repeat(32).as_str(), 8, "application/octet-stream"),
1414        ] {
1415            assert!(matches!(
1416                validate_artifact_reference(&reference, DEFAULT_MAX_ARTIFACT_BYTES),
1417                Err(ClientError::Protocol(_))
1418            ));
1419        }
1420        let upper = artifact_reference(&"AB".repeat(32), 8, "application/octet-stream");
1421        assert!(matches!(
1422            validate_artifact_reference(&upper, DEFAULT_MAX_ARTIFACT_BYTES),
1423            Err(ClientError::Protocol(_))
1424        ));
1425    }
1426}