Skip to main content

omni_dev/browser/
protocol.rs

1//! Wire-protocol types for the browser bridge.
2//!
3//! Three layers share these structs. The **control plane** accepts a
4//! [`ControlRequest`] body on `POST /__bridge/request` (and synthesises one for
5//! the transparent proxy). The **WebSocket plane** serialises a [`Command`] to
6//! the browser and deserialises a [`BrowserReply`] back. The control plane then
7//! returns a [`ResponseEnvelope`] to the caller. Every frame is newline-free
8//! JSON correlated by a monotonic integer `id` assigned by the server; the
9//! browser echoes it back unchanged.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15/// A request as supplied by a control-plane caller (the `POST /__bridge/request`
16/// body, or synthesised from the path/method/headers of a transparent-proxy
17/// request).
18///
19/// `url` is resolved against the page origin by the browser snippet; absolute
20/// URLs are rejected by the server unless an `--allow-origin` permits them.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ControlRequest {
23    /// Request URL. Relative (page-origin) by default.
24    pub url: String,
25    /// HTTP method. Defaults to `GET` when omitted.
26    #[serde(default = "default_method")]
27    pub method: String,
28    /// Request headers. Forwarded verbatim to the browser `fetch()`.
29    #[serde(default)]
30    pub headers: BTreeMap<String, String>,
31    /// Request body, or `null` for no body.
32    #[serde(default)]
33    pub body: Option<String>,
34    /// When `true`, the response is streamed back as incremental chunk frames
35    /// (`response.body.getReader()`) rather than buffered into one reply.
36    #[serde(default)]
37    pub stream: bool,
38    /// Which connected tab to route this request to: a connection id (the
39    /// canonical, always-unambiguous selector) or an `Origin` string that
40    /// uniquely matches one tab. Omitted is allowed only when exactly one tab is
41    /// connected. The `X-Omni-Bridge-Target` header takes precedence over this
42    /// field. Server-side routing only — never sent to the browser.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub target: Option<String>,
45    /// Request-scoped outbound-origin override. When present it takes
46    /// precedence over the per-origin `serve --allow-origin` grant for *this
47    /// request's* [`crate::browser::auth::validate_outbound_url`] check only,
48    /// letting one request target a cross-origin URL without affecting the
49    /// connection-time WS-upgrade gate. Server-side scope check only — never sent
50    /// to the browser.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub allow_origin: Option<String>,
53    /// Fetch credentials mode (`include` | `omit` | `same-origin`). Absent means
54    /// the browser snippet defaults to `include`, preserving v1 behavior.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub credentials: Option<String>,
57    /// Request-body transfer encoding. `Some("base64")` means `body` is a base64
58    /// string the browser snippet decodes to a byte array before `fetch()`;
59    /// absent (the default) means `body` is UTF-8 text, preserving v1
60    /// wire-compat. Mirrors the response-side `encoding` on [`BrowserReply`].
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub encoding: Option<String>,
63}
64
65fn default_method() -> String {
66    "GET".to_string()
67}
68
69/// Serde predicate: skip a `bool` field when it is `false` (the default), so
70/// buffered command frames stay byte-identical to the pre-streaming wire format.
71// `skip_serializing_if` requires `fn(&T) -> bool`, so the `&bool` is mandatory.
72#[allow(clippy::trivially_copy_pass_by_ref)]
73fn is_false(b: &bool) -> bool {
74    !*b
75}
76
77/// Server → browser command frame.
78///
79/// Identical shape to [`ControlRequest`] plus the server-assigned `id`.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct Command {
82    /// Server-assigned correlation id; echoed back by the browser.
83    pub id: u64,
84    /// Request URL (already scope-validated by the server).
85    pub url: String,
86    /// HTTP method.
87    pub method: String,
88    /// Request headers.
89    pub headers: BTreeMap<String, String>,
90    /// Request body, or `null`.
91    pub body: Option<String>,
92    /// When `true`, the browser streams the response as chunk frames. Omitted
93    /// from the wire when `false` for back-compat with buffered clients.
94    #[serde(default, skip_serializing_if = "is_false")]
95    pub stream: bool,
96    /// Fetch credentials mode (`include` | `omit` | `same-origin`), or `null`
97    /// to let the browser snippet default to `include`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub credentials: Option<String>,
100    /// Request-body transfer encoding. `Some("base64")` tells the browser
101    /// snippet to decode `body` to a byte array before `fetch()`; absent (the
102    /// default) means `body` is UTF-8 text, kept off the wire for back-compat
103    /// with buffered clients.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub encoding: Option<String>,
106}
107
108/// Server → browser cancellation frame.
109///
110/// Sent to stop an in-flight streamed response (the control-plane consumer
111/// disconnected, or a limit tripped) so the browser cancels its reader.
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct CancelCommand {
114    /// Correlation id of the stream to cancel.
115    pub id: u64,
116    /// Always `true`; distinguishes this frame from a [`Command`] in the browser.
117    pub cancel: bool,
118}
119
120impl CancelCommand {
121    /// Builds a cancellation frame for the given stream id.
122    #[must_use]
123    pub fn new(id: u64) -> Self {
124        Self { id, cancel: true }
125    }
126}
127
128/// Browser → server reply frame.
129///
130/// Either a success (`status`/`headers`/`body` present) or an error
131/// (`error` present). Modelled as a flat struct of `Option`s so a single
132/// `serde` deserialise accepts both shapes; [`BrowserReply::outcome`]
133/// classifies it.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct BrowserReply {
136    /// Correlation id echoed from the [`Command`].
137    pub id: u64,
138    /// HTTP status code on success.
139    #[serde(skip_serializing_if = "Option::is_none", default)]
140    pub status: Option<u16>,
141    /// Response headers on success.
142    #[serde(skip_serializing_if = "Option::is_none", default)]
143    pub headers: Option<BTreeMap<String, String>>,
144    /// Response body on success. Plain text unless [`Self::encoding`] tags it.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub body: Option<String>,
147    /// Body transfer encoding. `Some("base64")` when the browser read a
148    /// non-text body via `arrayBuffer()` and base64-encoded it; absent (the
149    /// default) means `body` is plain text, for back-compat with v1.
150    #[serde(skip_serializing_if = "Option::is_none", default)]
151    pub encoding: Option<String>,
152    /// Error message when the browser `fetch()` failed.
153    #[serde(skip_serializing_if = "Option::is_none", default)]
154    pub error: Option<String>,
155}
156
157/// Classified outcome of a [`BrowserReply`].
158pub enum ReplyOutcome {
159    /// A successful response with status, headers, and body.
160    Success {
161        /// HTTP status code.
162        status: u16,
163        /// Response headers.
164        headers: BTreeMap<String, String>,
165        /// Response body (base64-encoded when `encoding` is `Some("base64")`).
166        body: String,
167        /// Body transfer encoding (`Some("base64")` for binary bodies).
168        encoding: Option<String>,
169    },
170    /// The browser reported a `fetch()` failure.
171    Error(String),
172}
173
174impl BrowserReply {
175    /// Classifies this reply as success or error.
176    ///
177    /// A reply carrying an `error` is an error; otherwise the success fields
178    /// are taken with sensible defaults for any that the browser omitted.
179    pub fn outcome(self) -> ReplyOutcome {
180        match self.error {
181            Some(error) => ReplyOutcome::Error(error),
182            None => ReplyOutcome::Success {
183                status: self.status.unwrap_or(0),
184                headers: self.headers.unwrap_or_default(),
185                body: self.body.unwrap_or_default(),
186                encoding: self.encoding,
187            },
188        }
189    }
190}
191
192/// Browser → server frame: a superset of [`BrowserReply`] plus the streaming
193/// fields (`stream`/`chunk`/`seq`/`done`).
194///
195/// The WebSocket reader deserialises every inbound frame into this struct, then
196/// interprets it as a buffered reply or a [`StreamItem`] depending on which
197/// waiter is registered for its `id`. Modelled as a flat struct of `Option`s so
198/// one `serde` deserialise accepts every shape.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct BrowserFrame {
201    /// Correlation id echoed from the [`Command`].
202    pub id: u64,
203    /// HTTP status code (buffered success, or a stream's head frame).
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub status: Option<u16>,
206    /// Response headers (buffered success, or a stream's head frame).
207    #[serde(skip_serializing_if = "Option::is_none", default)]
208    pub headers: Option<BTreeMap<String, String>>,
209    /// Buffered response body.
210    #[serde(skip_serializing_if = "Option::is_none", default)]
211    pub body: Option<String>,
212    /// Buffered body transfer encoding (`Some("base64")` for binary bodies).
213    #[serde(skip_serializing_if = "Option::is_none", default)]
214    pub encoding: Option<String>,
215    /// Error message when the browser `fetch()` failed.
216    #[serde(skip_serializing_if = "Option::is_none", default)]
217    pub error: Option<String>,
218    /// `Some(true)` on the first frame of a streamed response (head: status +
219    /// headers, no body yet).
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub stream: Option<bool>,
222    /// Base64-encoded body chunk of a streamed response.
223    #[serde(skip_serializing_if = "Option::is_none", default)]
224    pub chunk: Option<String>,
225    /// Monotonic chunk sequence number within a stream.
226    #[serde(skip_serializing_if = "Option::is_none", default)]
227    pub seq: Option<u64>,
228    /// `Some(true)` on the terminating frame of a streamed response.
229    #[serde(skip_serializing_if = "Option::is_none", default)]
230    pub done: Option<bool>,
231}
232
233/// One item of a streamed browser response, derived from a [`BrowserFrame`] when
234/// the registered waiter is a stream.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum StreamItem {
237    /// Stream head: status and headers, before any body bytes.
238    Head {
239        /// HTTP status code.
240        status: u16,
241        /// Response headers.
242        headers: BTreeMap<String, String>,
243    },
244    /// A base64-encoded body chunk.
245    Chunk {
246        /// Monotonic chunk sequence number.
247        seq: u64,
248        /// Base64-encoded chunk bytes.
249        data: String,
250    },
251    /// The stream terminated normally.
252    End,
253    /// The browser reported an error (before or mid-stream).
254    Error(String),
255}
256
257impl BrowserFrame {
258    /// Interprets this frame as a buffered reply (the back-compat path taken
259    /// when the registered waiter is a one-shot buffered request).
260    #[must_use]
261    pub fn into_reply(self) -> BrowserReply {
262        BrowserReply {
263            id: self.id,
264            status: self.status,
265            headers: self.headers,
266            body: self.body,
267            encoding: self.encoding,
268            error: self.error,
269        }
270    }
271
272    /// Interprets this frame as a [`StreamItem`] (taken when the registered
273    /// waiter is a stream). An `error` wins; then `done`; then a `chunk`;
274    /// otherwise the frame is the stream's head.
275    #[must_use]
276    pub fn stream_item(self) -> StreamItem {
277        if let Some(error) = self.error {
278            StreamItem::Error(error)
279        } else if self.done == Some(true) {
280            StreamItem::End
281        } else if let Some(data) = self.chunk {
282            StreamItem::Chunk {
283                seq: self.seq.unwrap_or(0),
284                data,
285            }
286        } else {
287            StreamItem::Head {
288                status: self.status.unwrap_or(0),
289                headers: self.headers.unwrap_or_default(),
290            }
291        }
292    }
293}
294
295/// One NDJSON line of a streamed `POST /__bridge/request` response.
296///
297/// The server serialises these (one per line); the thin client deserialises
298/// them. Untagged so each line is the bare object the operator sees
299/// (`{status,headers}` / `{seq,chunk}` / `{done}` / `{error}`).
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301#[serde(untagged)]
302pub enum StreamLine {
303    /// Head line: status and headers.
304    Head {
305        /// HTTP status code.
306        status: u16,
307        /// Response headers.
308        headers: BTreeMap<String, String>,
309    },
310    /// Chunk line: a base64-encoded body chunk.
311    Chunk {
312        /// Monotonic chunk sequence number.
313        seq: u64,
314        /// Base64-encoded chunk bytes.
315        chunk: String,
316    },
317    /// Terminating line.
318    Done {
319        /// Always `true`.
320        done: bool,
321    },
322    /// Error line.
323    Error {
324        /// Error message.
325        error: String,
326    },
327}
328
329/// Control-plane response envelope returned to the caller of
330/// `POST /__bridge/request`.
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332pub struct ResponseEnvelope {
333    /// Correlation id of the request this envelope answers.
334    pub id: u64,
335    /// HTTP status returned by the browser.
336    pub status: u16,
337    /// Response headers returned by the browser.
338    pub headers: BTreeMap<String, String>,
339    /// Response body returned by the browser. Base64-encoded when
340    /// [`Self::encoding`] is `Some("base64")`; the caller decodes it.
341    pub body: String,
342    /// Body transfer encoding. `Some("base64")` for binary bodies; absent (the
343    /// default) means `body` is plain text.
344    #[serde(skip_serializing_if = "Option::is_none", default)]
345    pub encoding: Option<String>,
346}
347
348/// One connected tab, as reported by `GET /__bridge/status`.
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
350pub struct TabInfo {
351    /// Server-assigned connection id; the canonical routing selector.
352    pub id: u64,
353    /// The connecting tab's `Origin`, if it sent one.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub origin: Option<String>,
356}
357
358/// `GET /__bridge/status` response body.
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
360pub struct StatusResponse {
361    /// Whether at least one authenticated browser tab is currently connected.
362    pub connected: bool,
363    /// The connected tab's `Origin` when exactly one tab is connected; `None`
364    /// when zero or several are (ambiguous). Retained for v1 back-compat — use
365    /// [`Self::tabs`] for the full picture.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub browser_origin: Option<String>,
368    /// Every connected tab (id + origin), for routing with a `target`.
369    pub tabs: Vec<TabInfo>,
370    /// Number of in-flight requests awaiting a browser reply.
371    pub pending: usize,
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used, clippy::expect_used)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn control_request_defaults_method_and_body() {
381        let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
382        assert_eq!(req.method, "GET");
383        assert!(req.body.is_none());
384        assert!(req.headers.is_empty());
385        // The per-request outbound-origin override defaults to absent.
386        assert!(req.allow_origin.is_none());
387    }
388
389    #[test]
390    fn control_request_omits_allow_origin_when_absent() {
391        let req = ControlRequest {
392            url: "/x".to_string(),
393            method: "GET".to_string(),
394            headers: BTreeMap::new(),
395            body: None,
396            stream: false,
397            target: None,
398            allow_origin: None,
399            credentials: None,
400            encoding: None,
401        };
402        // Back-compat: an absent override is not serialised, so the wire body
403        // stays byte-identical to a pre-feature client's.
404        let json = serde_json::to_string(&req).unwrap();
405        assert!(!json.contains("allow_origin"));
406
407        // A present override round-trips.
408        let with = ControlRequest {
409            allow_origin: Some("https://ok.test".to_string()),
410            ..req
411        };
412        let json = serde_json::to_string(&with).unwrap();
413        let back: ControlRequest = serde_json::from_str(&json).unwrap();
414        assert_eq!(back.allow_origin.as_deref(), Some("https://ok.test"));
415    }
416
417    #[test]
418    fn command_round_trips_and_is_newline_free() {
419        let cmd = Command {
420            id: 7,
421            url: "/loki/api/v1/labels".to_string(),
422            method: "GET".to_string(),
423            headers: BTreeMap::new(),
424            body: None,
425            stream: false,
426            credentials: None,
427            encoding: None,
428        };
429        let json = serde_json::to_string(&cmd).unwrap();
430        assert!(!json.contains('\n'));
431        // A buffered command omits `stream` entirely (wire back-compat).
432        assert!(!json.contains("stream"));
433        let back: Command = serde_json::from_str(&json).unwrap();
434        assert_eq!(cmd, back);
435    }
436
437    #[test]
438    fn streaming_command_serialises_stream_flag() {
439        let cmd = Command {
440            id: 1,
441            url: "/sse".to_string(),
442            method: "GET".to_string(),
443            headers: BTreeMap::new(),
444            body: None,
445            stream: true,
446            credentials: None,
447            encoding: None,
448        };
449        let json = serde_json::to_string(&cmd).unwrap();
450        assert!(json.contains("\"stream\":true"));
451    }
452
453    #[test]
454    fn cancel_command_serialises_with_cancel_true() {
455        let json = serde_json::to_string(&CancelCommand::new(9)).unwrap();
456        assert_eq!(json, r#"{"id":9,"cancel":true}"#);
457    }
458
459    #[test]
460    fn frame_classifies_stream_head_chunk_and_end() {
461        let head: BrowserFrame =
462            serde_json::from_str(r#"{"id":1,"status":200,"headers":{"a":"b"},"stream":true}"#)
463                .unwrap();
464        assert!(matches!(
465            head.stream_item(),
466            StreamItem::Head { status: 200, headers } if headers.get("a").map(String::as_str) == Some("b")
467        ));
468
469        let chunk: BrowserFrame =
470            serde_json::from_str(r#"{"id":1,"seq":3,"chunk":"aGk="}"#).unwrap();
471        assert!(matches!(
472            chunk.stream_item(),
473            StreamItem::Chunk { seq: 3, data } if data == "aGk="
474        ));
475
476        let end: BrowserFrame = serde_json::from_str(r#"{"id":1,"done":true}"#).unwrap();
477        assert_eq!(end.stream_item(), StreamItem::End);
478
479        let err: BrowserFrame = serde_json::from_str(r#"{"id":1,"error":"boom"}"#).unwrap();
480        assert_eq!(err.stream_item(), StreamItem::Error("boom".into()));
481    }
482
483    #[test]
484    fn frame_into_reply_preserves_buffered_fields() {
485        let frame: BrowserFrame = serde_json::from_str(
486            r#"{"id":2,"status":200,"headers":{},"body":"hi","encoding":"base64"}"#,
487        )
488        .unwrap();
489        let reply = frame.into_reply();
490        assert_eq!(reply.id, 2);
491        assert!(matches!(
492            reply.outcome(),
493            ReplyOutcome::Success { body, encoding, .. }
494                if body == "hi" && encoding.as_deref() == Some("base64")
495        ));
496    }
497
498    #[test]
499    fn stream_lines_round_trip_untagged() {
500        for (line, json) in [
501            (
502                StreamLine::Head {
503                    status: 200,
504                    headers: BTreeMap::new(),
505                },
506                r#"{"status":200,"headers":{}}"#,
507            ),
508            (
509                StreamLine::Chunk {
510                    seq: 0,
511                    chunk: "aGk=".into(),
512                },
513                r#"{"seq":0,"chunk":"aGk="}"#,
514            ),
515            (StreamLine::Done { done: true }, r#"{"done":true}"#),
516            (
517                StreamLine::Error {
518                    error: "boom".into(),
519                },
520                r#"{"error":"boom"}"#,
521            ),
522        ] {
523            let serialised = serde_json::to_string(&line).unwrap();
524            assert_eq!(serialised, json);
525            assert!(!serialised.contains('\n'));
526            let back: StreamLine = serde_json::from_str(json).unwrap();
527            assert_eq!(back, line);
528        }
529    }
530
531    #[test]
532    fn control_request_defaults_credentials_to_none() {
533        let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
534        assert!(req.credentials.is_none());
535    }
536
537    #[test]
538    fn control_request_omits_credentials_when_absent() {
539        let req = ControlRequest {
540            url: "/x".to_string(),
541            method: "GET".to_string(),
542            headers: BTreeMap::new(),
543            body: None,
544            stream: false,
545            target: None,
546            allow_origin: None,
547            credentials: None,
548            encoding: None,
549        };
550        let json = serde_json::to_string(&req).unwrap();
551        assert!(!json.contains("credentials"));
552    }
553
554    #[test]
555    fn command_serializes_credentials_when_present() {
556        let cmd = Command {
557            id: 1,
558            url: "/x".to_string(),
559            method: "GET".to_string(),
560            headers: BTreeMap::new(),
561            body: None,
562            stream: false,
563            credentials: Some("omit".to_string()),
564            encoding: None,
565        };
566        let json = serde_json::to_string(&cmd).unwrap();
567        assert!(json.contains(r#""credentials":"omit""#));
568        let back: Command = serde_json::from_str(&json).unwrap();
569        assert_eq!(cmd, back);
570    }
571
572    #[test]
573    fn request_encoding_omitted_when_absent_and_round_trips_when_present() {
574        // Absent (the default): a base64 request encoding is never serialised, so
575        // the wire stays byte-identical to a pre-feature client's.
576        let cmd = Command {
577            id: 3,
578            url: "/upload".to_string(),
579            method: "POST".to_string(),
580            headers: BTreeMap::new(),
581            body: Some("plain".to_string()),
582            stream: false,
583            credentials: None,
584            encoding: None,
585        };
586        assert!(!serde_json::to_string(&cmd).unwrap().contains("encoding"));
587
588        // Present: `encoding: "base64"` round-trips on both request-side types.
589        let req = ControlRequest {
590            url: "/upload".to_string(),
591            method: "POST".to_string(),
592            headers: BTreeMap::new(),
593            body: Some("aGk=".to_string()),
594            stream: false,
595            target: None,
596            allow_origin: None,
597            credentials: None,
598            encoding: Some("base64".to_string()),
599        };
600        let back: ControlRequest =
601            serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
602        assert_eq!(back.encoding.as_deref(), Some("base64"));
603
604        let cmd = Command {
605            encoding: Some("base64".to_string()),
606            ..cmd
607        };
608        let json = serde_json::to_string(&cmd).unwrap();
609        assert!(json.contains(r#""encoding":"base64""#));
610        let back: Command = serde_json::from_str(&json).unwrap();
611        assert_eq!(cmd, back);
612    }
613
614    #[test]
615    fn success_reply_classifies_as_success() {
616        let reply: BrowserReply =
617            serde_json::from_str(r#"{"id":7,"status":200,"headers":{"a":"b"},"body":"hi"}"#)
618                .unwrap();
619        assert_eq!(reply.id, 7);
620        // `matches!` keeps the assertion to one expression so there is no
621        // never-taken `panic!` arm to register as an uncovered line.
622        assert!(
623            matches!(reply.outcome(),
624                ReplyOutcome::Success { status, headers, body, encoding }
625                    if status == 200
626                        && headers.get("a").map(String::as_str) == Some("b")
627                        && body == "hi"
628                        && encoding.is_none()),
629            "success reply must classify as Success with the expected fields"
630        );
631    }
632
633    #[test]
634    fn base64_reply_carries_encoding_through_outcome() {
635        let reply: BrowserReply = serde_json::from_str(
636            r#"{"id":7,"status":200,"headers":{},"body":"iVBOR=","encoding":"base64"}"#,
637        )
638        .unwrap();
639        // `matches!` keeps the whole assertion on one expression so there is no
640        // never-taken `panic!` arm to register as an uncovered line.
641        assert!(
642            matches!(reply.outcome(),
643                ReplyOutcome::Success { body, encoding, .. }
644                    if body == "iVBOR=" && encoding.as_deref() == Some("base64")),
645            "base64 reply must classify as Success with its encoding preserved"
646        );
647    }
648
649    #[test]
650    fn text_reply_omits_encoding_on_serialise() {
651        let reply = BrowserReply {
652            id: 1,
653            status: Some(200),
654            headers: None,
655            body: Some("hi".into()),
656            encoding: None,
657            error: None,
658        };
659        let json = serde_json::to_string(&reply).unwrap();
660        assert!(!json.contains("encoding"));
661    }
662
663    #[test]
664    fn envelope_omits_encoding_when_text() {
665        let env = ResponseEnvelope {
666            id: 1,
667            status: 200,
668            headers: BTreeMap::new(),
669            body: "hi".into(),
670            encoding: None,
671        };
672        let json = serde_json::to_string(&env).unwrap();
673        assert!(!json.contains("encoding"));
674    }
675
676    #[test]
677    fn error_reply_classifies_as_error() {
678        let reply: BrowserReply =
679            serde_json::from_str(r#"{"id":7,"error":"Failed to fetch"}"#).unwrap();
680        match reply.outcome() {
681            ReplyOutcome::Error(msg) => assert_eq!(msg, "Failed to fetch"),
682            ReplyOutcome::Success { .. } => panic!("expected error"),
683        }
684    }
685
686    #[test]
687    fn status_response_omits_origin_when_absent() {
688        let s = StatusResponse {
689            connected: false,
690            browser_origin: None,
691            tabs: Vec::new(),
692            pending: 0,
693        };
694        let json = serde_json::to_string(&s).unwrap();
695        assert!(!json.contains("browser_origin"));
696    }
697}