Skip to main content

chromiumoxide/
content_markdown.rs

1//! Client bindings for the vendor `Content` CDP domain — fetch the current
2//! page rendered as **Markdown** instead of HTML.
3//!
4//! Some remote engines expose a non-standard, capability-noun `Content`
5//! domain (alongside standard protocol domains) that can serialize the page
6//! as Markdown server-side. These bindings are hand-written
7//! [`Command`](chromiumoxide_types::Command) implementations issued as raw
8//! method strings — no PDL definitions or code generation involved — mirroring
9//! how other vendor methods (e.g. `WebMCP.listTools`) are sent.
10//!
11//! Two consumption modes are supported:
12//!
13//! - **Single-shot** (`stream: false`): `Content.getMarkdown` responds with
14//!   `{ "markdown": "..." }` — see [`content_markdown`].
15//! - **Streaming** (`stream: true`): the response is a plain acknowledgement
16//!   and the Markdown is delivered as a series of `Content.markdownChunk`
17//!   push events followed by a terminal `Content.markdownDone` event — see
18//!   [`content_markdown_streaming`] and [`content_markdown_stream`]. A server
19//!   that answers a streaming request with the Markdown inline in the
20//!   response (single-shot style) is tolerated: the inline result is used
21//!   directly and no events are awaited.
22//!
23//! Not every engine implements `Content.getMarkdown`. Depending on the
24//! engine's unknown-method policy, an unimplemented call surfaces either a
25//! protocol error or an empty success result — treat an empty object as "not
26//! supported yet" and fall back to [`Page::content`] plus local HTML→Markdown
27//! conversion. The streaming helpers apply the same convention: an
28//! acknowledged request that never produces a single stream event within the
29//! event timeout is reported as unsupported rather than hanging forever.
30
31use std::collections::BTreeMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use futures_util::future::{select, Either};
36use futures_util::stream::{try_unfold, Stream};
37use futures_util::StreamExt;
38use serde::{Deserialize, Serialize};
39
40use crate::error::{CdpError, Result};
41use crate::listeners::EventStream;
42use crate::page::Page;
43use chromiumoxide_cdp::cdp::CustomEvent;
44
45/// Default per-event wait when consuming a Markdown stream, in milliseconds.
46/// Matches the crate's standard request timeout
47/// ([`crate::handler::REQUEST_TIMEOUT`]).  Override at runtime with the
48/// `CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS` env var.
49pub const DEFAULT_STREAM_EVENT_TIMEOUT_MS: u64 = crate::handler::REQUEST_TIMEOUT;
50
51/// Resolve the per-event stream timeout for this process.  Honours
52/// `CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS` (integer milliseconds) when set;
53/// otherwise returns [`DEFAULT_STREAM_EVENT_TIMEOUT_MS`].
54#[inline]
55fn stream_event_timeout() -> Duration {
56    let ms = std::env::var("CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS")
57        .ok()
58        .and_then(|v| v.parse::<u64>().ok())
59        .unwrap_or(DEFAULT_STREAM_EVENT_TIMEOUT_MS);
60    Duration::from_millis(ms)
61}
62
63// ---------------------------------------------------------------------------
64// Command: Content.getMarkdown
65// ---------------------------------------------------------------------------
66
67/// Fetch the current page rendered as Markdown.
68/// [getMarkdown]: method `Content.getMarkdown`.
69///
70/// With `stream: false` the engine responds with the whole document in
71/// [`GetMarkdownReturns::markdown`].  With `stream: true` the response is an
72/// acknowledgement and the Markdown arrives as `Content.markdownChunk`
73/// events terminated by `Content.markdownDone` (see [`EventMarkdownChunk`] /
74/// [`EventMarkdownDone`]).
75///
76/// Not every engine implements `Content.getMarkdown`. Depending on the
77/// engine's unknown-method policy, an unimplemented call surfaces either a
78/// protocol error or an empty success result — treat an empty object as "not
79/// supported yet" and fall back to [`Page::content`] plus local conversion.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct GetMarkdownParams {
82    /// When `true`, deliver the Markdown as `Content.markdownChunk` push
83    /// events plus a terminal `Content.markdownDone` event instead of one
84    /// big response.
85    pub stream: bool,
86}
87
88impl GetMarkdownParams {
89    pub const IDENTIFIER: &'static str = "Content.getMarkdown";
90
91    /// Create params requesting single-shot (`stream = false`) or streamed
92    /// (`stream = true`) delivery.
93    pub fn new(stream: bool) -> Self {
94        Self { stream }
95    }
96}
97
98impl chromiumoxide_types::Method for GetMarkdownParams {
99    fn identifier(&self) -> chromiumoxide_types::MethodId {
100        Self::IDENTIFIER.into()
101    }
102}
103
104impl chromiumoxide_types::MethodType for GetMarkdownParams {
105    fn method_id() -> chromiumoxide_types::MethodId
106    where
107        Self: Sized,
108    {
109        Self::IDENTIFIER.into()
110    }
111}
112
113/// The response to `Content.getMarkdown`.
114///
115/// For a single-shot request a supporting engine sets [`markdown`]; for a
116/// streaming request the response is typically an acknowledgement with no
117/// `markdown` field.  An engine that does not implement the method may also
118/// answer with an empty object — [`GetMarkdownReturns::is_empty`] detects
119/// that "not supported yet" shape.  Any additional server-specific fields are
120/// preserved losslessly in [`extra`], so the client stays neutral and
121/// forward-compatible.
122///
123/// [`markdown`]: GetMarkdownReturns::markdown
124/// [`extra`]: GetMarkdownReturns::extra
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126pub struct GetMarkdownReturns {
127    /// The page rendered as Markdown (single-shot mode).  Absent in a
128    /// streaming acknowledgement and in the empty "not supported" response.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub markdown: Option<String>,
131    /// Any additional fields the server reported, preserved losslessly and
132    /// opaquely (round-trips on re-serialization).
133    #[serde(flatten)]
134    pub extra: BTreeMap<String, serde_json::Value>,
135}
136
137impl GetMarkdownReturns {
138    /// `true` when the response was a completely empty object — the
139    /// graceful-degradation signal an engine without `Content.getMarkdown`
140    /// support may answer with.
141    pub fn is_empty(&self) -> bool {
142        self.markdown.is_none() && self.extra.is_empty()
143    }
144}
145
146impl chromiumoxide_types::Command for GetMarkdownParams {
147    type Response = GetMarkdownReturns;
148}
149
150// ---------------------------------------------------------------------------
151// Push events: Content.markdownChunk / Content.markdownDone
152// ---------------------------------------------------------------------------
153
154/// One chunk of streamed Markdown, pushed by the engine while serving a
155/// `Content.getMarkdown` request with `stream: true`.
156/// [markdownChunk]: event `Content.markdownChunk`.
157#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
158pub struct EventMarkdownChunk {
159    /// The Markdown fragment carried by this event.
160    #[serde(default)]
161    pub chunk: String,
162    /// Any additional fields the server reported, preserved losslessly.
163    #[serde(flatten)]
164    pub extra: BTreeMap<String, serde_json::Value>,
165}
166
167impl EventMarkdownChunk {
168    pub const IDENTIFIER: &'static str = "Content.markdownChunk";
169}
170
171impl chromiumoxide_types::MethodType for EventMarkdownChunk {
172    fn method_id() -> chromiumoxide_types::MethodId
173    where
174        Self: Sized,
175    {
176        Self::IDENTIFIER.into()
177    }
178}
179
180impl CustomEvent for EventMarkdownChunk {}
181
182/// Terminal event closing a streamed `Content.getMarkdown` response: every
183/// `Content.markdownChunk` for the request has been delivered.
184/// [markdownDone]: event `Content.markdownDone`.
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
186pub struct EventMarkdownDone {
187    /// Any additional fields the server reported, preserved losslessly.
188    #[serde(flatten)]
189    pub extra: BTreeMap<String, serde_json::Value>,
190}
191
192impl EventMarkdownDone {
193    pub const IDENTIFIER: &'static str = "Content.markdownDone";
194}
195
196impl chromiumoxide_types::MethodType for EventMarkdownDone {
197    fn method_id() -> chromiumoxide_types::MethodId
198    where
199        Self: Sized,
200    {
201        Self::IDENTIFIER.into()
202    }
203}
204
205impl CustomEvent for EventMarkdownDone {}
206
207// ---------------------------------------------------------------------------
208// Public entry points
209// ---------------------------------------------------------------------------
210
211/// Fetch the page as Markdown in one round-trip (`Content.getMarkdown` with
212/// `stream: false`).
213///
214/// Returns `Ok(Some(markdown))` from a supporting engine.  `Ok(None)` means
215/// the engine acknowledged the call with an empty object — the documented
216/// "not supported yet" shape — and the caller should fall back to
217/// [`Page::content`] plus local HTML→Markdown conversion.  Engines whose
218/// unknown-method policy is to error (e.g. stock Chrome) surface a regular
219/// protocol error through [`Result`] instead; treat that the same way.
220pub async fn content_markdown(page: &Page) -> Result<Option<String>> {
221    Ok(page
222        .execute(GetMarkdownParams::new(false))
223        .await?
224        .result
225        .markdown)
226}
227
228/// Fetch the page as Markdown via the streamed protocol
229/// (`Content.getMarkdown` with `stream: true`), accumulating the
230/// `Content.markdownChunk` events into one `String` until
231/// `Content.markdownDone`.
232///
233/// Returns `Ok(Some(markdown))` on success.  `Ok(None)` means the engine
234/// acknowledged the request but never emitted a single stream event within
235/// the event timeout — the streaming analogue of the documented empty-object
236/// "not supported yet" response — so fall back to [`Page::content`] plus
237/// local conversion.  A protocol error from an engine that rejects unknown
238/// methods surfaces through [`Result`]; treat it the same way.
239///
240/// A server that answers the streaming request with the Markdown inline in
241/// the response is tolerated: the inline result is returned directly.
242///
243/// # Guardrails
244///
245/// - Each event wait is bounded by [`DEFAULT_STREAM_EVENT_TIMEOUT_MS`]
246///   (override with the `CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS` env var).  A
247///   stall *after* streaming began returns [`CdpError::Timeout`] rather than
248///   `Ok(None)`.
249/// - Accumulated bytes are capped at
250///   [`crate::content_stream::DEFAULT_MAX_ACCUMULATED_BYTES`] (override with
251///   the `CHROMEY_CONTENT_STREAM_MAX_BYTES` env var); hitting the cap
252///   returns an error rather than continuing to allocate.
253pub async fn content_markdown_streaming(page: &Page) -> Result<Option<String>> {
254    // Subscribe before issuing the command so no early chunk can be missed:
255    // listener registration and the command travel the same ordered channel
256    // to the handler.
257    let mut chunks = page.event_listener::<EventMarkdownChunk>().await?;
258    let mut done = page.event_listener::<EventMarkdownDone>().await?;
259
260    let resp = page.execute(GetMarkdownParams::new(true)).await?.result;
261    if let Some(markdown) = resp.markdown {
262        // Engine answered single-shot despite `stream: true` — tolerated.
263        return Ok(Some(markdown));
264    }
265
266    let timeout = stream_event_timeout();
267    let byte_cap = crate::content_stream::max_accumulated_bytes();
268    let mut out = String::new();
269    let mut got_any = false;
270    let mut rounds: usize = 0;
271
272    loop {
273        if rounds >= crate::content_stream::MAX_CHUNKS {
274            return Err(CdpError::msg("markdown stream exceeded MAX_CHUNKS"));
275        }
276        rounds += 1;
277
278        match next_event(&mut chunks, &mut done, timeout).await {
279            StreamStep::Chunk(chunk) => {
280                got_any = true;
281                if out.len().saturating_add(chunk.len()) > byte_cap {
282                    return Err(CdpError::msg(format!(
283                        "markdown stream: accumulated bytes exceeded cap ({} > {})",
284                        out.len().saturating_add(chunk.len()),
285                        byte_cap
286                    )));
287                }
288                out.push_str(&chunk);
289            }
290            StreamStep::Done => return Ok(Some(out)),
291            StreamStep::Closed => {
292                return Err(CdpError::msg(
293                    "markdown stream: event channel closed before Content.markdownDone",
294                ));
295            }
296            StreamStep::TimedOut => {
297                if got_any {
298                    return Err(CdpError::Timeout);
299                }
300                // Acknowledged but silent: the engine does not implement
301                // streamed Markdown — graceful-degradation convention.
302                return Ok(None);
303            }
304        }
305    }
306}
307
308/// Pump-style async [`Stream`] of the page's Markdown — yields each
309/// `Content.markdownChunk` fragment as the engine pushes it, without
310/// accumulating, terminating cleanly on `Content.markdownDone`.
311///
312/// The first poll subscribes to the events and issues `Content.getMarkdown`
313/// with `stream: true`; each subsequent poll yields one chunk.  A server
314/// that answers with the Markdown inline in the response is tolerated: the
315/// stream yields it as a single item and ends.
316///
317/// # Errors / graceful degradation
318///
319/// - An engine that rejects unknown methods errors on first poll.
320/// - An engine that acknowledges the request but never emits a single stream
321///   event within the event timeout ([`DEFAULT_STREAM_EVENT_TIMEOUT_MS`],
322///   override with `CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS`) yields one error
323///   identifying the method as unsupported.  On any error, fall back to
324///   [`Page::content`] plus local HTML→Markdown conversion.
325/// - A stall *after* streaming began yields [`CdpError::Timeout`].
326///
327/// Drop the stream early (cancellation, `StreamExt::take`, `break`) to stop
328/// consuming; the event listeners are pruned by the handler once their
329/// receivers are dropped.  The pump itself does **not** enforce a
330/// total-bytes cap (that's the caller's responsibility when consuming); for
331/// a capped accumulating read use [`content_markdown_streaming`].
332pub fn content_markdown_stream(page: &Page) -> impl Stream<Item = Result<String>> + Send + 'static {
333    let page = page.clone();
334    try_unfold(PumpState::Init { page }, |state| async move {
335        match state {
336            PumpState::Init { page } => {
337                // Subscribe before issuing the command so no early chunk can
338                // be missed.
339                let chunks = page.event_listener::<EventMarkdownChunk>().await?;
340                let done = page.event_listener::<EventMarkdownDone>().await?;
341
342                let resp = page.execute(GetMarkdownParams::new(true)).await?.result;
343                if let Some(markdown) = resp.markdown {
344                    // Single-shot answer despite `stream: true` — yield it
345                    // as the only item, then end.
346                    return Ok(Some((markdown, PumpState::Finished)));
347                }
348
349                let timeout = stream_event_timeout();
350                pump_next(chunks, done, timeout, false, 0).await
351            }
352            PumpState::Pumping {
353                chunks,
354                done,
355                timeout,
356                got_any,
357                rounds,
358            } => pump_next(chunks, done, timeout, got_any, rounds).await,
359            PumpState::Finished => Ok(None),
360        }
361    })
362}
363
364// ---------------------------------------------------------------------------
365// Internals
366// ---------------------------------------------------------------------------
367
368/// Internal state for the pump-stream unfold.  `Send + 'static` so the
369/// stream is usable across `.await` points without borrowing the caller.
370enum PumpState {
371    /// Haven't subscribed / issued the command yet.
372    Init { page: Page },
373    /// Command acknowledged; consuming chunk events.
374    Pumping {
375        chunks: EventStream<EventMarkdownChunk>,
376        done: EventStream<EventMarkdownDone>,
377        timeout: Duration,
378        /// Whether at least one stream event arrived (drives the
379        /// unsupported-vs-stalled distinction on timeout).
380        got_any: bool,
381        rounds: usize,
382    },
383    /// Inline single-shot answer already yielded.
384    Finished,
385}
386
387/// Await the next stream event (or timeout) and return the next unfold step.
388async fn pump_next(
389    mut chunks: EventStream<EventMarkdownChunk>,
390    mut done: EventStream<EventMarkdownDone>,
391    timeout: Duration,
392    got_any: bool,
393    rounds: usize,
394) -> Result<Option<(String, PumpState)>> {
395    if rounds >= crate::content_stream::MAX_CHUNKS {
396        return Ok(None);
397    }
398    match next_event(&mut chunks, &mut done, timeout).await {
399        StreamStep::Chunk(chunk) => Ok(Some((
400            chunk,
401            PumpState::Pumping {
402                chunks,
403                done,
404                timeout,
405                got_any: true,
406                rounds: rounds + 1,
407            },
408        ))),
409        StreamStep::Done => Ok(None),
410        StreamStep::Closed => Err(CdpError::msg(
411            "markdown stream: event channel closed before Content.markdownDone",
412        )),
413        StreamStep::TimedOut => {
414            if got_any {
415                Err(CdpError::Timeout)
416            } else {
417                Err(CdpError::msg(
418                    "Content.getMarkdown: no stream events before timeout — the engine \
419                     likely does not support streamed Markdown; fall back to Page::content \
420                     and convert locally",
421                ))
422            }
423        }
424    }
425}
426
427/// Outcome of one bounded wait on the chunk/done event pair.
428enum StreamStep {
429    /// A `Content.markdownChunk` arrived carrying this fragment.
430    Chunk(String),
431    /// `Content.markdownDone` arrived: the stream completed.
432    Done,
433    /// A listener channel closed (handler shut down) before completion.
434    Closed,
435    /// No event arrived within the timeout window.
436    TimedOut,
437}
438
439/// Wait (bounded by `timeout`) for whichever of the two event streams fires
440/// first.  When both are ready the chunk side wins, so no chunk queued ahead
441/// of the terminal event is ever dropped — the done event stays queued for
442/// the next call.
443async fn next_event(
444    chunks: &mut EventStream<EventMarkdownChunk>,
445    done: &mut EventStream<EventMarkdownDone>,
446    timeout: Duration,
447) -> StreamStep {
448    match tokio::time::timeout(timeout, select(chunks.next(), done.next())).await {
449        Err(_elapsed) => StreamStep::TimedOut,
450        Ok(Either::Left((Some(ev), _))) => StreamStep::Chunk(
451            // Avoid copying the fragment when we hold the only reference.
452            Arc::try_unwrap(ev)
453                .map(|e| e.chunk)
454                .unwrap_or_else(|shared| shared.chunk.clone()),
455        ),
456        Ok(Either::Right((Some(_done_ev), _))) => StreamStep::Done,
457        Ok(Either::Left((None, _))) | Ok(Either::Right((None, _))) => StreamStep::Closed,
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use serde_json::json;
465
466    #[test]
467    fn method_identifiers_match_the_domain() {
468        use chromiumoxide_types::{Method, MethodType};
469
470        assert_eq!(GetMarkdownParams::IDENTIFIER, "Content.getMarkdown");
471        assert_eq!(EventMarkdownChunk::IDENTIFIER, "Content.markdownChunk");
472        assert_eq!(EventMarkdownDone::IDENTIFIER, "Content.markdownDone");
473
474        assert_eq!(
475            GetMarkdownParams::default().identifier(),
476            "Content.getMarkdown"
477        );
478        assert_eq!(EventMarkdownChunk::method_id(), "Content.markdownChunk");
479        assert_eq!(EventMarkdownDone::method_id(), "Content.markdownDone");
480
481        // Wire shape: `{ "stream": bool }`, nothing else.
482        assert_eq!(
483            serde_json::to_value(GetMarkdownParams::new(false)).expect("params must serialize"),
484            json!({ "stream": false })
485        );
486        assert_eq!(
487            serde_json::to_value(GetMarkdownParams::new(true)).expect("params must serialize"),
488            json!({ "stream": true })
489        );
490    }
491
492    #[test]
493    fn deserializes_single_shot_response() {
494        let returns: GetMarkdownReturns =
495            serde_json::from_value(json!({ "markdown": "# Title\n\nBody" }))
496                .expect("response must deserialize");
497        assert_eq!(returns.markdown.as_deref(), Some("# Title\n\nBody"));
498        assert!(returns.extra.is_empty());
499        assert!(!returns.is_empty());
500    }
501
502    #[test]
503    fn empty_object_response_means_not_supported() {
504        // Graceful-degradation convention: an engine without the method may
505        // answer with an empty success object instead of a protocol error.
506        let returns: GetMarkdownReturns =
507            serde_json::from_value(json!({})).expect("empty object must deserialize");
508        assert_eq!(returns.markdown, None);
509        assert!(returns.is_empty());
510    }
511
512    #[test]
513    fn response_extras_are_lossless() {
514        let wire = json!({ "markdown": "# Hi", "truncated": false, "units": 2 });
515        let returns: GetMarkdownReturns =
516            serde_json::from_value(wire.clone()).expect("response must deserialize");
517        assert_eq!(returns.markdown.as_deref(), Some("# Hi"));
518        assert_eq!(returns.extra["truncated"], json!(false));
519        assert_eq!(returns.extra["units"], json!(2));
520        // Round-trips byte-identically, extras included.
521        assert_eq!(
522            serde_json::to_value(&returns).expect("response must serialize"),
523            wire
524        );
525    }
526
527    #[test]
528    fn chunk_and_done_events_deserialize() {
529        let chunk: EventMarkdownChunk =
530            serde_json::from_value(json!({ "chunk": "## Section" })).expect("chunk event");
531        assert_eq!(chunk.chunk, "## Section");
532        assert!(chunk.extra.is_empty());
533
534        // Tolerates a bare object and preserves unknown fields.
535        let chunk: EventMarkdownChunk =
536            serde_json::from_value(json!({ "chunk": "x", "seq": 3 })).expect("chunk event");
537        assert_eq!(chunk.extra["seq"], json!(3));
538
539        let done: EventMarkdownDone = serde_json::from_value(json!({})).expect("done event");
540        assert!(done.extra.is_empty());
541        let done: EventMarkdownDone =
542            serde_json::from_value(json!({ "totalChunks": 7 })).expect("done event");
543        assert_eq!(done.extra["totalChunks"], json!(7));
544    }
545
546    /// End-to-end through the crate's listener machinery: register listeners
547    /// for the raw vendor method strings, dispatch raw JSON the way the
548    /// handler does for unknown (non-PDL) events, and receive typed events.
549    #[tokio::test]
550    async fn dispatches_through_custom_event_listeners() {
551        use crate::listeners::{EventListenerRequest, EventListeners};
552
553        let mut listeners = EventListeners::default();
554
555        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
556        let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
557        listeners.add_listener(EventListenerRequest::new::<EventMarkdownChunk>(chunk_tx));
558        listeners.add_listener(EventListenerRequest::new::<EventMarkdownDone>(done_tx));
559
560        // Exactly what `consume_event!`'s custom branch does with an
561        // unknown-method event's payload.
562        listeners
563            .try_send_custom("Content.markdownChunk", json!({ "chunk": "# A" }))
564            .expect("chunk dispatch");
565        listeners
566            .try_send_custom("Content.markdownDone", json!({}))
567            .expect("done dispatch");
568        listeners.flush();
569
570        let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
571        let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
572
573        let ev = chunks.next().await.expect("one chunk event");
574        assert_eq!(ev.chunk, "# A");
575        assert!(done.next().await.is_some());
576    }
577
578    /// The select in `next_event` must prefer a queued chunk over a queued
579    /// done event, and still surface the done event on the following call.
580    #[tokio::test]
581    async fn next_event_prefers_chunks_then_done() {
582        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
583        let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
584        let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
585        let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
586
587        let chunk_ev: Arc<dyn chromiumoxide_cdp::cdp::Event> = Arc::new(EventMarkdownChunk {
588            chunk: "part".to_string(),
589            extra: Default::default(),
590        });
591        let done_ev: Arc<dyn chromiumoxide_cdp::cdp::Event> =
592            Arc::new(EventMarkdownDone::default());
593        chunk_tx.send(chunk_ev).expect("send chunk");
594        done_tx.send(done_ev).expect("send done");
595
596        let timeout = Duration::from_secs(5);
597        match next_event(&mut chunks, &mut done, timeout).await {
598            StreamStep::Chunk(s) => assert_eq!(s, "part"),
599            _ => panic!("expected the queued chunk first"),
600        }
601        match next_event(&mut chunks, &mut done, timeout).await {
602            StreamStep::Done => {}
603            _ => panic!("expected the queued done event second"),
604        }
605    }
606
607    /// With nothing queued and both senders alive, the bounded wait reports
608    /// a timeout (this is the "acknowledged but silent = unsupported" probe).
609    #[tokio::test]
610    async fn next_event_times_out_when_silent() {
611        let (_chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
612        let (_done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
613        let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
614        let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
615
616        match next_event(&mut chunks, &mut done, Duration::from_millis(20)).await {
617            StreamStep::TimedOut => {}
618            _ => panic!("expected a timeout"),
619        }
620    }
621}