use std::panic::{AssertUnwindSafe, catch_unwind};
use ashlar_command::{CommandError, CommandInfo, Dispatch};
use serde_json::{Value, json};
use crate::protocol::{PROTOCOL_VERSION, Reply, Request};
pub const STANDARD_METHODS: &[&str] = &["app.ping", "app.version", "engine.commands", "engine.execute", "engine.batch"];
pub const UI_METHODS: &[&str] =
&["ui.inspect", "ui.click", "ui.drag", "ui.scroll", "ui.key", "ui.type", "ui.screenshot", "ui.menu.list", "ui.menu.invoke", "ui.set", "app.quit"];
pub const DEBUG_METHODS: &[&str] = &[
"ui.snapshot",
"ui.hover",
"ui.focus",
"ui.fill",
"ui.press",
"ui.scroll_into_view",
"ui.get",
"ui.wait",
"ui.highlight",
"devtools.toggle",
"devtools.state",
];
pub const MAX_BATCH: usize = 10_000;
#[derive(Debug, Clone, PartialEq)]
pub struct AppInfo {
pub name: String,
pub version: String,
}
impl AppInfo {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self { name: name.into(), version: version.into() }
}
}
pub trait UiHandler {
fn methods(&self) -> Vec<String>;
fn handle(&mut self, req: Request);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoUi;
impl UiHandler for NoUi {
fn methods(&self) -> Vec<String> {
Vec::new()
}
fn handle(&mut self, req: Request) {
if req.method().starts_with("ui.") || req.method() == "app.quit" {
let m = req.method().to_string();
req.fail(format!("`{m}` needs the app window; this is a headless session (connect to the running app instead)"));
} else {
req.unknown();
}
}
}
pub fn handle(req: Request, dispatch: &mut dyn Dispatch, ui: &mut dyn UiHandler, app: &AppInfo) {
match standard(req.method(), req.params(), dispatch, ui, app) {
Some(r) => req.reply(r),
None => ui.handle(req),
}
}
pub fn answer(method: &str, params: Value, dispatch: &mut dyn Dispatch, ui: &mut dyn UiHandler, app: &AppInfo) -> Reply {
let (req, rx) = Request::new(method, params);
handle(req, dispatch, ui, app);
rx.try_recv().unwrap_or_else(|_| Reply::err(format!("`{method}` cannot be answered synchronously here")))
}
fn standard(method: &str, p: &Value, dispatch: &mut dyn Dispatch, ui: &dyn UiHandler, app: &AppInfo) -> Option<Reply> {
Some(match method {
"app.ping" => Reply::ok(json!("pong")),
"app.version" => {
let mut methods: Vec<String> = STANDARD_METHODS.iter().map(|m| m.to_string()).collect();
methods.extend(ui.methods());
Reply::ok(json!({"name": app.name, "version": app.version, "protocol": PROTOCOL_VERSION, "methods": methods}))
}
"engine.commands" => {
let q = p.get("query").and_then(Value::as_str).unwrap_or("").to_lowercase();
let list: Vec<CommandInfo> = commands(dispatch).into_iter().filter(|c| matches_query(c, &q)).collect();
Reply::ok(serde_json::to_value(list).unwrap_or_default())
}
"engine.execute" => match p.get("command").and_then(Value::as_str) {
Some(id) => match execute(dispatch, id, params_of(p)) {
Ok(v) => Reply::Ok(v),
Err(e) => crate::explain::explain(&e, &commands(dispatch)),
},
None => Reply::err("engine.execute needs a string `command`"),
},
"engine.batch" => batch(dispatch, p),
_ => return None,
})
}
pub fn matches_query(c: &CommandInfo, q: &str) -> bool {
q.is_empty() || c.id.to_lowercase().contains(q) || c.label.to_lowercase().contains(q) || c.menu.iter().any(|m| m.to_lowercase().contains(q))
}
fn params_of(p: &Value) -> Value {
match p.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) => v.clone(),
}
}
fn commands(dispatch: &mut dyn Dispatch) -> Vec<CommandInfo> {
catch_unwind(AssertUnwindSafe(|| dispatch.commands())).unwrap_or_default()
}
fn execute(dispatch: &mut dyn Dispatch, id: &str, params: Value) -> Result<Value, CommandError> {
catch_unwind(AssertUnwindSafe(|| dispatch.execute(id, params))).unwrap_or_else(|_| Err(CommandError::Panicked { id: id.to_string() }))
}
fn batch(dispatch: &mut dyn Dispatch, p: &Value) -> Reply {
let Some(steps) = p.get("commands").and_then(Value::as_array) else {
return Reply::err("engine.batch needs a `commands` array of {command, params}");
};
if steps.len() > MAX_BATCH {
return Reply::err(format!("engine.batch takes at most {MAX_BATCH} commands, got {}", steps.len()));
}
let keep_going = p.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let mut out = Vec::with_capacity(steps.len());
let mut first_failure: Option<String> = None;
let mut failed = 0usize;
for (i, step) in steps.iter().enumerate() {
let Some(id) = step.get("command").and_then(Value::as_str) else {
let e = format!("step {} has no string `command`", i.saturating_add(1));
out.push(json!({"ok": false, "error": e}));
first_failure.get_or_insert(e);
failed = failed.saturating_add(1);
if keep_going {
continue;
}
break;
};
match execute(dispatch, id, params_of(step)) {
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
Err(e) => {
let (msg, detail) = match crate::explain::explain(&e, &commands(dispatch)) {
Reply::Err { error, detail } => (error, detail.unwrap_or(Value::Null)),
Reply::Ok(_) => (e.to_string(), serde_json::to_value(&e).unwrap_or(Value::Null)),
};
out.push(json!({"command": id, "ok": false, "error": msg, "detail": detail}));
first_failure.get_or_insert(format!("step {} (`{id}`) failed: {msg}", i.saturating_add(1)));
failed = failed.saturating_add(1);
if !keep_going {
break;
}
}
}
}
match first_failure {
None => Reply::ok(Value::Array(out)),
Some(first) if keep_going => Reply::err_with(format!("{failed} of {} steps failed; first: {first}", steps.len()), json!({"steps": out})),
Some(first) => Reply::err_with(first, json!({"steps": out})),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Counter(i64);
impl Dispatch for Counter {
fn commands(&self) -> Vec<CommandInfo> {
["count.add", "count.fail", "count.boom"]
.iter()
.map(|id| CommandInfo {
id: id.to_string(),
label: id.to_string(),
menu: vec!["Edit".into()],
shortcut: None,
params_schema: Value::Null,
params_text: None,
enabled: true,
disabled_reason: None,
mutates: true,
})
.collect()
}
fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
match id {
"count.add" => {
self.0 += params.get("by").and_then(Value::as_i64).unwrap_or(1);
Ok(json!(self.0))
}
"count.fail" => Err(CommandError::Failed { id: id.into(), msg: "no".into() }),
"count.boom" => panic!("boom"),
_ => Err(CommandError::Unknown { id: id.into() }),
}
}
}
fn call(d: &mut Counter, m: &str, p: Value) -> Reply {
answer(m, p, d, &mut NoUi, &AppInfo::new("test", "1.2.3"))
}
#[test]
fn standard_methods() {
let mut d = Counter::default();
assert_eq!(call(&mut d, "app.ping", json!({})), Reply::ok(json!("pong")));
let Reply::Ok(v) = call(&mut d, "app.version", json!({})) else { panic!() };
assert_eq!((v["name"].clone(), v["version"].clone(), v["protocol"].clone()), (json!("test"), json!("1.2.3"), json!(1)));
assert_eq!(v["methods"].as_array().unwrap().len(), STANDARD_METHODS.len());
let Reply::Ok(v) = call(&mut d, "engine.commands", json!({"query": "FAIL"})) else { panic!() };
assert_eq!(v.as_array().unwrap().len(), 1);
assert_eq!(call(&mut d, "engine.execute", json!({"command": "count.add", "params": {"by": 5}})), Reply::ok(json!(5)));
assert_eq!(call(&mut d, "engine.execute", json!({"command": "count.add"})), Reply::ok(json!(6)));
let Reply::Err { error, detail } = call(&mut d, "engine.execute", json!({"command": "nope"})) else { panic!() };
assert_eq!(detail, Some(json!({"kind": "unknown", "id": "nope", "suggestions": []})));
assert!(error.starts_with("unknown command `nope`;"), "{error}");
let Reply::Err { error, detail } = call(&mut d, "engine.execute", json!({"command": "count.ad"})) else { panic!() };
assert!(error.contains("did you mean `count.add`"), "{error}");
assert_eq!(detail.unwrap()["suggestions"][0]["id"], "count.add");
assert!(!call(&mut d, "engine.execute", json!({"command": 3})).is_ok());
}
#[test]
fn a_panicking_command_is_an_error() {
let mut d = Counter::default();
let Reply::Err { detail, .. } = call(&mut d, "engine.execute", json!({"command": "count.boom"})) else { panic!() };
assert_eq!(detail, Some(json!({"kind": "panicked", "id": "count.boom"})));
}
#[test]
fn batches_stop_at_the_first_error_unless_keep_going() {
let mut d = Counter::default();
let steps = json!([{"command": "count.add"}, {"command": "count.fail"}, {"command": "count.add"}]);
let Reply::Err { error, detail } = call(&mut d, "engine.batch", json!({"commands": steps})) else { panic!() };
assert!(error.starts_with("step 2 (`count.fail`) failed"), "{error}");
assert_eq!(detail.unwrap()["steps"].as_array().unwrap().len(), 2);
assert_eq!(d.0, 1);
let Reply::Err { error, detail } = call(&mut d, "engine.batch", json!({"commands": steps, "keepGoing": true})) else { panic!() };
assert!(error.starts_with("1 of 3 steps failed"), "{error}");
assert_eq!(detail.unwrap()["steps"].as_array().unwrap().len(), 3);
assert_eq!(d.0, 3);
assert_eq!(
call(&mut d, "engine.batch", json!({"commands": [{"command": "count.add"}]})),
Reply::ok(json!([{"command": "count.add", "ok": true, "result": 4}]))
);
assert!(!call(&mut d, "engine.batch", json!({})).is_ok());
assert!(!call(&mut d, "engine.batch", json!({"commands": [5]})).is_ok());
let huge: Vec<Value> = (0..=MAX_BATCH).map(|_| json!({"command": "count.add"})).collect();
assert!(!call(&mut d, "engine.batch", json!({"commands": huge})).is_ok());
assert_eq!(d.0, 4, "an oversize batch runs nothing");
}
#[test]
fn ui_methods_go_to_the_handler() {
let mut d = Counter::default();
let Reply::Err { error, .. } = call(&mut d, "ui.click", json!({})) else { panic!() };
assert!(error.contains("needs the app window"), "{error}");
let Reply::Err { error, .. } = call(&mut d, "frobnicate", json!({})) else { panic!() };
assert!(error.contains("unknown method"), "{error}");
struct Later(Vec<Request>);
impl UiHandler for Later {
fn methods(&self) -> Vec<String> {
vec!["ui.screenshot".into()]
}
fn handle(&mut self, req: Request) {
self.0.push(req);
}
}
let mut ui = Later(Vec::new());
let app = AppInfo::new("t", "1");
let (req, rx) = Request::new("ui.screenshot", json!({}));
handle(req, &mut d, &mut ui, &app);
assert!(rx.try_recv().is_err(), "kept for a later frame");
ui.0.pop().unwrap().ok(json!({"png": ""}));
assert!(rx.try_recv().unwrap().is_ok());
let Reply::Ok(v) = answer("app.version", json!({}), &mut d, &mut ui, &app) else { panic!() };
assert!(v["methods"].as_array().unwrap().contains(&json!("ui.screenshot")));
assert!(!answer("ui.screenshot", json!({}), &mut d, &mut ui, &app).is_ok());
}
}