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(())
}
}