use crate::diff;
use crate::eval::EvalEngine;
#[cfg(feature = "press")]
use crate::key;
use crate::protocol::{RPC_INTERNAL_ERROR, RpcError};
use crate::recorder::{RecordEntry, Recorder};
use crate::screenshot;
use crate::server::{EvalFn, ListWindowsFn, PressHooksRef};
use std::time::Duration;
#[cfg(feature = "press")]
use tokio::sync::Mutex as AsyncMutex;
#[cfg(feature = "press")]
const FOCUS_SETTLE_MS: u64 = 80;
#[cfg(feature = "press")]
static PRESS_ORDER_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_BRIDGE_TIMEOUT_MS: u64 = 10_000;
const BRIDGE_TIMEOUT_BUFFER_MS: u64 = 2_000;
fn bridge_eval_timeout(params: Option<&serde_json::Value>) -> Duration {
let timeout_ms =
params.and_then(|p| p.get("timeout")).and_then(serde_json::Value::as_u64).unwrap_or(DEFAULT_BRIDGE_TIMEOUT_MS);
Duration::from_millis(timeout_ms.saturating_add(BRIDGE_TIMEOUT_BUFFER_MS))
}
fn extract_window(params: Option<&serde_json::Value>) -> (Option<String>, Option<serde_json::Value>) {
let window = params.and_then(|o| o.get("window")).and_then(|v| v.as_str()).map(String::from);
match (window, params) {
(Some(w), Some(p)) => {
let mut cleaned = p.clone();
if let Some(obj) = cleaned.as_object_mut() {
obj.remove("window");
}
(Some(w), Some(cleaned))
}
(w, _) => (w, None),
}
}
fn inject_plugin_version(result: &mut serde_json::Value) {
if let Some(obj) = result.as_object_mut() {
obj.insert("plugin_version".to_owned(), serde_json::json!(env!("CARGO_PKG_VERSION")));
}
}
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
pub(crate) async fn dispatch(
method: &str, params: Option<&serde_json::Value>, engine: &EvalEngine, eval_fn: Option<&EvalFn>,
list_fn: Option<&ListWindowsFn>, press_hooks: Option<&PressHooksRef>, recorder: &Recorder,
) -> Result<serde_json::Value, RpcError> {
#[cfg(not(feature = "press"))]
let _ = press_hooks;
let original_params = params.cloned();
let (window, owned_params) = extract_window(params);
let params = owned_params.as_ref().or(params);
let win = window.as_deref();
let result = match method {
"ping" => {
let mut result = serde_json::json!({"status": "ok"});
inject_plugin_version(&mut result);
Ok(result)
}
"windows.list" => {
if let Some(f) = list_fn {
f().map_err(|message| RpcError { code: RPC_INTERNAL_ERROR, message, data: None })
} else {
Err(RpcError { code: -32603, message: "No window manager available".to_owned(), data: None })
}
}
"snapshot" => {
let result = handle_eval_method("snapshot", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await?;
engine.store_snapshot(&result);
Ok(result)
}
"query" | "filter" => handle_eval_method(method, params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"diff" => handle_diff(params, engine, eval_fn, win).await,
#[cfg(feature = "press")]
"press" => handle_press(params, press_hooks, win).await,
#[cfg(not(feature = "press"))]
"press" => Err(RpcError {
code: -32601,
message: "press disabled (compile `tauri-plugin-hasgard` with the `press` feature)".to_owned(),
data: None,
}),
"click" | "fill" | "type" | "select" | "check" | "scroll" | "drag" | "drop" | "text" | "html" | "value"
| "attrs" | "eval" | "ipc" | "navigate" | "url" | "title" | "visible" | "count" | "checked" | "disabled"
| "boundingBox" | "focus" | "blur" | "hover" | "dblclick" | "setInputFiles" | "wheel" => {
handle_eval_method(method, params, engine, eval_fn, win, DEFAULT_TIMEOUT).await
}
"state" => {
let mut result = handle_eval_method("state", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await?;
inject_plugin_version(&mut result);
Ok(result)
}
"wait" | "watch" => handle_eval_method(method, params, engine, eval_fn, win, bridge_eval_timeout(params)).await,
"screenshot" => handle_eval_method(method, params, engine, eval_fn, win, SCREENSHOT_TIMEOUT).await,
"screenshot_native" => screenshot::handle_screenshot(params).await,
"console.getLogs" => handle_eval_method("consoleLogs", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"console.clear" => handle_eval_method("clearLogs", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"network.getRequests" => {
handle_eval_method("networkRequests", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await
}
"network.clear" => handle_eval_method("clearNetwork", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"storage.get" => handle_eval_method("storageGet", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"storage.set" => handle_eval_method("storageSet", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"storage.list" => handle_eval_method("storageList", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"storage.clear" => handle_eval_method("storageClear", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"forms.dump" => handle_eval_method("formDump", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"dialog.list" => handle_eval_method("dialogs", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"dialog.clear" => handle_eval_method("clearDialogs", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"dialog.handle" => handle_eval_method("handleDialogs", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"route.add" => handle_eval_method("route", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"route.list" => handle_eval_method("routes", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"route.clear" => handle_eval_method("clearRoutes", params, engine, eval_fn, win, DEFAULT_TIMEOUT).await,
"record.start" => {
recorder.start();
Ok(serde_json::json!({"status": "recording"}))
}
"record.stop" => {
let entries = recorder.stop();
let count = entries.len();
Ok(serde_json::json!({"entries": entries, "count": count}))
}
"record.status" => Ok(recorder.status()),
"record.add" => {
let entry: RecordEntry = serde_json::from_value(params.cloned().unwrap_or(serde_json::Value::Null))
.map_err(|e| RpcError { code: -32602, message: e.to_string(), data: None })?;
recorder.add_entry(entry);
Ok(serde_json::json!({"status": "ok"}))
}
_ => Err(RpcError { code: -32601, message: format!("Method not found: {method}"), data: None }),
};
if result.is_ok() && recorder.is_active() {
recorder.record(method, original_params.as_ref());
}
result
}
async fn handle_diff(
params: Option<&serde_json::Value>, engine: &EvalEngine, eval_fn: Option<&EvalFn>, window: Option<&str>,
) -> Result<serde_json::Value, RpcError> {
let eval_fn = eval_fn.ok_or_else(|| RpcError {
code: -32603,
message: "No webview available for eval".to_owned(),
data: None,
})?;
let reference = if let Some(ref_val) = params.and_then(|p| p.get("reference")) {
ref_val.clone()
} else {
engine.get_last_snapshot().ok_or_else(|| RpcError {
code: -32602,
message: "No previous snapshot available. Run `snapshot` first or use `diff --ref <file>`".to_owned(),
data: None,
})?
};
let snapshot_params = params.map(|p| {
let mut cleaned = p.clone();
if let Some(obj) = cleaned.as_object_mut() {
obj.remove("reference");
}
cleaned
});
let script = build_bridge_call("snapshot", snapshot_params.as_ref()).map_err(|msg| RpcError {
code: -32602,
message: msg,
data: None,
})?;
let (id, rx) = engine.register();
let wrapped = EvalEngine::wrap_script(id, &script);
if let Err(e) = eval_fn(window, wrapped) {
engine.resolve(id, Err(format!("Eval failed: {e}")));
return Err(RpcError { code: -32603, message: format!("Eval failed: {e}"), data: None });
}
let result = engine.wait(id, rx, DEFAULT_TIMEOUT).await.map_err(|e| RpcError {
code: -32603,
message: format!("Eval error: {e}"),
data: None,
})?;
let old_elements: Vec<diff::SnapshotElement> = reference
.get("elements")
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(|e| RpcError {
code: -32602,
message: format!("Failed to parse reference snapshot elements: {e}"),
data: None,
})?
.unwrap_or_default();
let new_elements: Vec<diff::SnapshotElement> = result
.get("elements")
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(|e| RpcError {
code: -32603,
message: format!("Failed to parse new snapshot elements: {e}"),
data: None,
})?
.unwrap_or_default();
let diff_result = diff::compute_diff(&old_elements, &new_elements);
engine.store_snapshot(&result);
serde_json::to_value(&diff_result).map_err(|e| RpcError {
code: -32603,
message: format!("Serialization error: {e}"),
data: None,
})
}
#[cfg(feature = "press")]
async fn handle_press(
params: Option<&serde_json::Value>, press_hooks: Option<&PressHooksRef>, window: Option<&str>,
) -> Result<serde_json::Value, RpcError> {
let key_str =
params.and_then(|p| p.get("key")).and_then(serde_json::Value::as_str).filter(|s| !s.is_empty()).ok_or_else(
|| RpcError {
code: -32602,
message: "press requires a non-empty \"key\" string param".to_owned(),
data: None,
},
)?;
key::parse_combo(key_str).map_err(|e| RpcError {
code: -32602,
message: format!("invalid press combo: {e}"),
data: None,
})?;
if window.is_some() && press_hooks.is_none() {
return Err(RpcError {
code: -32603,
message: "cannot focus target window: no focus hook installed".to_owned(),
data: None,
});
}
let _order_guard = PRESS_ORDER_LOCK.lock().await;
if let Some(hooks) = press_hooks {
match (hooks.focus)(window) {
Ok(()) => {
tokio::time::sleep(Duration::from_millis(FOCUS_SETTLE_MS)).await;
}
Err(e) => {
if let Some(label) = window {
return Err(RpcError {
code: -32603,
message: format!("failed to focus window '{label}': {e}"),
data: None,
});
}
tracing::warn!(error = %e, "focus before press failed (continuing)");
}
}
}
let combo = key_str.to_owned();
let hooks = press_hooks.cloned();
tokio::task::spawn_blocking(move || match hooks {
Some(hooks) => {
let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
(hooks.run_injection)(Box::new(move || {
let _ = result_tx.send(key::simulate_press(&combo));
}))
.map_err(key::KeyError::EnigoInit)?;
result_rx
.recv()
.unwrap_or_else(|_| Err(key::KeyError::EnigoInit("native key injection produced no result".to_owned())))
}
None => key::simulate_press(&combo),
})
.await
.map_err(|e| {
let message = if e.is_panic() {
format!("press task panicked: {e}")
} else if e.is_cancelled() {
"press task was cancelled".to_owned()
} else {
format!("press task failed: {e}")
};
RpcError { code: -32603, message, data: None }
})?
.map_err(|e| RpcError { code: -32603, message: format!("press failed: {e}"), data: None })?;
Ok(serde_json::json!({"ok": true}))
}
async fn handle_eval_method(
method: &str, params: Option<&serde_json::Value>, engine: &EvalEngine, eval_fn: Option<&EvalFn>,
window: Option<&str>, timeout: Duration,
) -> Result<serde_json::Value, RpcError> {
let eval_fn = eval_fn.ok_or_else(|| RpcError {
code: -32603,
message: "No webview available for eval".to_owned(),
data: None,
})?;
let script =
build_bridge_call(method, params).map_err(|msg| RpcError { code: -32602, message: msg, data: None })?;
let (id, rx) = engine.register();
let wrapped = EvalEngine::wrap_script(id, &script);
if let Err(e) = eval_fn(window, wrapped) {
engine.resolve(id, Err(format!("Eval failed: {e}")));
return Err(RpcError { code: -32603, message: format!("Eval failed: {e}"), data: None });
}
engine.wait(id, rx, timeout).await.map_err(|e| RpcError {
code: -32603,
message: format!("Eval error: {e}"),
data: None,
})
}
fn build_bridge_call(method: &str, params: Option<&serde_json::Value>) -> Result<String, String> {
let args = match params {
Some(v) if !v.is_null() => v.to_string(),
_ => "{}".to_owned(),
};
if method == "ipc" {
let command = params
.and_then(|p| p.get("command"))
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| "ipc requires a non-empty \"command\" string param".to_owned())?;
let command_js = serde_json::to_string(command).unwrap_or_else(|_| "\"\"".to_owned());
let ipc_args = params.and_then(|p| p.get("args")).map_or("{}".to_owned(), ToString::to_string);
return Ok(format!("window.__TAURI_INTERNALS__.invoke({command_js}, {ipc_args})"));
}
Ok(format!("window.__HASGARD__.{method}({args})"))
}
pub(crate) fn handle_callback(engine: &EvalEngine, id: u64, result: Option<String>, error: Option<String>) {
if let Some(err) = error {
engine.resolve(id, Err(err));
} else if let Some(res) = result {
match serde_json::from_str(&res) {
Ok(val) => engine.resolve(id, Ok(val)),
Err(_) => engine.resolve(id, Ok(serde_json::Value::String(res))),
}
} else {
tracing::warn!(id, "callback received with neither result nor error");
engine.resolve(id, Ok(serde_json::Value::Null));
}
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value, reason = "tauri::command contract — macro wrapper is the real consumer")]
pub(crate) fn callback(
eval_engine: tauri::State<'_, EvalEngine>, id: u64, result: Option<String>, error: Option<String>,
) {
handle_callback(&eval_engine, id, result, error);
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value, reason = "tauri::command contract — macro wrapper is the real consumer")]
pub(crate) fn __callback(
eval_engine: tauri::State<'_, EvalEngine>, id: u64, result: Option<String>, error: Option<String>,
) {
handle_callback(&eval_engine, id, result, error);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_dispatch_ping_returns_ok() {
let engine = EvalEngine::new();
let result =
dispatch("ping", None, &engine, None, None, None, &Recorder::new()).await.expect("dispatch succeeds");
assert_eq!(result["status"], json!("ok"));
}
#[tokio::test]
async fn test_dispatch_ping_reports_plugin_version() {
let engine = EvalEngine::new();
let result =
dispatch("ping", None, &engine, None, None, None, &Recorder::new()).await.expect("dispatch succeeds");
assert_eq!(result["plugin_version"], json!(env!("CARGO_PKG_VERSION")));
}
#[cfg(feature = "press")]
#[tokio::test]
async fn test_dispatch_press_with_invalid_combo_returns_invalid_params() {
let engine = EvalEngine::new();
let result =
dispatch("press", Some(&json!({"key": "Control++P"})), &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32602);
assert!(err.message.contains("invalid press combo"));
}
#[cfg(feature = "press")]
#[tokio::test]
async fn test_dispatch_press_with_explicit_window_and_no_focus_fn_errors() {
let engine = EvalEngine::new();
let result = dispatch(
"press",
Some(&json!({"key": "Enter", "window": "settings"})),
&engine,
None,
None,
None,
&Recorder::new(),
)
.await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("focus"));
}
#[cfg(feature = "press")]
#[tokio::test]
async fn test_dispatch_press_with_missing_key_returns_invalid_params() {
let engine = EvalEngine::new();
let result = dispatch("press", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32602);
}
#[tokio::test]
async fn test_dispatch_unknown_method_returns_error() {
let engine = EvalEngine::new();
let result = dispatch("nonexistent", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32601);
}
#[tokio::test]
async fn test_dispatch_snapshot_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("snapshot", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_diff_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("diff", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_diff_without_previous_snapshot() {
let engine = EvalEngine::new();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(|_w: Option<&str>, _script: String| Ok(()));
let result = dispatch("diff", None, &engine, Some(&eval_fn), None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32602);
assert!(err.message.contains("No previous snapshot"));
}
#[test]
fn test_build_bridge_call_snapshot() {
let params = json!({"interactive": true, "selector": null, "depth": 3});
let script = build_bridge_call("snapshot", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.snapshot("));
assert!(script.contains("\"interactive\":true"));
}
#[test]
fn test_build_bridge_call_no_params() {
let script = build_bridge_call("snapshot", None).expect("build_bridge_call");
assert_eq!(script, "window.__HASGARD__.snapshot({})");
}
#[test]
fn test_build_bridge_call_ipc_missing_command() {
let result = build_bridge_call("ipc", None);
assert!(result.is_err());
assert!(result.expect_err("ipc rejects missing command").contains("command"));
}
#[tokio::test]
async fn test_callback_with_json_result() {
let engine = EvalEngine::new();
let (id, rx) = engine.register();
handle_callback(&engine, id, Some(r#"{"title":"hello"}"#.to_owned()), None);
let val = rx.await.expect("channel not dropped").expect("eval ok");
assert_eq!(val, json!({"title": "hello"}));
}
#[tokio::test]
async fn test_callback_with_null_string_resolves_to_value_null() {
let engine = EvalEngine::new();
let (id, rx) = engine.register();
handle_callback(&engine, id, Some("null".to_owned()), None);
let val = rx.await.expect("channel not dropped").expect("eval ok");
assert_eq!(val, serde_json::Value::Null);
}
#[tokio::test]
async fn test_callback_with_error() {
let engine = EvalEngine::new();
let (id, rx) = engine.register();
handle_callback(&engine, id, None, Some("TypeError: x".to_owned()));
let result = rx.await.expect("channel not dropped");
assert_eq!(result, Err("TypeError: x".to_owned()));
}
#[tokio::test]
async fn test_dispatch_console_get_logs_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("console.getLogs", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_console_clear_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("console.clear", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[test]
fn test_build_bridge_call_console_logs() {
let params = json!({"level": "error", "last": 10});
let script = build_bridge_call("consoleLogs", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.consoleLogs("));
assert!(script.contains("\"level\":\"error\""));
}
#[test]
fn test_build_bridge_call_clear_logs() {
let script = build_bridge_call("clearLogs", None).expect("build_bridge_call");
assert_eq!(script, "window.__HASGARD__.clearLogs({})");
}
#[tokio::test]
async fn test_dispatch_network_get_requests_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("network.getRequests", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_network_clear_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("network.clear", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[test]
fn test_build_bridge_call_network_requests() {
let params = json!({"filter": "/api", "failedOnly": true, "last": 10});
let script = build_bridge_call("networkRequests", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.networkRequests("));
assert!(script.contains("\"filter\":\"/api\""));
}
#[test]
fn test_build_bridge_call_clear_network() {
let script = build_bridge_call("clearNetwork", None).expect("build_bridge_call");
assert_eq!(script, "window.__HASGARD__.clearNetwork({})");
}
#[test]
fn test_build_bridge_call_visible() {
let params = json!({"ref": "el-1"});
let script = build_bridge_call("visible", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.visible("));
assert!(script.contains("\"ref\":\"el-1\""));
}
#[test]
fn test_build_bridge_call_count() {
let params = json!({"selector": ".item"});
let script = build_bridge_call("count", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.count("));
assert!(script.contains("\"selector\":\".item\""));
}
#[test]
fn test_build_bridge_call_checked() {
let params = json!({"ref": "el-2"});
let script = build_bridge_call("checked", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.checked("));
assert!(script.contains("\"ref\":\"el-2\""));
}
#[tokio::test]
async fn test_dispatch_watch_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("watch", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[test]
fn test_build_bridge_call_watch() {
let params = json!({"timeout": 5000, "selector": ".results", "stable": 500});
let script = build_bridge_call("watch", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.watch("));
assert!(script.contains("\"timeout\":5000"));
}
#[tokio::test]
async fn test_dispatch_drag_routes_to_eval() {
let engine = EvalEngine::new();
let result = dispatch("drag", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_ne!(err.code, -32601);
}
#[tokio::test]
async fn test_dispatch_drop_routes_to_eval() {
let engine = EvalEngine::new();
let result = dispatch("drop", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_ne!(err.code, -32601);
}
#[test]
fn test_build_bridge_call_drag() {
let params = json!({"source": {"ref": "e5"}, "target": {"ref": "e6"}});
let script = build_bridge_call("drag", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.drag("));
}
#[test]
fn test_build_bridge_call_drop() {
let params = json!({"ref": "e3", "files": []});
let script = build_bridge_call("drop", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.drop("));
}
#[tokio::test]
async fn test_dispatch_storage_get_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("storage.get", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_storage_set_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("storage.set", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_storage_list_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("storage.list", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_storage_clear_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("storage.clear", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[test]
fn test_build_bridge_call_storage_get() {
let params = json!({"key": "auth_token", "session": false});
let script = build_bridge_call("storageGet", Some(¶ms)).expect("build_bridge_call");
assert_eq!(script, r#"window.__HASGARD__.storageGet({"key":"auth_token","session":false})"#);
}
#[test]
fn test_build_bridge_call_storage_set() {
let params = json!({"key": "theme", "value": "dark", "session": false});
let script = build_bridge_call("storageSet", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.storageSet("));
assert!(script.contains("\"key\":\"theme\""));
assert!(script.contains("\"value\":\"dark\""));
assert!(script.contains("\"session\":false"));
}
#[test]
fn test_build_bridge_call_storage_list() {
let params = json!({"session": true});
let script = build_bridge_call("storageList", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.storageList("));
assert!(script.contains("\"session\":true"));
}
#[test]
fn test_build_bridge_call_storage_clear() {
let params = json!({"session": false});
let script = build_bridge_call("storageClear", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.storageClear("));
assert!(script.contains("\"session\":false"));
}
#[test]
fn test_build_bridge_call_form_dump() {
let script = build_bridge_call("formDump", None).expect("build_bridge_call");
assert_eq!(script, "window.__HASGARD__.formDump({})");
}
#[test]
fn test_build_bridge_call_form_dump_with_selector() {
let params = json!({"selector": "#login-form"});
let script = build_bridge_call("formDump", Some(¶ms)).expect("build_bridge_call");
assert!(script.starts_with("window.__HASGARD__.formDump("));
assert!(script.contains("\"selector\":\"#login-form\""));
}
#[tokio::test]
async fn test_dispatch_forms_dump_without_eval_fn() {
let engine = EvalEngine::new();
let result = dispatch("forms.dump", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
#[tokio::test]
async fn test_dispatch_windows_list_without_list_fn() {
let engine = EvalEngine::new();
let result = dispatch("windows.list", None, &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No window manager"));
}
#[tokio::test]
async fn test_dispatch_windows_list_with_list_fn() {
let engine = EvalEngine::new();
let list_fn: crate::server::ListWindowsFn = std::sync::Arc::new(|| {
Ok(serde_json::json!({"windows": [{"label": "main", "url": "http://localhost", "title": "Test"}]}))
});
let result = dispatch("windows.list", None, &engine, None, Some(&list_fn), None, &Recorder::new()).await;
let val = result.expect("dispatch succeeds");
let windows = val.get("windows").expect("windows key present").as_array().expect("windows is array");
assert_eq!(windows.len(), 1);
assert_eq!(windows[0].get("label").expect("label key present"), "main");
}
#[tokio::test]
async fn test_dispatch_window_param_extracted_from_params() {
let engine = EvalEngine::new();
let captured: std::sync::Arc<std::sync::Mutex<String>> =
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let captured_clone = captured.clone();
let engine_clone = engine.clone();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(move |_w: Option<&str>, script: String| {
*captured_clone.lock().expect("captured mutex") = script;
engine_clone.resolve(1, Ok(serde_json::json!({"ok": true})));
Ok(())
});
let params = serde_json::json!({"ref": "el-1", "window": "settings"});
let _ = dispatch("click", Some(¶ms), &engine, Some(&eval_fn), None, None, &Recorder::new()).await;
let script = captured.lock().expect("captured mutex").clone();
assert!(!script.contains("\"window\""));
assert!(script.contains("\"ref\""));
}
#[tokio::test]
async fn test_dispatch_screenshot_native_rejects_missing_window_id() {
let engine = EvalEngine::new();
let params = json!({"output_path": "/tmp/x.png"});
let err = dispatch("screenshot_native", Some(¶ms), &engine, None, None, None, &Recorder::new())
.await
.expect_err("missing window_id must surface as Err");
assert_eq!(err.code, -32602);
assert!(err.message.contains("window_id"), "error message must reference window_id");
}
#[tokio::test]
async fn test_dispatch_screenshot_native_rejects_relative_output_path() {
let engine = EvalEngine::new();
let params = json!({"window_id": 1_u32, "output_path": "relative/path.png"});
let err = dispatch("screenshot_native", Some(¶ms), &engine, None, None, None, &Recorder::new())
.await
.expect_err("relative path must error before any capture");
assert_eq!(err.code, -32602);
let data = err.data.as_ref().expect("error data present");
assert_eq!(data.get("error").and_then(|v| v.as_str()), Some("INVALID_OUTPUT_PATH"));
}
#[tokio::test]
async fn test_dispatch_screenshot_routes_to_bridge_regardless_of_params() {
let engine = EvalEngine::new();
for params in [json!({}), json!({"output_path": "/tmp/x.png"})] {
let result = dispatch("screenshot", Some(¶ms), &engine, None, None, None, &Recorder::new()).await;
let err = result.expect_err("dispatch returns Err");
assert_eq!(err.code, -32603);
assert!(err.message.contains("No webview"));
}
}
#[tokio::test]
async fn test_dispatch_record_start_returns_recording() {
let engine = EvalEngine::new();
let recorder = Recorder::new();
let result =
dispatch("record.start", None, &engine, None, None, None, &recorder).await.expect("dispatch succeeds");
assert_eq!(result["status"], "recording");
assert!(recorder.is_active());
}
#[tokio::test]
async fn test_dispatch_record_stop_returns_entries() {
let engine = EvalEngine::new();
let recorder = Recorder::new();
recorder.start();
recorder.record("click", Some(&json!({"ref": "e1"})));
let result =
dispatch("record.stop", None, &engine, None, None, None, &recorder).await.expect("dispatch succeeds");
assert_eq!(result["count"], 1);
assert!(result["entries"].as_array().is_some());
assert!(!recorder.is_active());
}
#[tokio::test]
async fn test_dispatch_record_status() {
let engine = EvalEngine::new();
let recorder = Recorder::new();
recorder.start();
let result =
dispatch("record.status", None, &engine, None, None, None, &recorder).await.expect("dispatch succeeds");
assert_eq!(result["active"], true);
assert_eq!(result["count"], 0);
}
#[tokio::test]
async fn test_dispatch_record_add_entry() {
let engine = EvalEngine::new();
let recorder = Recorder::new();
recorder.start();
let params = json!({"action": "navigate", "timestamp": 100, "url": "/home"});
let result = dispatch("record.add", Some(¶ms), &engine, None, None, None, &recorder)
.await
.expect("dispatch succeeds");
assert_eq!(result["status"], "ok");
let entries = recorder.stop();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].action, "navigate");
}
#[test]
fn test_bridge_eval_timeout_uses_param_plus_buffer() {
let params = json!({"selector": "#root", "timeout": 60_000_u64});
let got = bridge_eval_timeout(Some(¶ms));
assert_eq!(got, Duration::from_millis(60_000 + BRIDGE_TIMEOUT_BUFFER_MS));
}
#[test]
fn test_bridge_eval_timeout_defaults_when_missing() {
let got = bridge_eval_timeout(None);
assert_eq!(got, Duration::from_millis(DEFAULT_BRIDGE_TIMEOUT_MS + BRIDGE_TIMEOUT_BUFFER_MS));
}
#[test]
fn test_bridge_eval_timeout_defaults_when_param_not_u64() {
let params = json!({"timeout": "soon"});
let got = bridge_eval_timeout(Some(¶ms));
assert_eq!(got, Duration::from_millis(DEFAULT_BRIDGE_TIMEOUT_MS + BRIDGE_TIMEOUT_BUFFER_MS));
}
#[test]
fn test_bridge_eval_timeout_saturates_on_overflow() {
let params = json!({"timeout": u64::MAX});
let got = bridge_eval_timeout(Some(¶ms));
assert_eq!(got, Duration::from_millis(u64::MAX));
}
#[test]
fn test_bridge_eval_timeout_zero_still_padded() {
let got = bridge_eval_timeout(Some(&json!({"timeout": 0_u64})));
assert_eq!(got, Duration::from_millis(BRIDGE_TIMEOUT_BUFFER_MS));
}
#[tokio::test(start_paused = true)]
async fn test_dispatch_wait_honors_user_timeout_above_default() {
let engine = EvalEngine::new();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(|_w: Option<&str>, _script: String| Ok(()));
let params = json!({
"selector": "[data-testid=\"never-exists\"]",
"timeout": 30_000_u64,
});
let start = tokio::time::Instant::now();
let err = dispatch("wait", Some(¶ms), &engine, Some(&eval_fn), None, None, &Recorder::new())
.await
.expect_err("dispatch must time out");
let elapsed = start.elapsed();
let user_timeout = Duration::from_secs(30);
assert!(elapsed > user_timeout, "elapsed {elapsed:?} should outlive user timeout {user_timeout:?}");
assert_eq!(err.code, -32603);
assert!(err.message.contains("timed out"), "unexpected error message: {}", err.message);
}
#[tokio::test(start_paused = true)]
async fn test_dispatch_wait_default_timeout_outlives_bridge_default() {
let engine = EvalEngine::new();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(|_w: Option<&str>, _script: String| Ok(()));
let start = tokio::time::Instant::now();
let _err = dispatch(
"wait",
Some(&json!({"selector": "#root"})),
&engine,
Some(&eval_fn),
None,
None,
&Recorder::new(),
)
.await
.expect_err("dispatch must time out");
let elapsed = start.elapsed();
let bridge_default = Duration::from_millis(DEFAULT_BRIDGE_TIMEOUT_MS);
assert!(elapsed > bridge_default, "elapsed {elapsed:?} should outlive bridge default {bridge_default:?}");
}
#[tokio::test(start_paused = true)]
async fn test_dispatch_watch_still_outlives_user_timeout() {
let engine = EvalEngine::new();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(|_w: Option<&str>, _script: String| Ok(()));
let start = tokio::time::Instant::now();
let _err = dispatch(
"watch",
Some(&json!({"timeout": 25_000_u64})),
&engine,
Some(&eval_fn),
None,
None,
&Recorder::new(),
)
.await
.expect_err("dispatch must time out");
let elapsed = start.elapsed();
let user_timeout = Duration::from_secs(25);
assert!(elapsed > user_timeout, "elapsed {elapsed:?} should outlive user timeout {user_timeout:?}");
}
#[tokio::test]
async fn test_dispatch_wait_returns_callback_value_before_timeout() {
let engine = EvalEngine::new();
let engine_clone = engine.clone();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(move |_w: Option<&str>, _script: String| {
engine_clone.resolve(1, Ok(json!({"found": true})));
Ok(())
});
let result = dispatch(
"wait",
Some(&json!({"selector": "#root", "timeout": 60_000_u64})),
&engine,
Some(&eval_fn),
None,
None,
&Recorder::new(),
)
.await
.expect("dispatch should resolve via callback, not time out");
assert_eq!(result, json!({"found": true}));
}
#[tokio::test]
async fn test_dispatch_state_injects_plugin_version() {
let engine = EvalEngine::new();
let engine_clone = engine.clone();
let eval_fn: crate::server::EvalFn = std::sync::Arc::new(move |_w: Option<&str>, _script: String| {
engine_clone.resolve(1, Ok(json!({"url": "http://localhost/", "title": "App", "ready": true})));
Ok(())
});
let result = dispatch("state", None, &engine, Some(&eval_fn), None, None, &Recorder::new())
.await
.expect("dispatch succeeds");
assert_eq!(result["url"], json!("http://localhost/"));
assert_eq!(result["ready"], json!(true));
assert_eq!(result["plugin_version"], json!(env!("CARGO_PKG_VERSION")));
}
}