Skip to main content

zendriver/expect/
dialog.rs

1//! [`DialogExpectation`] + [`MatchedDialog`] + [`crate::Tab::expect_dialog`]
2//! (gated `expect`).
3//!
4//! Registers a one-shot subscription against `Page.javascriptDialogOpened`
5//! on a tab's session, resolves with the first dialog event, and exposes
6//! [`MatchedDialog::accept`] / [`MatchedDialog::dismiss`] which dispatch
7//! `Page.handleJavaScriptDialog`. No URL matcher: dialogs don't carry a URL
8//! the way requests/responses do; the page URL is captured on the matched
9//! dialog for context but isn't a filter — any dialog opened during the
10//! expectation window matches.
11//!
12//! `Page.enable` is already on for every Tab via P1's `Tab::goto`, so this
13//! module does not re-enable the domain.
14
15use std::future::Future;
16use std::pin::Pin;
17use std::task::{Context, Poll};
18use std::time::Duration;
19
20use futures::StreamExt;
21use serde::Deserialize;
22use serde_json::json;
23use tokio::sync::oneshot;
24use tokio::time::Sleep;
25use zendriver_transport::SessionHandle;
26
27use crate::error::{Result, ZendriverError};
28
29/// Default outer timeout for a [`DialogExpectation`] — matches the rest of
30/// the high-level surface (`wait_for_load`, etc).
31const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
32
33/// JavaScript dialog flavor reported by Chrome on
34/// `Page.javascriptDialogOpened`.
35///
36/// `Beforeunload` corresponds to the browser's leave-confirmation dialog;
37/// the others mirror the `window.alert` / `confirm` / `prompt` builtins.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DialogType {
40    /// `window.alert(...)`.
41    Alert,
42    /// Navigation-away confirmation (`beforeunload` handler).
43    Beforeunload,
44    /// `window.confirm(...)`.
45    Confirm,
46    /// `window.prompt(...)`.
47    Prompt,
48}
49
50impl DialogType {
51    fn from_cdp(s: &str) -> Self {
52        match s {
53            "alert" => Self::Alert,
54            "beforeunload" => Self::Beforeunload,
55            "confirm" => Self::Confirm,
56            "prompt" => Self::Prompt,
57            // CDP only ever reports the four above; fall back to Alert as
58            // the safest default rather than introducing an Unknown variant.
59            _ => Self::Alert,
60        }
61    }
62}
63
64/// A JavaScript dialog observed via `Page.javascriptDialogOpened`.
65///
66/// The session handle is retained so [`Self::accept`] / [`Self::dismiss`]
67/// can dispatch `Page.handleJavaScriptDialog` against the same target the
68/// event arrived on. Consumed by value on accept/dismiss — each dialog can
69/// only be handled once.
70///
71/// `Debug` is manually implemented since [`SessionHandle`] does not derive
72/// it; the session is rendered as a placeholder.
73#[derive(Clone)]
74pub struct MatchedDialog {
75    /// Dialog flavor (alert/beforeunload/confirm/prompt).
76    pub dialog_type: DialogType,
77    /// Message text shown by the dialog.
78    pub message: String,
79    /// Default value for `prompt(...)` dialogs. `None` for alert/confirm/
80    /// beforeunload (which Chrome reports with an empty default).
81    pub default_prompt: Option<String>,
82    /// URL of the page that opened the dialog.
83    pub url: String,
84    /// Session this dialog arrived on. Retained so accept/dismiss dispatch
85    /// against the correct target.
86    pub session: SessionHandle,
87}
88
89impl std::fmt::Debug for MatchedDialog {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("MatchedDialog")
92            .field("dialog_type", &self.dialog_type)
93            .field("message", &self.message)
94            .field("default_prompt", &self.default_prompt)
95            .field("url", &self.url)
96            .field("session", &"<SessionHandle>")
97            .finish()
98    }
99}
100
101impl MatchedDialog {
102    /// Accept the dialog.
103    ///
104    /// For `prompt` dialogs, pass the value to submit via `prompt_text`;
105    /// for alert/confirm/beforeunload, pass `None`. Dispatches
106    /// `Page.handleJavaScriptDialog { accept: true, promptText }`.
107    ///
108    /// # Examples
109    ///
110    /// ```no_run
111    /// # async fn ex() -> zendriver::Result<()> {
112    /// # let browser = zendriver::Browser::builder().launch().await?;
113    /// # let tab = browser.main_tab();
114    /// let dialog = tab.expect_dialog().await?;
115    /// // ... trigger something that opens a prompt ...
116    /// dialog.accept(Some("Alice".into())).await?;
117    /// # Ok(()) }
118    /// ```
119    pub async fn accept(self, prompt_text: Option<String>) -> Result<()> {
120        let _ = self
121            .session
122            .call(
123                "Page.handleJavaScriptDialog",
124                json!({
125                    "accept": true,
126                    "promptText": prompt_text.unwrap_or_default(),
127                }),
128            )
129            .await?;
130        Ok(())
131    }
132
133    /// Dismiss the dialog.
134    ///
135    /// Dispatches `Page.handleJavaScriptDialog { accept: false }`.
136    ///
137    /// # Examples
138    ///
139    /// ```no_run
140    /// # async fn ex() -> zendriver::Result<()> {
141    /// # let browser = zendriver::Browser::builder().launch().await?;
142    /// # let tab = browser.main_tab();
143    /// let dialog = tab.expect_dialog().await?;
144    /// dialog.dismiss().await?;
145    /// # Ok(()) }
146    /// ```
147    pub async fn dismiss(self) -> Result<()> {
148        let _ = self
149            .session
150            .call("Page.handleJavaScriptDialog", json!({ "accept": false }))
151            .await?;
152        Ok(())
153    }
154}
155
156/// Awaitable handle returned by [`crate::Tab::expect_dialog`]. Resolves with
157/// the first matched [`MatchedDialog`] or [`ZendriverError::Timeout`] if no
158/// dialog opens within the configured timeout.
159///
160/// Implements [`Future`] directly — `.await` works without calling
161/// `.matched()`. The `.matched()` accessor exists for parity with the
162/// Playwright-style fluent API.
163#[derive(Debug)]
164pub struct DialogExpectation {
165    rx: oneshot::Receiver<MatchedDialog>,
166    timeout: Duration,
167    sleep: Option<Pin<Box<Sleep>>>,
168}
169
170impl DialogExpectation {
171    /// Override the default 30s timeout.
172    ///
173    /// # Examples
174    ///
175    /// ```no_run
176    /// # use std::time::Duration;
177    /// # async fn ex() -> zendriver::Result<()> {
178    /// # let browser = zendriver::Browser::builder().launch().await?;
179    /// # let tab = browser.main_tab();
180    /// let dialog = tab.expect_dialog().timeout(Duration::from_secs(5)).await?;
181    /// # let _ = dialog;
182    /// # Ok(()) }
183    /// ```
184    #[must_use]
185    pub fn timeout(mut self, dur: Duration) -> Self {
186        self.timeout = dur;
187        // Reset any already-armed sleep — the next poll will rebuild it
188        // with the new deadline.
189        self.sleep = None;
190        self
191    }
192
193    /// Playwright-style alias for `.await`.
194    ///
195    /// Functionally identical to awaiting the expectation directly.
196    ///
197    /// # Examples
198    ///
199    /// ```no_run
200    /// # async fn ex() -> zendriver::Result<()> {
201    /// # let browser = zendriver::Browser::builder().launch().await?;
202    /// # let tab = browser.main_tab();
203    /// let dialog = tab.expect_dialog().matched().await?;
204    /// dialog.dismiss().await?;
205    /// # Ok(()) }
206    /// ```
207    pub async fn matched(self) -> Result<MatchedDialog> {
208        self.await
209    }
210}
211
212impl Future for DialogExpectation {
213    type Output = Result<MatchedDialog>;
214
215    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
216        // Poll the oneshot first — if the subscriber already sent, return
217        // without ever arming the sleep timer.
218        match Pin::new(&mut self.rx).poll(cx) {
219            Poll::Ready(Ok(dialog)) => return Poll::Ready(Ok(dialog)),
220            Poll::Ready(Err(_)) => {
221                // Sender dropped without sending — subscriber task exited
222                // (transport closed). Surface as timeout: same observable
223                // shape (no event arrived), avoids inventing a new error.
224                return Poll::Ready(Err(ZendriverError::Timeout(self.timeout)));
225            }
226            Poll::Pending => {}
227        }
228
229        // Lazily arm the timer on first poll so `timeout(...)` overrides
230        // take effect.
231        let timeout = self.timeout;
232        let sleep = self
233            .sleep
234            .get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout)));
235        match sleep.as_mut().poll(cx) {
236            Poll::Ready(()) => Poll::Ready(Err(ZendriverError::Timeout(timeout))),
237            Poll::Pending => Poll::Pending,
238        }
239    }
240}
241
242/// CDP `Page.javascriptDialogOpened` payload. Field names follow the
243/// protocol (camelCase) via serde rename.
244#[derive(Debug, Deserialize)]
245struct JavascriptDialogOpenedEvent {
246    url: String,
247    message: String,
248    #[serde(rename = "type")]
249    dialog_type: String,
250    #[serde(rename = "defaultPrompt", default)]
251    default_prompt: Option<String>,
252}
253
254/// Spawn a one-shot subscriber that watches `Page.javascriptDialogOpened`
255/// on `session`, sends the first event through the `tx`, and exits.
256/// Subscription registers synchronously before the returned
257/// [`DialogExpectation`] is constructed so dialogs fired immediately after
258/// a trigger action cannot slip past us.
259pub(crate) fn register(session: &SessionHandle) -> DialogExpectation {
260    let (tx, rx) = oneshot::channel();
261    let mut stream =
262        session.subscribe::<JavascriptDialogOpenedEvent>("Page.javascriptDialogOpened");
263    let session_for_dialog = session.clone();
264    tokio::spawn(async move {
265        if let Some(ev) = stream.next().await {
266            let matched = MatchedDialog {
267                dialog_type: DialogType::from_cdp(&ev.dialog_type),
268                message: ev.message,
269                // CDP sends an empty string for non-prompt dialogs; normalize
270                // to None so the field is meaningful only for `prompt`.
271                //
272                // Note: this collapses two distinct CDP states into one:
273                // (a) `defaultPrompt` field absent (alert/confirm), and
274                // (b) `defaultPrompt: ""` (a prompt with empty default).
275                // The protocol carries no signal that distinguishes them
276                // either — Chrome sends "" in both cases — so the
277                // collapse loses nothing observable and gives users one
278                // less invariant to track.
279                default_prompt: ev.default_prompt.filter(|s| !s.is_empty()),
280                url: ev.url,
281                session: session_for_dialog,
282            };
283            // Send is fallible only if the receiver was dropped; in that
284            // case the caller no longer cares and we just exit.
285            let _ = tx.send(matched);
286        }
287    });
288    DialogExpectation {
289        rx,
290        timeout: DEFAULT_EXPECT_TIMEOUT,
291        sleep: None,
292    }
293}
294
295#[cfg(test)]
296#[allow(clippy::panic, clippy::unwrap_used)]
297mod tests {
298    use super::*;
299    use serde_json::json;
300    use zendriver_transport::testing::MockConnection;
301
302    /// Register an expectation, emit a `Page.javascriptDialogOpened`, and
303    /// assert the expectation resolves with the decoded [`MatchedDialog`].
304    #[tokio::test]
305    async fn expect_dialog_resolves_on_javascript_dialog_opened() {
306        let (mock, conn) = MockConnection::pair();
307        let session = SessionHandle::new(conn.clone(), "S1");
308
309        let expectation = register(&session);
310
311        mock.emit_event_for_session(
312            "Page.javascriptDialogOpened",
313            json!({
314                "url": "https://example.com/form",
315                "message": "What is your name?",
316                "type": "prompt",
317                "defaultPrompt": "Anonymous",
318                "hasBrowserHandler": false,
319            }),
320            "S1",
321        )
322        .await;
323
324        let matched = tokio::time::timeout(Duration::from_secs(2), expectation)
325            .await
326            .expect("expectation did not resolve within 2s")
327            .expect("expectation returned Err");
328
329        assert_eq!(matched.dialog_type, DialogType::Prompt);
330        assert_eq!(matched.message, "What is your name?");
331        assert_eq!(matched.default_prompt.as_deref(), Some("Anonymous"));
332        assert_eq!(matched.url, "https://example.com/form");
333
334        conn.shutdown();
335    }
336
337    /// Call `MatchedDialog::accept(Some("hello"))`, assert the outgoing CDP
338    /// request is `Page.handleJavaScriptDialog { accept: true, promptText:
339    /// "hello" }`.
340    #[tokio::test]
341    async fn accept_dispatches_handle_javascript_dialog() {
342        let (mut mock, conn) = MockConnection::pair();
343        let session = SessionHandle::new(conn.clone(), "S1");
344
345        let dialog = MatchedDialog {
346            dialog_type: DialogType::Prompt,
347            message: "What is your name?".into(),
348            default_prompt: Some("Anonymous".into()),
349            url: "https://example.com/form".into(),
350            session: session.clone(),
351        };
352
353        let fut = tokio::spawn(async move { dialog.accept(Some("hello".into())).await });
354
355        let id = mock.expect_cmd("Page.handleJavaScriptDialog").await;
356        let sent = mock.last_sent();
357        assert_eq!(sent["params"]["accept"], true);
358        assert_eq!(sent["params"]["promptText"], "hello");
359        mock.reply(id, json!({})).await;
360
361        fut.await
362            .expect("accept task panicked")
363            .expect("accept returned Err");
364
365        conn.shutdown();
366    }
367}