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}