Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-saga/src/ui/driver/webdriver.rs

//! Real-browser [`WebReplayDriver`](crate::ui::WebReplayDriver) over **WebDriver**
//! (feature `drive`, OFF by default).
//!
//! This is the "actually move the real web page" path when re-injecting `Msg`s
//! into a live Elm Njord is not available (the Njord-side follow-on). It drives
//! a real browser through a WebDriver endpoint (geckodriver / chromedriver)
//! using [`fantoccini`], synthesizing each [`DriveAction`] as a DOM gesture via
//! `execute` (JavaScript) — the most driver-portable way, avoiding the fiddly
//! low-level Actions API. Navigation uses WebDriver `goto`.
//!
//! It is compiled ONLY under `--features drive` so the default build carries no
//! browser/async-runtime deps. It is **not** exercised by the default test run
//! (no real browser in CI); point it at a running `geckodriver`/`chromedriver`
//! to use it for real:
//!
//! ```ignore
//! # geckodriver --port 4444 &   (or: chromedriver --port=4444 --url-base=/wd/hub)
//! let mut d = WebDriverDriver::connect("http://localhost:4444")?;
//! nornir_saga::ui::drive_saga(&saga, "http://localhost:8080", &mut d)?;
//! ```
//!
//! The `nornir saga replay --drive <url> --webdriver <endpoint>` CLI wires this
//! (when nornir is built with the `saga-drive` feature).

use anyhow::{Context, Result};
use fantoccini::{Client, ClientBuilder};
use serde_json::json;
use tokio::runtime::Runtime;

use crate::ui::driver::{DriveAction, WebReplayDriver};

/// A [`WebReplayDriver`] backed by a real browser via WebDriver. Owns a small
/// current-thread tokio runtime so the surrounding replay stays synchronous.
pub struct WebDriverDriver {
    rt: Runtime,
    client: Client,
    /// The page base URL captured at [`open`](WebReplayDriver::open), so a
    /// [`DriveAction::Navigate`] with a relative route resolves against it.
    base: Option<String>,
}

impl WebDriverDriver {
    /// Connect to a WebDriver endpoint (e.g. `http://localhost:4444`) and start
    /// a session. The browser stays open until this driver is dropped.
    pub fn connect(webdriver_endpoint: &str) -> Result<WebDriverDriver> {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .context("build tokio runtime for WebDriver")?;
        let client = rt
            .block_on(ClientBuilder::native().connect(webdriver_endpoint))
            .with_context(|| format!("connect WebDriver at {webdriver_endpoint}"))?;
        Ok(WebDriverDriver { rt, client, base: None })
    }

    /// Resolve a possibly-relative route against the captured base URL.
    fn resolve(&self, route: &str) -> String {
        if route.starts_with("http://") || route.starts_with("https://") {
            return route.to_string();
        }
        match &self.base {
            Some(base) => format!("{}{}", base.trim_end_matches('/'), route),
            None => route.to_string(),
        }
    }

    /// Run a JS snippet with args, mapping errors into `anyhow`.
    fn js(&self, script: &str, args: Vec<serde_json::Value>) -> Result<()> {
        self.rt
            .block_on(self.client.execute(script, args))
            .map(|_| ())
            .map_err(|e| anyhow::anyhow!("webdriver execute failed: {e}"))
    }
}

impl WebReplayDriver for WebDriverDriver {
    fn open(&mut self, url: &str) -> Result<()> {
        self.base = Some(url.to_string());
        self.rt
            .block_on(self.client.goto(url))
            .map_err(|e| anyhow::anyhow!("goto {url}: {e}"))
    }

    fn dispatch(&mut self, action: &DriveAction) -> Result<()> {
        match action {
            DriveAction::Navigate { route } => {
                let target = self.resolve(route);
                self.rt
                    .block_on(self.client.goto(&target))
                    .map_err(|e| anyhow::anyhow!("goto {target}: {e}"))
            }
            DriveAction::MoveTo { x, y } => self.js(
                "var e=document.elementFromPoint(arguments[0],arguments[1]);\
                 if(e){e.dispatchEvent(new MouseEvent('mousemove',{clientX:arguments[0],clientY:arguments[1],bubbles:true}));}",
                vec![json!(x), json!(y)],
            ),
            DriveAction::ClickAt { x, y, target } => self.js(
                "var t=arguments[2]?document.querySelector(arguments[2]):null;\
                 var e=t||document.elementFromPoint(arguments[0],arguments[1]);\
                 if(e){e.dispatchEvent(new MouseEvent('click',{clientX:arguments[0],clientY:arguments[1],bubbles:true}));if(e.click)e.click();}",
                vec![json!(x), json!(y), json!(target)],
            ),
            DriveAction::ScrollTo { x, y } => {
                self.js("window.scrollTo(arguments[0],arguments[1]);", vec![json!(x), json!(y)])
            }
            DriveAction::FocusOn { target } => self.js(
                "var e=document.querySelector(arguments[0]);if(e&&e.focus)e.focus();",
                vec![json!(target)],
            ),
            DriveAction::TypeInto { target, value } => self.js(
                "var e=document.querySelector(arguments[0]);\
                 if(e){e.value=arguments[1];e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));}",
                vec![json!(target), json!(value)],
            ),
            DriveAction::Key { key } => self.js(
                "var e=document.activeElement||document.body;\
                 e.dispatchEvent(new KeyboardEvent('keydown',{key:arguments[0],bubbles:true}));\
                 e.dispatchEvent(new KeyboardEvent('keyup',{key:arguments[0],bubbles:true}));",
                vec![json!(key)],
            ),
        }
    }

    fn finish(&mut self) -> Result<()> {
        // Best-effort: leave the browser on the final frame; closing the session
        // is the caller's choice (drop closes the runtime, ending the session).
        Ok(())
    }
}