zendriver 0.1.3

Async-first, undetectable browser automation via the Chrome DevTools Protocol
Documentation
//! [`Element`] keyboard input: `type_text` / `type_text_fast` / `press` /
//! `press_with`.
//!
//! Each method focuses this element first (so keystrokes route to it),
//! then dispatches via the shared [`crate::input::InputController`].
//! `type_text` / `type_text_fast` route through realistic/fast typing
//! helpers, and `press` / `press_with` issue a single keydown/keyup pair.
//!
//! Everything wraps in an internal "refresh on stale" wrapper so a stale
//! handle transparently re-resolves once and retries.
//!
//! `press_with` deliberately does NOT mutate the controller's tracked
//! held-modifier state — passing the modifier bits straight to the
//! dispatch helpers avoids holding the `InputController` mutex across
//! `.await` (which would serialize every CDP call on the Browser) and
//! keeps the held-modifier state from drifting if a dispatch errors
//! mid-flight.

use crate::element::Element;
use crate::error::Result;
use crate::input::keyboard::{self, Key, KeyModifiers};

impl Element {
    /// Focus this element, then type `text` with realistic timing.
    ///
    /// Per-character delays, occasional typos, and "thinking" pauses are
    /// pulled from the active [`crate::input::InputController`]'s
    /// [`zendriver_stealth::InputProfile`].
    ///
    /// Use [`Element::type_text_fast`] when you want a deterministic
    /// keystroke sequence (no delays, no typos) — that path is preferable
    /// for tests + fast automation flows.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn ex() -> zendriver::Result<()> {
    /// # let browser = zendriver::Browser::builder().launch().await?;
    /// # let tab = browser.main_tab();
    /// let input = tab.find().css("input[name=q]").one().await?;
    /// input.type_text("hello world").await?;
    /// # Ok(()) }
    /// ```
    pub async fn type_text(&self, text: impl AsRef<str>) -> Result<()> {
        let text = text.as_ref().to_string();
        self.with_refresh(|| {
            let text = text.clone();
            async move {
                self.focus().await?;
                let input = self.inner.tab.input().clone();
                keyboard::type_text_realistic(&input, &self.inner.tab, &text).await
            }
        })
        .await
    }

    /// Focus this element, then dispatch `text` with no delays.
    ///
    /// One character at a time with no typos. Each character becomes a
    /// `keyDown` + `keyUp` pair via `Input.dispatchKeyEvent`.
    ///
    /// Use [`Element::type_text`] when realism matters (driving a page
    /// that's keystroke-timing-sensitive, paths under the `spoofed`
    /// stealth profile, etc.).
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # async fn ex() -> zendriver::Result<()> {
    /// # let browser = zendriver::Browser::builder().launch().await?;
    /// # let tab = browser.main_tab();
    /// let input = tab.find().css("input").one().await?;
    /// input.type_text_fast("test").await?;
    /// # Ok(()) }
    /// ```
    pub async fn type_text_fast(&self, text: impl AsRef<str>) -> Result<()> {
        let text = text.as_ref().to_string();
        self.with_refresh(|| {
            let text = text.clone();
            async move {
                self.focus().await?;
                let input = self.inner.tab.input().clone();
                keyboard::type_text_fast(&input, &self.inner.tab, &text).await
            }
        })
        .await
    }

    /// Focus this element, then dispatch a single [`Key`] with any
    /// currently-held modifiers.
    ///
    /// Reads held modifiers from the tab's [`crate::input::InputController`].
    /// For dispatches that hold ad-hoc modifiers (e.g. Ctrl+A, Shift+End),
    /// use [`Element::press_with`].
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use zendriver::{Key, SpecialKey};
    /// # async fn ex() -> zendriver::Result<()> {
    /// # let browser = zendriver::Browser::builder().launch().await?;
    /// # let tab = browser.main_tab();
    /// let input = tab.find().css("input").one().await?;
    /// input.press(Key::Special(SpecialKey::Enter)).await?;
    /// # Ok(()) }
    /// ```
    pub async fn press(&self, key: Key) -> Result<()> {
        self.with_refresh(|| async move {
            self.focus().await?;
            let input = self.inner.tab.input().clone();
            let mods = input.state.lock().await.modifiers_held.cdp_bits();
            dispatch_key(&self.inner.tab, key, mods).await
        })
        .await
    }

    /// Focus this element, then dispatch a single [`Key`] with `mods`
    /// held during the dispatch.
    ///
    /// The held bits replace (rather than merge with) any modifiers already
    /// tracked in the controller for the duration of this one dispatch —
    /// `mods` is passed straight through to the CDP `modifiers` field; the
    /// tracked state isn't mutated. See the module-level docs for rationale.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use zendriver::{Key, KeyModifiers, SpecialKey};
    /// # async fn ex() -> zendriver::Result<()> {
    /// # let browser = zendriver::Browser::builder().launch().await?;
    /// # let tab = browser.main_tab();
    /// let input = tab.find().css("input").one().await?;
    /// // Ctrl+A (select all)
    /// input.press_with(Key::Char('a'), KeyModifiers::CTRL).await?;
    /// # Ok(()) }
    /// ```
    pub async fn press_with(&self, key: Key, mods: KeyModifiers) -> Result<()> {
        self.with_refresh(|| async move {
            self.focus().await?;
            dispatch_key(&self.inner.tab, key, mods.cdp_bits()).await
        })
        .await
    }
}

/// Single-key dispatch helper — routes `Key::Char` to `dispatch_char` and
/// `Key::Special` to `dispatch_special`. Kept private (consumers call
/// the `press` / `press_with` methods instead).
async fn dispatch_key(tab: &crate::tab::Tab, key: Key, modifier_bits: i32) -> Result<()> {
    match key {
        Key::Char(c) => keyboard::dispatch_char(tab, c, modifier_bits).await,
        Key::Special(k) => keyboard::dispatch_special(tab, k, modifier_bits).await,
    }
}

#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::tab::Tab;
    use serde_json::{Value, json};
    use zendriver_transport::SessionHandle;
    use zendriver_transport::testing::MockConnection;

    #[tokio::test]
    async fn type_text_fast_emits_two_dispatchkeyevent_per_char() {
        let (mut mock, conn) = MockConnection::pair();
        let sess = SessionHandle::new(conn.clone(), "S1");
        // `Tab::new_for_test` seeds the native input profile with a
        // deterministic seed; the raw type path doesn't sample the RNG
        // (no jitter, no typos), so seed value is irrelevant here.
        let tab = Tab::new_for_test(sess);
        let el = Element::from_jsret(tab.clone(), 17, "R17".to_string());

        let fut = tokio::spawn({
            let e = el.clone();
            async move { e.type_text_fast("hi").await }
        });

        // focus() runs the actionability gate first: visible → enabled.
        // ActionabilityCheck::TEXT_INPUT skips stable + receives_pointer.
        for _ in 0..2 {
            let id = mock.expect_cmd("Runtime.callFunctionOn").await;
            mock.reply(
                id,
                json!({ "result": { "value": true, "type": "boolean" } }),
            )
            .await;
        }
        // focus() then calls el.focus() — one more Runtime.callFunctionOn.
        let id = mock.expect_cmd("Runtime.callFunctionOn").await;
        let sent = mock.last_sent();
        assert!(
            sent["params"]["functionDeclaration"]
                .as_str()
                .unwrap()
                .contains("this.focus()")
        );
        mock.reply(id, json!({ "result": { "type": "undefined" } }))
            .await;

        // type_text_fast "hi" emits 4 Input.dispatchKeyEvent calls in
        // order: h-down, h-up, i-down, i-up.
        let expected = [
            ("h", "keyDown"),
            ("h", "keyUp"),
            ("i", "keyDown"),
            ("i", "keyUp"),
        ];
        for (ch, kind) in expected {
            let id = mock.expect_cmd("Input.dispatchKeyEvent").await;
            let last = mock.last_sent();
            assert_eq!(last["params"]["text"].as_str().unwrap(), ch);
            assert_eq!(last["params"]["type"].as_str().unwrap(), kind);
            mock.reply(id, Value::Null).await;
        }

        fut.await.unwrap().unwrap();
        conn.shutdown();
    }
}