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}