use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "tauri-hasgard", version, about = "Interactive testing CLI for Tauri apps")]
pub(crate) struct Cli {
#[arg(long, env = "TAURI_HASGARD_SOCKET")]
pub socket: Option<PathBuf>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, env = "TAURI_HASGARD_WINDOW", global = true)]
pub window: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Mcp,
Windows,
Ping,
State,
Snapshot {
#[arg(short, long)]
interactive: bool,
#[arg(short, long)]
selector: Option<String>,
#[arg(short, long)]
depth: Option<u8>,
#[arg(long, value_name = "FILE")]
save: Option<std::path::PathBuf>,
},
Diff {
#[arg(long, value_name = "FILE")]
r#ref: Option<std::path::PathBuf>,
#[arg(short, long)]
interactive: bool,
#[arg(short, long)]
selector: Option<String>,
#[arg(short, long)]
depth: Option<u8>,
},
Click { target: String },
Fill { target: String, value: String },
Type { target: String, text: String },
Press { key: String },
Select { target: String, value: String },
Check { target: String },
Scroll {
direction: String,
amount: Option<i32>,
#[arg(long)]
r#ref: Option<String>,
},
Drag {
source: String,
#[arg(conflicts_with = "offset")]
target: Option<String>,
#[arg(long, value_name = "X,Y", conflicts_with = "target")]
offset: Option<String>,
},
Drop {
target: String,
#[arg(long, required = true)]
file: Vec<std::path::PathBuf>,
},
Text { target: String },
Html { target: Option<String> },
Value { target: String },
Attrs { target: String },
Eval {
script: Option<String>,
},
Ipc {
command: String,
#[arg(long)]
args: Option<String>,
},
Screenshot {
path: Option<PathBuf>,
#[arg(long)]
selector: Option<String>,
},
#[command(name = "screenshot_native", visible_alias = "screenshot-native")]
ScreenshotNative {
#[arg(long)]
window_id: u32,
#[arg(long)]
output: PathBuf,
#[arg(long, default_value = "png")]
format: String,
},
Navigate { url: String },
Url,
Title,
Wait {
target: Option<String>,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
gone: bool,
#[arg(long, default_value = "10000")]
timeout: u64,
},
Watch {
#[arg(long)]
selector: Option<String>,
#[arg(long, default_value = "10000")]
timeout: u64,
#[arg(long, default_value = "300")]
stable: u64,
#[arg(long)]
require_mutation: bool,
},
Logs {
#[arg(long, value_parser = ["log", "info", "warn", "error"])]
level: Option<String>,
#[arg(long)]
last: Option<usize>,
#[arg(long, conflicts_with = "follow")]
clear: bool,
#[arg(long, short = 'f')]
follow: bool,
},
Network {
#[arg(long)]
filter: Option<String>,
#[arg(long)]
failed: bool,
#[arg(long)]
last: Option<usize>,
#[arg(long, conflicts_with = "follow")]
clear: bool,
#[arg(long, short = 'f')]
follow: bool,
},
#[command(subcommand)]
Assert(AssertKind),
Storage(StorageArgs),
Forms(FormsArgs),
Record {
#[command(subcommand)]
action: RecordAction,
},
Replay {
path: PathBuf,
#[arg(long)]
export: Option<String>,
},
Run {
scenario: PathBuf,
#[arg(long, value_name = "FILE")]
junit: Option<PathBuf>,
#[arg(long)]
no_fail_fast: bool,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum AssertKind {
Text { target: String, expected: String },
Visible { target: String },
Hidden { target: String },
Value { target: String, expected: String },
Count { selector: String, expected: u64 },
Checked { target: String },
Contains { target: String, expected: String },
Url { expected: String },
}
#[derive(clap::Args, Debug)]
pub(crate) struct StorageArgs {
#[arg(long)]
pub session: bool,
#[command(subcommand)]
pub action: StorageAction,
}
#[derive(clap::Args, Debug)]
pub(crate) struct FormsArgs {
#[arg(long)]
pub selector: Option<String>,
}
#[derive(Subcommand, Debug)]
pub(crate) enum StorageAction {
Get { key: String },
Set { key: String, value: String },
List,
Clear,
}
#[derive(Subcommand, Debug)]
pub(crate) enum RecordAction {
Start,
Stop {
#[arg(short, long)]
output: PathBuf,
},
Status,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Ref(String),
Selector(String),
Coords(i32, i32),
}
pub(crate) fn parse_target(s: &str) -> Target {
if let Some(r) = s.strip_prefix('@') {
return Target::Ref(r.to_owned());
}
if let Some((x_str, y_str)) = s.split_once(',')
&& let (Ok(x), Ok(y)) = (x_str.trim().parse::<i32>(), y_str.trim().parse::<i32>())
{
return Target::Coords(x, y);
}
Target::Selector(s.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_target_ref() {
assert_eq!(parse_target("@e1"), Target::Ref("e1".to_owned()));
assert_eq!(parse_target("@e42"), Target::Ref("e42".to_owned()));
}
#[test]
fn test_parse_target_selector() {
assert_eq!(parse_target("#submit-btn"), Target::Selector("#submit-btn".to_owned()));
assert_eq!(parse_target(".class"), Target::Selector(".class".to_owned()));
}
#[test]
fn test_parse_target_coords() {
assert_eq!(parse_target("100,200"), Target::Coords(100, 200));
assert_eq!(parse_target("0, 0"), Target::Coords(0, 0));
}
#[test]
fn test_parse_target_invalid_coords_as_selector() {
assert_eq!(parse_target("abc,def"), Target::Selector("abc,def".to_owned()));
}
#[test]
fn test_parse_diff_command() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "diff"]);
assert!(matches!(cli.command, Command::Diff { r#ref: None, interactive: false, selector: None, depth: None }));
}
#[test]
fn test_parse_diff_with_ref() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "diff", "--ref", "/tmp/snap.json"]);
if let Command::Diff { r#ref: Some(path), .. } = cli.command {
assert_eq!(path, std::path::PathBuf::from("/tmp/snap.json"));
} else {
panic!("Expected Diff command with ref");
}
}
#[test]
fn test_parse_assert_text() {
let cli =
Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "assert", "text", "@e1", "Dashboard"]);
if let Command::Assert(AssertKind::Text { target, expected }) = cli.command {
assert_eq!(target, "@e1");
assert_eq!(expected, "Dashboard");
} else {
panic!("Expected Assert Text command");
}
}
#[test]
fn test_parse_assert_visible() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "assert", "visible", "#submit"]);
if let Command::Assert(AssertKind::Visible { target }) = cli.command {
assert_eq!(target, "#submit");
} else {
panic!("Expected Assert Visible command");
}
}
#[test]
fn test_parse_assert_count() {
let cli =
Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "assert", "count", ".list-item", "5"]);
if let Command::Assert(AssertKind::Count { selector, expected }) = cli.command {
assert_eq!(selector, ".list-item");
assert_eq!(expected, 5);
} else {
panic!("Expected Assert Count command");
}
}
#[test]
fn test_parse_assert_url() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "assert", "url", "/dashboard"]);
if let Command::Assert(AssertKind::Url { expected }) = cli.command {
assert_eq!(expected, "/dashboard");
} else {
panic!("Expected Assert Url command");
}
}
#[test]
fn test_parse_watch_command() {
let cli = Cli::parse_from([
"tauri-hasgard",
"--socket",
"/tmp/test.sock",
"watch",
"--selector",
".results",
"--timeout",
"5000",
"--stable",
"500",
]);
if let Command::Watch { selector, timeout, stable, require_mutation } = cli.command {
assert_eq!(selector, Some(".results".to_owned()));
assert_eq!(timeout, 5000);
assert_eq!(stable, 500);
assert!(!require_mutation);
} else {
panic!("Expected Watch command");
}
}
#[test]
fn test_parse_watch_defaults() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "watch"]);
if let Command::Watch { selector, timeout, stable, require_mutation } = cli.command {
assert_eq!(selector, None);
assert_eq!(timeout, 10000);
assert_eq!(stable, 300);
assert!(!require_mutation);
} else {
panic!("Expected Watch command");
}
}
#[test]
fn test_parse_watch_require_mutation() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "watch", "--require-mutation"]);
if let Command::Watch { require_mutation, .. } = cli.command {
assert!(require_mutation);
} else {
panic!("Expected Watch command");
}
}
#[test]
fn test_parse_watch_require_mutation_with_selector() {
let cli = Cli::parse_from([
"tauri-hasgard",
"--socket",
"/tmp/test.sock",
"watch",
"--selector",
"#root",
"--require-mutation",
"--stable",
"500",
]);
if let Command::Watch { selector, stable, require_mutation, .. } = cli.command {
assert_eq!(selector, Some("#root".to_owned()));
assert_eq!(stable, 500);
assert!(require_mutation);
} else {
panic!("Expected Watch command");
}
}
#[test]
fn test_parse_drag_to_element() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "drag", "@e5", "@e6"]);
assert!(matches!(cli.command, Command::Drag { ref source, target: Some(_), .. } if source == "@e5"));
}
#[test]
fn test_parse_drag_with_offset() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "drag", "@e5", "--offset", "0,100"]);
assert!(
matches!(cli.command, Command::Drag { ref source, offset: Some(ref off), .. } if source == "@e5" && off == "0,100")
);
}
#[test]
fn test_parse_drop_with_file() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "drop", "@e3", "--file", "test.png"]);
assert!(matches!(cli.command, Command::Drop { ref target, .. } if target == "@e3"));
}
#[test]
fn test_parse_drop_multiple_files() {
let cli = Cli::parse_from([
"tauri-hasgard",
"--socket",
"/tmp/t.sock",
"drop",
"@e3",
"--file",
"a.png",
"--file",
"b.txt",
]);
if let Command::Drop { file, .. } = cli.command {
assert_eq!(file.len(), 2);
} else {
panic!("expected Drop command");
}
}
#[test]
fn test_parse_drag_rejects_both_target_and_offset() {
let result = Cli::try_parse_from([
"tauri-hasgard",
"--socket",
"/tmp/t.sock",
"drag",
"@e5",
"@e6",
"--offset",
"0,100",
]);
assert!(result.is_err());
}
#[test]
fn test_parse_drop_requires_file() {
let result = Cli::try_parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "drop", "@e3"]);
assert!(result.is_err());
}
#[test]
fn test_parse_storage_get() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "storage", "get", "auth_token"]);
if let Command::Storage(StorageArgs { session, action: StorageAction::Get { key } }) = cli.command {
assert!(!session);
assert_eq!(key, "auth_token");
} else {
panic!("Expected Storage Get command");
}
}
#[test]
fn test_parse_storage_set() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "storage", "set", "theme", "dark"]);
if let Command::Storage(StorageArgs { session, action: StorageAction::Set { key, value } }) = cli.command {
assert!(!session);
assert_eq!(key, "theme");
assert_eq!(value, "dark");
} else {
panic!("Expected Storage Set command");
}
}
#[test]
fn test_parse_storage_list_session() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "storage", "--session", "list"]);
if let Command::Storage(StorageArgs { session, action: StorageAction::List }) = cli.command {
assert!(session);
} else {
panic!("Expected Storage List command with session flag");
}
}
#[test]
fn test_parse_storage_clear() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/t.sock", "storage", "clear"]);
assert!(matches!(cli.command, Command::Storage(StorageArgs { action: StorageAction::Clear, .. })));
}
#[test]
fn test_parse_forms_command() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "forms"]);
if let Command::Forms(FormsArgs { selector }) = cli.command {
assert_eq!(selector, None);
} else {
panic!("Expected Forms command");
}
}
#[test]
fn test_parse_forms_with_selector() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "forms", "--selector", "#login"]);
if let Command::Forms(FormsArgs { selector }) = cli.command {
assert_eq!(selector, Some("#login".to_owned()));
} else {
panic!("Expected Forms command with selector");
}
}
#[test]
fn test_parse_record_start() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "record", "start"]);
assert!(matches!(cli.command, Command::Record { action: RecordAction::Start }));
}
#[test]
fn test_parse_record_stop_with_output() {
let cli =
Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "record", "stop", "--output", "test.json"]);
if let Command::Record { action: RecordAction::Stop { output } } = cli.command {
assert_eq!(output, std::path::PathBuf::from("test.json"));
} else {
panic!("Expected Record Stop command with output");
}
}
#[test]
fn test_parse_record_stop_requires_output() {
let result = Cli::try_parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "record", "stop"]);
assert!(result.is_err());
}
#[test]
fn test_parse_replay_with_export() {
let cli =
Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "replay", "test.json", "--export", "sh"]);
if let Command::Replay { path, export } = cli.command {
assert_eq!(path, std::path::PathBuf::from("test.json"));
assert_eq!(export, Some("sh".to_owned()));
} else {
panic!("Expected Replay command with export");
}
}
#[test]
fn test_windows_command() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "windows"]);
assert!(matches!(cli.command, Command::Windows));
}
#[test]
fn test_parse_screenshot_native_command() {
let cli = Cli::parse_from([
"tauri-hasgard",
"--socket",
"/tmp/test.sock",
"screenshot_native",
"--window-id",
"42",
"--output",
"/tmp/out.png",
]);
if let Command::ScreenshotNative { window_id, output, format } = cli.command {
assert_eq!(window_id, 42);
assert_eq!(output, std::path::PathBuf::from("/tmp/out.png"));
assert_eq!(format, "png");
} else {
panic!("Expected ScreenshotNative command");
}
}
#[test]
fn test_mcp_command() {
let cli = Cli::parse_from(["tauri-hasgard", "mcp"]);
assert!(matches!(cli.command, Command::Mcp));
}
#[test]
fn test_window_flag_with_command() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "--window", "settings", "snapshot"]);
assert_eq!(cli.window, Some("settings".to_owned()));
assert!(matches!(cli.command, Command::Snapshot { .. }));
}
#[test]
#[serial_test::serial]
fn test_window_flag_env() {
unsafe {
std::env::set_var("TAURI_HASGARD_WINDOW", "main");
}
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "ping"]);
assert_eq!(cli.window, Some("main".to_owned()));
unsafe {
std::env::remove_var("TAURI_HASGARD_WINDOW");
}
}
#[test]
fn test_parse_eval_with_script_containing_quotes() {
let script = r#"document.querySelector('[data-id="main"]').textContent"#;
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "eval", script]);
if let Command::Eval { script: parsed } = cli.command {
assert_eq!(parsed, Some(script.to_owned()));
} else {
panic!("Expected Eval command");
}
}
#[test]
fn test_parse_eval_dash_reads_stdin() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "eval", "-"]);
if let Command::Eval { script } = cli.command {
assert_eq!(script, Some("-".to_owned()));
} else {
panic!("Expected Eval command with dash");
}
}
#[test]
fn test_parse_eval_no_arg_reads_stdin() {
let cli = Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "eval"]);
if let Command::Eval { script } = cli.command {
assert_eq!(script, None);
} else {
panic!("Expected Eval command with no script");
}
}
#[test]
fn test_parse_snapshot_with_save() {
let cli =
Cli::parse_from(["tauri-hasgard", "--socket", "/tmp/test.sock", "snapshot", "--save", "/tmp/snap.json"]);
if let Command::Snapshot { save: Some(path), .. } = cli.command {
assert_eq!(path, std::path::PathBuf::from("/tmp/snap.json"));
} else {
panic!("Expected Snapshot command with save");
}
}
}