Skip to main content

ashlar_debug/
lib.rs

1//! `ashlar-debug`: drive a running ashlar app's UI from the shell, the way a browser automation CLI
2//! drives a web page: snapshot the UI as an outline with short refs (`@e3`), act on refs, wait,
3//! read values, take (annotated) screenshots, and run scripts of these as UI tests.
4//!
5//! ```text
6//! ashlar-debug open ./target/debug/notes        # launch with a control port, remember the session
7//! ashlar-debug snapshot -i                      # - button "Save" [ref=e3] …
8//! ashlar-debug click @e3
9//! ashlar-debug fill @e2 "Groceries" --submit
10//! ashlar-debug wait --text "Saved"
11//! ashlar-debug screenshot --annotate            # prints the PNG's path
12//! ashlar-debug batch ui-test.txt                # one command per line, plus `assert …` lines
13//! ashlar-debug close
14//! ```
15//!
16//! Every command is one control-protocol call (`docs/control-protocol.md`) over
17//! [`ashlar_control::Client`]. Human output by default, `--json` for one `{"ok":…}` line; exit codes
18//! 0 (ok), 1 (failed: the app said no, an assertion failed, nothing answers), 2 (usage).
19//!
20//! The library is the whole CLI ([`run`]) so tests and other tools can embed it; [`demo`] is a
21//! deterministic stand-in app that answers the same methods.
22#![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 snapshot;
28pub mod target;
29pub mod transport;
30mod verbs;
31pub mod words;
32
33use std::io::{BufRead, Write};
34use std::path::PathBuf;
35use std::time::Duration;
36
37use ashlar_control::{Client, ControlError};
38use serde_json::{Value, json};
39
40pub use session::Env;
41pub use snapshot::{Outcome as SnapshotOutcome, Snapshots, Tolerance};
42pub use transport::{Report, ScriptError, Transport, run_script};
43pub use verbs::save_png;
44use words::Args;
45
46/// How a command ended.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Exit {
49    Ok,
50    /// The app refused, an assertion failed, or nothing answered.
51    Failed,
52    /// Bad arguments.
53    Usage,
54}
55
56impl Exit {
57    pub fn code(self) -> u8 {
58        match self {
59            Exit::Ok => 0,
60            Exit::Failed => 1,
61            Exit::Usage => 2,
62        }
63    }
64}
65
66impl From<Exit> for std::process::ExitCode {
67    fn from(e: Exit) -> Self {
68        std::process::ExitCode::from(e.code())
69    }
70}
71
72/// The streams a command uses (real stdio in `main`, buffers in tests).
73pub struct Io<'a> {
74    pub input: &'a mut dyn BufRead,
75    pub out: &'a mut dyn Write,
76    pub err: &'a mut dyn Write,
77}
78
79/// A command's success: the machine result (`--json`) and the human text.
80#[derive(Debug, Clone, PartialEq)]
81pub(crate) struct Done {
82    pub result: Value,
83    pub text: String,
84}
85
86impl Done {
87    pub fn new(result: Value, text: impl Into<String>) -> Self {
88        Self { result, text: text.into() }
89    }
90}
91
92/// A command's failure.
93#[derive(Debug, Clone, PartialEq)]
94pub(crate) enum Fail {
95    /// Bad arguments: the message and the verb's usage line.
96    Usage(String, String),
97    /// Everything else: the message, machine detail, and a hint for humans.
98    Failed { error: String, detail: Option<Value>, hint: Option<String> },
99}
100
101impl Fail {
102    pub fn msg(e: impl std::fmt::Display) -> Self {
103        Fail::Failed { error: e.to_string(), detail: None, hint: None }
104    }
105
106    pub fn text(&self) -> String {
107        match self {
108            Fail::Usage(m, _) => m.clone(),
109            Fail::Failed { error, .. } => error.clone(),
110        }
111    }
112
113    /// From a control call: hints for stale refs and methods the app does not answer.
114    pub fn control(method: &str, e: ControlError) -> Self {
115        match e {
116            ControlError::Remote { error, detail } => {
117                let kind = detail.as_ref().and_then(|d| d.get("kind")).and_then(Value::as_str).unwrap_or("");
118                let hint = if kind == "stale_ref" || error.starts_with("stale_ref") {
119                    Some("refs come from the latest snapshot: run `ashlar-debug snapshot` again".to_string())
120                } else if error.starts_with("unknown method") {
121                    Some(format!("this app does not answer `{method}`; `ashlar-debug status` lists what it does"))
122                } else {
123                    None
124                };
125                Fail::Failed { error, detail, hint }
126            }
127            other => Fail::Failed {
128                error: other.to_string(),
129                detail: None,
130                hint: Some("is the app still running? `ashlar-debug status` checks the session".into()),
131            },
132        }
133    }
134}
135
136pub(crate) type Res = Result<Done, Fail>;
137
138/// One invocation's state: options, the session it talks to, the connection once made.
139pub(crate) struct Ctx<'e, 'io, 'a> {
140    pub env: &'e Env,
141    pub io: &'io mut Io<'a>,
142    pub json: bool,
143    pub session: String,
144    pub port: Option<u16>,
145    pub key_dir: Option<PathBuf>,
146    pub app: Option<String>,
147    /// Longest wait for one reply.
148    pub timeout: Duration,
149    pub conn: Option<Client>,
150    /// Inside `batch` (no nested batches; step output is quieter).
151    pub in_batch: bool,
152    /// A trace started inside a `batch` run with `--port`/`--app` (no session file to keep it in).
153    pub batch_trace: Option<session::Trace>,
154    /// An in-process target ([`run_script`]): every call goes here, and session verbs fail.
155    pub inproc: Option<&'io mut (dyn Transport + 'io)>,
156}
157
158impl<'e, 'io, 'a> Ctx<'e, 'io, 'a> {
159    pub(crate) fn new(env: &'e Env, io: &'io mut Io<'a>, json: bool) -> Self {
160        Ctx {
161            env,
162            io,
163            json,
164            session: env.session.clone(),
165            port: None,
166            key_dir: None,
167            app: None,
168            timeout: DEFAULT_TIMEOUT,
169            conn: None,
170            in_batch: false,
171            batch_trace: None,
172            inproc: None,
173        }
174    }
175}
176
177const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
178
179/// `(verb, usage, about)` for help and usage errors.
180pub(crate) const VERBS: &[(&str, &str, &str)] = &[
181    ("open", "open <exe> [--wait MS] [--no-raise] [-- app-args…]", "launch an app with a control port and remember it as the session"),
182    ("connect", "connect <port> [--no-raise]", "remember an app already running with `--control <port>`"),
183    ("close", "close [--quit]", "quit the app `open` started (or forget a connected one; --quit quits it too)"),
184    ("status", "status", "the session and the app's name, version and methods"),
185    ("sessions", "sessions", "every recorded session and whether it answers"),
186    ("snapshot", "snapshot [-i] [--scope T] [--depth N] [-c]", "the UI as an outline with refs (-i: interactive elements only)"),
187    ("click", "click <T> [--button primary|secondary|middle] [--count N] [--mods Shift+Mod]", "click an element"),
188    ("dblclick", "dblclick <T>", "double-click an element"),
189    ("hover", "hover <T>", "move the pointer over an element"),
190    ("focus", "focus <T>", "give an element keyboard focus"),
191    ("fill", "fill <T> <text> [--submit]", "replace a text field's contents (--submit presses Enter)"),
192    ("type", "type <text>", "type text into the focused element"),
193    ("press", "press <keys>", "press a chord or a sequence: Mod+S, \"Tab Tab Enter\""),
194    ("scroll", "scroll [T] [up|down|left|right] [px] | --dx N --dy N", "scroll (over an element, or the window)"),
195    ("scrollintoview", "scrollintoview <T>", "scroll until an element is visible"),
196    ("drag", "drag <from> <to> [--steps N]", "press on one element, move, release on another"),
197    ("get", "get <text|value|name|role|rect|enabled|visible|focused|checked|selected|element> <T>", "read one property of an element"),
198    (
199        "wait",
200        "wait <T | ms> | --text S | --gone T | --enabled T | --idle  [--timeout MS]",
201        "wait for an element, text, its absence, enablement, or an idle app",
202    ),
203    ("screenshot", "screenshot [path.png] [--target T] [--annotate] [--highlight T] [--pad N] [--no-raise]", "save a PNG and print its path"),
204    ("highlight", "highlight <T> [--ms N]", "flash an outline around an element in the live app"),
205    ("exec", "exec <command> [json|-]", "run one of the app's commands"),
206    ("commands", "commands [query]", "list the app's commands"),
207    ("menu", "menu [id]", "print the menu tree, or invoke the item `id`"),
208    ("logs", "logs [--lines N] [--level LEVEL]", "the app's recent log lines"),
209    ("stats", "stats [--reset]", "the app's frame and job statistics"),
210    ("clock", "clock [pause | step <ms> | resume]", "pause, step or resume the app's animation clock (input still runs while paused)"),
211    ("devtools", "devtools [open|close|toggle|state]", "the in-app inspector"),
212    ("call", "call <method> [json|-]", "any control method, raw"),
213    ("batch", "batch <file|-> [--verbose]", "run a script of these commands and `assert` lines; stops at the first failure"),
214    ("trace", "trace start <dir> | trace stop", "record a screenshot and snapshot after every action"),
215    ("demo", "demo [--control PORT]", "serve a small stand-in app (for trying the CLI and for tests)"),
216    ("version", "version", "print the version"),
217];
218
219pub(crate) fn usage_of(verb: &str) -> String {
220    VERBS.iter().find(|v| v.0 == verb).map_or_else(String::new, |v| format!("ashlar-debug {}", v.1))
221}
222
223fn help(out: &mut dyn Write) {
224    let _ = writeln!(out, "ashlar-debug {}: drive a running app's UI\n", env!("CARGO_PKG_VERSION"));
225    let _ = writeln!(out, "usage: ashlar-debug [--session NAME | --port N [--key-dir D] | --app NAME] [--json] [--call-timeout MS] <command>\n");
226    for (_, usage, about) in VERBS {
227        let _ = writeln!(out, "  {usage}\n      {about}");
228    }
229    let _ = writeln!(
230        out,
231        "\nTargets (T): @e3 or e3 (a ref from the latest snapshot), id=<automation id>, text=<label>,\n\
232         <role>=<label> (button=Save), or x,y in window points.\n\
233         Exit codes: 0 ok, 1 failed, 2 usage. --json prints one {{\"ok\":…}} line.\n\
234         Apps that render screenshots off screen (ashlar-app does) are driven while covered or minimized;\n\
235         others have a covered window raised once for a screenshot. --no-raise (on open, connect or\n\
236         screenshot) or ASHLAR_DEBUG_NO_RAISE=1 never raises it, for runs that must not steal focus."
237    );
238}
239
240/// Run one invocation (`args` without the program name).
241pub fn run(args: Vec<String>, env: &Env, io: &mut Io<'_>) -> Exit {
242    let shown = args.clone();
243    let mut a = Args::new(args);
244    let json = a.flag(&["--json"]);
245    let parsed = (|| -> Result<_, String> {
246        let session = a.value(&["--session"])?.unwrap_or_else(|| env.session.clone());
247        let port = a.number::<u16>(&["--port"])?;
248        let key_dir = a.value(&["--key-dir"])?.map(PathBuf::from);
249        let app = a.value(&["--app"])?;
250        let timeout = a.number::<u64>(&["--call-timeout"])?.map(Duration::from_millis);
251        Ok((session, port, key_dir, app, timeout))
252    })();
253    let (session, port, key_dir, app, timeout) = match parsed {
254        Ok(p) => p,
255        Err(e) => return finish(json, io, Err(Fail::Usage(e, "ashlar-debug --help".into()))),
256    };
257    if let Err(e) = session::check_name(&session) {
258        return finish(json, io, Err(Fail::Usage(e, "ashlar-debug --help".into())));
259    }
260    let Some(verb) = a.positional() else {
261        if a.flag(&["--help", "-h"]) || a.flag(&["--version", "-V"]) {
262            help(io.out);
263            return Exit::Ok;
264        }
265        help(io.err);
266        return Exit::Usage;
267    };
268    let mut ctx = Ctx::new(env, io, json);
269    (ctx.session, ctx.port, ctx.key_dir, ctx.app) = (session, port, key_dir, app);
270    ctx.timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
271    let r = ctx.run_verb(&verb, a, &shown);
272    finish(json, ctx.io, r)
273}
274
275/// Print a command's outcome and turn it into an exit code.
276pub(crate) fn finish(json: bool, io: &mut Io<'_>, r: Res) -> Exit {
277    match r {
278        Ok(d) => {
279            if json {
280                let _ = writeln!(io.out, "{}", json!({"ok": true, "result": d.result}));
281            } else if !d.text.is_empty() {
282                let _ = writeln!(io.out, "{}", d.text.trim_end_matches('\n'));
283            }
284            Exit::Ok
285        }
286        Err(Fail::Usage(msg, usage)) => {
287            if json {
288                let _ = writeln!(io.out, "{}", json!({"ok": false, "error": msg}));
289            }
290            let _ = writeln!(io.err, "error: {msg}\nusage: {usage}");
291            Exit::Usage
292        }
293        Err(Fail::Failed { error, detail, hint }) => {
294            if json {
295                let mut o = json!({"ok": false, "error": error});
296                if let Some(d) = detail {
297                    o["detail"] = d;
298                }
299                let _ = writeln!(io.out, "{o}");
300            } else {
301                let _ = writeln!(io.err, "error: {error}");
302                if let Some(h) = hint {
303                    let _ = writeln!(io.err, "hint: {h}");
304                }
305            }
306            Exit::Failed
307        }
308    }
309}
310
311/// `main` for the binary: process arguments, real stdio, the OS environment.
312pub fn main() -> std::process::ExitCode {
313    let args: Vec<String> = std::env::args().skip(1).collect();
314    let env = Env::from_os();
315    let (stdin, stdout, stderr) = (std::io::stdin(), std::io::stdout(), std::io::stderr());
316    let (mut i, mut o, mut e) = (stdin.lock(), stdout.lock(), stderr.lock());
317    run(args, &env, &mut Io { input: &mut i, out: &mut o, err: &mut e }).into()
318}