1#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
23
24mod batch;
25pub mod demo;
26pub mod session;
27pub mod target;
28mod verbs;
29pub mod words;
30
31use std::io::{BufRead, Write};
32use std::path::PathBuf;
33use std::time::Duration;
34
35use ashlar_control::{Client, ControlError};
36use serde_json::{Value, json};
37
38pub use session::Env;
39use words::Args;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Exit {
44 Ok,
45 Failed,
47 Usage,
49}
50
51impl Exit {
52 pub fn code(self) -> u8 {
53 match self {
54 Exit::Ok => 0,
55 Exit::Failed => 1,
56 Exit::Usage => 2,
57 }
58 }
59}
60
61impl From<Exit> for std::process::ExitCode {
62 fn from(e: Exit) -> Self {
63 std::process::ExitCode::from(e.code())
64 }
65}
66
67pub struct Io<'a> {
69 pub input: &'a mut dyn BufRead,
70 pub out: &'a mut dyn Write,
71 pub err: &'a mut dyn Write,
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub(crate) struct Done {
77 pub result: Value,
78 pub text: String,
79}
80
81impl Done {
82 pub fn new(result: Value, text: impl Into<String>) -> Self {
83 Self { result, text: text.into() }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub(crate) enum Fail {
90 Usage(String, String),
92 Failed { error: String, detail: Option<Value>, hint: Option<String> },
94}
95
96impl Fail {
97 pub fn msg(e: impl std::fmt::Display) -> Self {
98 Fail::Failed { error: e.to_string(), detail: None, hint: None }
99 }
100
101 pub fn text(&self) -> String {
102 match self {
103 Fail::Usage(m, _) => m.clone(),
104 Fail::Failed { error, .. } => error.clone(),
105 }
106 }
107
108 pub fn control(method: &str, e: ControlError) -> Self {
110 match e {
111 ControlError::Remote { error, detail } => {
112 let kind = detail.as_ref().and_then(|d| d.get("kind")).and_then(Value::as_str).unwrap_or("");
113 let hint = if kind == "stale_ref" || error.starts_with("stale_ref") {
114 Some("refs come from the latest snapshot: run `ashlar-debug snapshot` again".to_string())
115 } else if error.starts_with("unknown method") {
116 Some(format!("this app does not answer `{method}`; `ashlar-debug status` lists what it does"))
117 } else {
118 None
119 };
120 Fail::Failed { error, detail, hint }
121 }
122 other => Fail::Failed {
123 error: other.to_string(),
124 detail: None,
125 hint: Some("is the app still running? `ashlar-debug status` checks the session".into()),
126 },
127 }
128 }
129}
130
131pub(crate) type Res = Result<Done, Fail>;
132
133pub(crate) struct Ctx<'e, 'io, 'a> {
135 pub env: &'e Env,
136 pub io: &'io mut Io<'a>,
137 pub json: bool,
138 pub session: String,
139 pub port: Option<u16>,
140 pub key_dir: Option<PathBuf>,
141 pub app: Option<String>,
142 pub timeout: Duration,
144 pub conn: Option<Client>,
145 pub in_batch: bool,
147}
148
149const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
150
151pub(crate) const VERBS: &[(&str, &str, &str)] = &[
153 ("open", "open <exe> [--wait MS] [--no-raise] [-- app-args…]", "launch an app with a control port and remember it as the session"),
154 ("connect", "connect <port> [--no-raise]", "remember an app already running with `--control <port>`"),
155 ("close", "close [--quit]", "quit the app `open` started (or forget a connected one; --quit quits it too)"),
156 ("status", "status", "the session and the app's name, version and methods"),
157 ("sessions", "sessions", "every recorded session and whether it answers"),
158 ("snapshot", "snapshot [-i] [--scope T] [--depth N] [-c]", "the UI as an outline with refs (-i: interactive elements only)"),
159 ("click", "click <T> [--button primary|secondary|middle] [--count N] [--mods Shift+Mod]", "click an element"),
160 ("dblclick", "dblclick <T>", "double-click an element"),
161 ("hover", "hover <T>", "move the pointer over an element"),
162 ("focus", "focus <T>", "give an element keyboard focus"),
163 ("fill", "fill <T> <text> [--submit]", "replace a text field's contents (--submit presses Enter)"),
164 ("type", "type <text>", "type text into the focused element"),
165 ("press", "press <keys>", "press a chord or a sequence: Mod+S, \"Tab Tab Enter\""),
166 ("scroll", "scroll [T] [up|down|left|right] [px] | --dx N --dy N", "scroll (over an element, or the window)"),
167 ("scrollintoview", "scrollintoview <T>", "scroll until an element is visible"),
168 ("drag", "drag <from> <to> [--steps N]", "press on one element, move, release on another"),
169 ("get", "get <text|value|name|role|rect|enabled|visible|focused|checked|selected|element> <T>", "read one property of an element"),
170 (
171 "wait",
172 "wait <T | ms> | --text S | --gone T | --enabled T | --idle [--timeout MS]",
173 "wait for an element, text, its absence, enablement, or an idle app",
174 ),
175 ("screenshot", "screenshot [path.png] [--target T] [--annotate] [--highlight T] [--pad N] [--no-raise]", "save a PNG and print its path"),
176 ("highlight", "highlight <T> [--ms N]", "flash an outline around an element in the live app"),
177 ("exec", "exec <command> [json|-]", "run one of the app's commands"),
178 ("commands", "commands [query]", "list the app's commands"),
179 ("menu", "menu [id]", "print the menu tree, or invoke the item `id`"),
180 ("logs", "logs [--lines N] [--level LEVEL]", "the app's recent log lines"),
181 ("stats", "stats [--reset]", "the app's frame and job statistics"),
182 ("devtools", "devtools [open|close|toggle|state]", "the in-app inspector"),
183 ("call", "call <method> [json|-]", "any control method, raw"),
184 ("batch", "batch <file|-> [--verbose]", "run a script of these commands and `assert` lines; stops at the first failure"),
185 ("trace", "trace start <dir> | trace stop", "record a screenshot and snapshot after every action"),
186 ("demo", "demo [--control PORT]", "serve a small stand-in app (for trying the CLI and for tests)"),
187 ("version", "version", "print the version"),
188];
189
190pub(crate) fn usage_of(verb: &str) -> String {
191 VERBS.iter().find(|v| v.0 == verb).map_or_else(String::new, |v| format!("ashlar-debug {}", v.1))
192}
193
194fn help(out: &mut dyn Write) {
195 let _ = writeln!(out, "ashlar-debug {}: drive a running app's UI\n", env!("CARGO_PKG_VERSION"));
196 let _ = writeln!(out, "usage: ashlar-debug [--session NAME | --port N [--key-dir D] | --app NAME] [--json] [--call-timeout MS] <command>\n");
197 for (_, usage, about) in VERBS {
198 let _ = writeln!(out, " {usage}\n {about}");
199 }
200 let _ = writeln!(
201 out,
202 "\nTargets (T): @e3 or e3 (a ref from the latest snapshot), id=<automation id>, text=<label>,\n\
203 <role>=<label> (button=Save), or x,y in window points.\n\
204 Exit codes: 0 ok, 1 failed, 2 usage. --json prints one {{\"ok\":…}} line.\n\
205 Apps that render screenshots off screen (ashlar-app does) are driven while covered or minimized;\n\
206 others have a covered window raised once for a screenshot. --no-raise (on open, connect or\n\
207 screenshot) or ASHLAR_DEBUG_NO_RAISE=1 never raises it, for runs that must not steal focus."
208 );
209}
210
211pub fn run(args: Vec<String>, env: &Env, io: &mut Io<'_>) -> Exit {
213 let shown = args.clone();
214 let mut a = Args::new(args);
215 let json = a.flag(&["--json"]);
216 let parsed = (|| -> Result<_, String> {
217 let session = a.value(&["--session"])?.unwrap_or_else(|| env.session.clone());
218 let port = a.number::<u16>(&["--port"])?;
219 let key_dir = a.value(&["--key-dir"])?.map(PathBuf::from);
220 let app = a.value(&["--app"])?;
221 let timeout = a.number::<u64>(&["--call-timeout"])?.map(Duration::from_millis);
222 Ok((session, port, key_dir, app, timeout))
223 })();
224 let (session, port, key_dir, app, timeout) = match parsed {
225 Ok(p) => p,
226 Err(e) => return finish(json, io, Err(Fail::Usage(e, "ashlar-debug --help".into()))),
227 };
228 if let Err(e) = session::check_name(&session) {
229 return finish(json, io, Err(Fail::Usage(e, "ashlar-debug --help".into())));
230 }
231 let Some(verb) = a.positional() else {
232 if a.flag(&["--help", "-h"]) || a.flag(&["--version", "-V"]) {
233 help(io.out);
234 return Exit::Ok;
235 }
236 help(io.err);
237 return Exit::Usage;
238 };
239 let mut ctx = Ctx { env, io, json, session, port, key_dir, app, timeout: timeout.unwrap_or(DEFAULT_TIMEOUT), conn: None, in_batch: false };
240 let r = ctx.run_verb(&verb, a, &shown);
241 finish(json, ctx.io, r)
242}
243
244pub(crate) fn finish(json: bool, io: &mut Io<'_>, r: Res) -> Exit {
246 match r {
247 Ok(d) => {
248 if json {
249 let _ = writeln!(io.out, "{}", json!({"ok": true, "result": d.result}));
250 } else if !d.text.is_empty() {
251 let _ = writeln!(io.out, "{}", d.text.trim_end_matches('\n'));
252 }
253 Exit::Ok
254 }
255 Err(Fail::Usage(msg, usage)) => {
256 if json {
257 let _ = writeln!(io.out, "{}", json!({"ok": false, "error": msg}));
258 }
259 let _ = writeln!(io.err, "error: {msg}\nusage: {usage}");
260 Exit::Usage
261 }
262 Err(Fail::Failed { error, detail, hint }) => {
263 if json {
264 let mut o = json!({"ok": false, "error": error});
265 if let Some(d) = detail {
266 o["detail"] = d;
267 }
268 let _ = writeln!(io.out, "{o}");
269 } else {
270 let _ = writeln!(io.err, "error: {error}");
271 if let Some(h) = hint {
272 let _ = writeln!(io.err, "hint: {h}");
273 }
274 }
275 Exit::Failed
276 }
277 }
278}
279
280pub fn main() -> std::process::ExitCode {
282 let args: Vec<String> = std::env::args().skip(1).collect();
283 let env = Env::from_os();
284 let (stdin, stdout, stderr) = (std::io::stdin(), std::io::stdout(), std::io::stderr());
285 let (mut i, mut o, mut e) = (stdin.lock(), stdout.lock(), stderr.lock());
286 run(args, &env, &mut Io { input: &mut i, out: &mut o, err: &mut e }).into()
287}