crates/focuser-cli/tests/cli.rs
Rama master · réplica de lectura del repositorio original.
//! End-to-end tests that run the real `focuser` binary against a scratch database.
//!
//! These are the tests that replace manual release checking. They exercise the
//! same `execute()` the GUI calls, so a scenario passing here is evidence about
//! the shipped application, not about a mock.
//!
//! `CARGO_BIN_EXE_focuser-cli` is set by Cargo for integration tests, so the
//! binary under test is always the one just built.
use std::path::Path;
use std::process::{Command, Output};
use tempfile::TempDir;
/// A CLI invocation against an isolated database.
struct Cli {
_dir: TempDir,
db: std::path::PathBuf,
}
impl Cli {
fn new() -> Self {
let dir = TempDir::new().expect("temp dir");
let db = dir.path().join("test.db");
Self { _dir: dir, db }
}
fn raw(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_focuser-cli"))
.arg("--db")
.arg(&self.db)
.args(args)
.output()
.expect("failed to run focuser-cli")
}
/// Run, expecting success. Returns trimmed stdout.
fn ok(&self, args: &[&str]) -> String {
let out = self.raw(args);
assert!(
out.status.success(),
"expected `{}` to succeed, got exit {:?}\nstderr: {}",
args.join(" "),
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
/// Run, expecting failure. Returns (exit code, trimmed stderr).
fn fail(&self, args: &[&str]) -> (i32, String) {
let out = self.raw(args);
assert!(
!out.status.success(),
"expected `{}` to fail, but it succeeded",
args.join(" ")
);
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr).trim().to_string(),
)
}
/// Run with `--json` and parse the result.
fn json(&self, args: &[&str]) -> serde_json::Value {
let mut with_json = vec!["--json"];
with_json.extend_from_slice(args);
serde_json::from_str(&self.ok(&with_json)).expect("stdout was not valid JSON")
}
/// Create a list and return its id.
fn make_list(&self, name: &str) -> String {
self.json(&["list", "create", name])["data"]["id"]
.as_str()
.expect("created list should carry an id")
.to_string()
}
}
#[test]
fn creating_a_list_makes_it_appear_in_ls() {
let cli = Cli::new();
let id = cli.make_list("Social");
let lists = cli.json(&["list", "ls"]);
let data = lists["data"].as_array().unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0]["id"], id);
assert_eq!(data[0]["name"], "Social");
assert_eq!(data[0]["enabled"], true);
}
#[test]
fn json_output_is_machine_readable_and_human_output_is_not_required_to_be() {
let cli = Cli::new();
cli.make_list("Social");
// The contract scripts rely on: `--json` parses, and carries a discriminant.
let result = cli.json(&["list", "ls"]);
assert_eq!(result["kind"], "block_lists");
// Human output must at least mention the name; its exact shape is not a
// stable interface and is deliberately not asserted further.
assert!(cli.ok(&["list", "ls"]).contains("Social"));
}
#[test]
fn exit_codes_distinguish_failure_kinds() {
let cli = Cli::new();
let missing = "00000000-0000-0000-0000-000000000000";
// Validation — the caller sent something invalid.
let (code, stderr) = cli.fail(&["list", "create", " "]);
assert_eq!(code, 2, "validation should exit 2");
assert!(stderr.starts_with("validation:"), "got: {stderr}");
// Not found — the caller referenced something that does not exist.
let (code, stderr) = cli.fail(&["list", "rm", missing]);
assert_eq!(code, 4, "not-found should exit 4");
assert!(stderr.starts_with("block_list_not_found:"), "got: {stderr}");
}
#[test]
fn a_missing_rule_is_reported_rather_than_silently_succeeding() {
let cli = Cli::new();
let list = cli.make_list("Sites");
let missing = "00000000-0000-0000-0000-000000000000";
// The pre-rewrite command used Vec::retain and returned success here.
let (code, stderr) = cli.fail(&["site", "rm", &list, missing]);
assert_eq!(code, 4);
assert!(stderr.contains("rule_not_found"), "got: {stderr}");
}
#[test]
fn full_website_rule_lifecycle() {
let cli = Cli::new();
let list = cli.make_list("Sites");
let rule = cli.json(&["site", "add", &list, "example.com"])["data"]["id"]
.as_str()
.unwrap()
.to_string();
let after_add = cli.json(&["list", "ls"]);
assert_eq!(
after_add["data"][0]["websites"].as_array().unwrap().len(),
1
);
cli.ok(&["site", "rm", &list, &rule]);
let after_rm = cli.json(&["list", "ls"]);
assert!(
after_rm["data"][0]["websites"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn bulk_import_reports_how_many_values_it_accepted() {
let cli = Cli::new();
let list = cli.make_list("Sites");
let added = cli.json(&[
"site",
"import",
&list,
"example.com",
"EXAMPLE.com", // duplicate once normalised
"# comment",
"",
"other.com",
]);
assert_eq!(added["kind"], "count");
assert_eq!(added["data"], 2);
}
#[test]
fn bulk_import_reads_a_file() {
let cli = Cli::new();
let list = cli.make_list("Sites");
let path = cli.db.parent().unwrap().join("domains.txt");
std::fs::write(&path, "one.com\n# a comment\n\ntwo.com\n").unwrap();
let added = cli.json(&["site", "import", &list, "--file", path.to_str().unwrap()]);
assert_eq!(added["data"], 2);
}
#[test]
fn toggling_a_list_persists_across_invocations() {
let cli = Cli::new();
let id = cli.make_list("Social");
cli.ok(&["list", "disable", &id]);
assert_eq!(cli.json(&["list", "ls"])["data"][0]["enabled"], false);
cli.ok(&["list", "enable", &id]);
assert_eq!(cli.json(&["list", "ls"])["data"][0]["enabled"], true);
}
#[test]
fn protection_prevents_modification_and_is_reported_by_status() {
let cli = Cli::new();
let id = cli.make_list("Committed");
cli.ok(&["protect", "enable", &id, "--minutes", "60"]);
let (code, stderr) = cli.fail(&["list", "disable", &id]);
assert_eq!(code, 5, "protected should exit 5");
assert!(stderr.starts_with("protected:"), "got: {stderr}");
// Deleting a protected list must be refused too — this is the path the old
// CLI bypassed entirely by using SetBlockListEnabled.
assert_eq!(cli.fail(&["list", "rm", &id]).0, 5);
let status = cli.json(&["protect", "status"]);
assert_eq!(status["data"].as_array().unwrap().len(), 1);
assert_eq!(status["data"][0]["block_list_id"], id);
assert_eq!(
status["data"][0]["prevent_modification"], true,
"protection must default to actually protecting"
);
}
#[test]
fn protection_opt_outs_are_honoured() {
let cli = Cli::new();
let id = cli.make_list("Partly locked");
cli.ok(&[
"protect",
"enable",
&id,
"--minutes",
"60",
"--allow-modification",
]);
let info = &cli.json(&["protect", "status"])["data"][0];
assert_eq!(info["prevent_modification"], false);
assert_eq!(info["prevent_uninstall"], true, "others stay on");
// With modification permitted, editing the list is allowed again.
cli.ok(&["site", "add", &id, "example.com"]);
}
#[test]
fn schedule_slots_parse_and_round_trip() {
let cli = Cli::new();
let id = cli.make_list("Work");
cli.ok(&[
"schedule",
"set",
&id,
"--slot",
"mon:09:00-17:00",
"--slot",
"sat:9-12",
]);
let schedule = &cli.json(&["list", "ls"])["data"][0]["schedule"];
let slots = schedule["time_slots"].as_array().unwrap();
assert_eq!(slots.len(), 2);
assert_eq!(slots[0]["day"], "Mon");
cli.ok(&["schedule", "set", &id, "--always"]);
assert!(cli.json(&["list", "ls"])["data"][0]["schedule"].is_null());
}
#[test]
fn an_unparseable_slot_is_rejected_before_touching_the_database() {
let cli = Cli::new();
let id = cli.make_list("Work");
let (code, stderr) = cli.fail(&["schedule", "set", &id, "--slot", "funday:9-17"]);
// clap rejects the value, so this never reaches execute().
assert_eq!(code, 2, "clap usage errors exit 2");
assert!(stderr.contains("funday"), "error should name the bad value");
}
#[test]
fn settings_distinguish_unset_from_empty() {
let cli = Cli::new();
let unset = cli.json(&["setting", "get", "theme"]);
assert!(unset["data"].is_null(), "unset should be null, not \"\"");
cli.ok(&["setting", "set", "theme", ""]);
let empty = cli.json(&["setting", "get", "theme"]);
assert_eq!(empty["data"], "", "an explicitly empty value is not null");
}
#[test]
fn allowance_lifecycle_and_validation() {
let cli = Cli::new();
let (code, _) = cli.fail(&[
"allowance",
"create",
"youtube.com",
"--limit-secs",
"30", // below the 60s minimum
]);
assert_eq!(code, 2);
let created = cli.json(&[
"allowance",
"create",
"youtube.com",
"--limit-secs",
"600",
"--strict",
]);
let id = created["data"]["id"].as_str().unwrap().to_string();
let listed = cli.json(&["allowance", "ls"]);
assert_eq!(listed["data"].as_array().unwrap().len(), 1);
assert_eq!(listed["data"][0]["remaining_secs"], 600);
cli.ok(&["allowance", "rm", &id]);
assert!(
cli.json(&["allowance", "ls"])["data"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn pomodoro_reports_idle_then_running() {
let cli = Cli::new();
let list = cli.make_list("Focus");
assert!(cli.json(&["pomodoro", "status"])["data"].is_null());
cli.ok(&["pomodoro", "start", &list]);
let status = cli.json(&["pomodoro", "status"]);
assert_eq!(status["data"]["block_list_name"], "Focus");
assert_eq!(status["data"]["paused"], false);
assert_eq!(cli.json(&["pomodoro", "pause"])["data"], true);
assert_eq!(cli.json(&["pomodoro", "stop"])["data"], true);
// Stopping again is a no-op and must report that rather than claiming success.
assert_eq!(cli.json(&["pomodoro", "stop"])["data"], false);
}
#[test]
fn stats_reject_a_backwards_date_range() {
let cli = Cli::new();
let (code, stderr) = cli.fail(&[
"stats",
"show",
"--from",
"2026-05-10",
"--to",
"2026-05-01",
]);
assert_eq!(code, 2);
assert!(stderr.contains("after"), "got: {stderr}");
}
#[test]
fn stats_retention_round_trips() {
let cli = Cli::new();
assert_eq!(cli.json(&["stats", "retention"])["data"], 30);
cli.ok(&["stats", "set-retention", "7"]);
assert_eq!(cli.json(&["stats", "retention"])["data"], 7);
assert_eq!(cli.fail(&["stats", "set-retention", "0"]).0, 2);
}
#[test]
fn clear_all_skips_protected_lists() {
let cli = Cli::new();
let open = cli.make_list("Open");
let locked = cli.make_list("Locked");
for id in [&open, &locked] {
cli.ok(&["site", "add", id, "x.com"]);
}
cli.ok(&["protect", "enable", &locked, "--minutes", "60"]);
// One locked list must not fail the whole bulk operation.
assert_eq!(cli.json(&["site", "clear"])["data"], 1);
}
#[test]
fn the_database_flag_isolates_runs_from_each_other() {
let a = Cli::new();
let b = Cli::new();
a.make_list("Only in A");
assert_eq!(a.json(&["list", "ls"])["data"].as_array().unwrap().len(), 1);
assert!(
b.json(&["list", "ls"])["data"]
.as_array()
.unwrap()
.is_empty(),
"each --db must be a separate database, or tests would interfere"
);
}
/// Guard against a scenario file drifting from the binary's actual surface.
#[test]
fn every_top_level_subcommand_is_reachable() {
let cli = Cli::new();
let help = String::from_utf8_lossy(&cli.raw(&["--help"]).stdout).to_string();
for expected in [
"list",
"site",
"app",
"exception",
"schedule",
"stats",
"protect",
"setting",
"blocks",
"pomodoro",
"allowance",
] {
assert!(
help.contains(expected),
"`{expected}` missing from --help output"
);
}
}
/// The binary must not need a real Focuser install to run.
#[test]
fn runs_against_a_database_that_does_not_exist_yet() {
let dir = TempDir::new().unwrap();
let db = dir.path().join("nested").join("fresh.db");
std::fs::create_dir_all(db.parent().unwrap()).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_focuser-cli"))
.arg("--db")
.arg(&db)
.args(["list", "ls"])
.output()
.unwrap();
assert!(
out.status.success(),
"a fresh database should be created, got: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(Path::new(&db).exists(), "database file should be created");
}
// ─── Whole configuration ────────────────────────────────────────────
#[test]
fn export_then_import_round_trips_through_a_file() {
let source = Cli::new();
let id = source.make_list("Social");
source.ok(&["site", "add", &id, "reddit.com"]);
let exported = source.ok(&["config", "export"]);
let target = Cli::new();
target.make_list("Replaced");
let path = target._dir.path().join("config.json");
std::fs::write(&path, &exported).unwrap();
target.ok(&["config", "import", path.to_str().unwrap()]);
let lists = target.json(&["list", "ls"]);
let data = lists["data"].as_array().unwrap();
assert_eq!(data.len(), 1, "import replaces rather than merges");
assert_eq!(data[0]["name"], "Social");
assert_eq!(data[0]["websites"].as_array().unwrap().len(), 1);
}
#[test]
fn wipe_refuses_without_an_explicit_yes() {
let cli = Cli::new();
cli.make_list("Keep me");
let (code, stderr) = cli.fail(&["config", "wipe"]);
assert_ne!(code, 0);
assert!(
stderr.contains("--yes"),
"stderr should say what is missing"
);
assert_eq!(
cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
1
);
}
#[test]
fn wipe_with_yes_removes_everything() {
let cli = Cli::new();
cli.make_list("Gone");
cli.ok(&["config", "wipe", "--yes"]);
assert!(
cli.json(&["list", "ls"])["data"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn import_of_a_junk_file_fails_as_validation_and_changes_nothing() {
let cli = Cli::new();
cli.make_list("Keep me");
let path = cli._dir.path().join("junk.json");
std::fs::write(&path, "this is not a configuration").unwrap();
let (code, _) = cli.fail(&["config", "import", path.to_str().unwrap()]);
assert_eq!(code, 2, "caller error, not a backend fault");
assert_eq!(
cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
1
);
}
#[test]
fn wipe_and_import_are_refused_while_a_list_is_locked() {
let cli = Cli::new();
let id = cli.make_list("Locked");
cli.ok(&["protect", "enable", &id, "--minutes", "60"]);
let path = cli._dir.path().join("empty.json");
std::fs::write(
&path,
r#"{"version":1,"app":"Focuser","exported_at":"2026-07-27T00:00:00Z","block_lists":[]}"#,
)
.unwrap();
assert_eq!(cli.fail(&["config", "wipe", "--yes"]).0, 5);
assert_eq!(cli.fail(&["config", "import", path.to_str().unwrap()]).0, 5);
assert_eq!(
cli.json(&["list", "ls"])["data"].as_array().unwrap().len(),
1
);
}
// ─── Diagnostics ────────────────────────────────────────────────────
#[test]
fn a_lock_with_no_end_names_the_flags_it_needs() {
let cli = Cli::new();
let id = cli.make_list("Forever");
let (code, error) = cli.fail(&["protect", "enable", &id, "--until-unlocked"]);
assert_eq!(code, 2);
assert!(
error.contains("--password") && error.contains("--random-text-length"),
"got: {error}"
);
cli.ok(&["list", "disable", &id]);
}
#[test]
fn a_lock_with_no_end_says_so_and_ends_with_its_password() {
let cli = Cli::new();
let id = cli.make_list("Forever");
cli.ok(&[
"protect",
"enable",
&id,
"--until-unlocked",
"--password",
"open-sesame",
]);
assert!(cli.ok(&["protect", "status"]).contains("until unlocked"));
assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);
cli.ok(&["protect", "unlock", &id, "open-sesame"]);
cli.ok(&["list", "disable", &id]);
}
#[test]
fn explicit_password_arguments_still_enable_and_unlock_protection() {
let cli = Cli::new();
let id = cli.make_list("Password protected");
let password = "compatibility-test-secret";
cli.ok(&["protect", "enable", &id, "--password", password]);
let wrong = cli.raw(&["protect", "unlock", &id, "wrong-secret"]);
assert!(!wrong.status.success());
assert!(!String::from_utf8_lossy(&wrong.stderr).contains("wrong-secret"));
assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);
cli.ok(&["protect", "unlock", &id, password]);
cli.ok(&["list", "disable", &id]);
}
#[test]
fn password_prompt_without_a_terminal_does_not_enable_protection() {
let cli = Cli::new();
let id = cli.make_list("Interactive only");
let (code, error) = cli.fail(&["protect", "enable", &id, "--password"]);
assert_eq!(code, 1);
assert!(error.contains("interactive terminal"), "got: {error}");
cli.ok(&["list", "disable", &id]);
}
#[test]
fn unlock_prompt_without_a_terminal_preserves_protection_and_json_stdout() {
let cli = Cli::new();
let id = cli.make_list("Still protected");
cli.ok(&["protect", "enable", &id, "--password", "test-secret"]);
let out = cli.raw(&["--json", "protect", "unlock", &id]);
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty());
assert!(String::from_utf8_lossy(&out.stderr).contains("interactive terminal"));
assert_eq!(cli.fail(&["list", "disable", &id]).0, 5);
}
#[test]
fn prompted_password_conflicts_with_random_text() {
let cli = Cli::new();
let id = cli.make_list("One unlock method");
let (code, error) = cli.fail(&[
"protect",
"enable",
&id,
"--password",
"--random-text-length",
"20",
]);
assert_eq!(code, 2);
assert!(error.contains("cannot be used with"), "got: {error}");
cli.ok(&["list", "disable", &id]);
}
#[test]
fn check_reports_whether_a_domain_is_blocked() {
let cli = Cli::new();
let id = cli.make_list("Social");
cli.ok(&["site", "add", &id, "reddit.com"]);
assert_eq!(cli.json(&["check", "reddit.com"])["data"], true);
assert_eq!(cli.json(&["check", "www.reddit.com"])["data"], true);
assert_eq!(cli.json(&["check", "example.com"])["data"], false);
cli.ok(&["list", "disable", &id]);
assert_eq!(cli.json(&["check", "reddit.com"])["data"], false);
}
#[test]
fn browsers_lists_every_known_browser() {
let cli = Cli::new();
let browsers = cli.json(&["browsers"]);
assert_eq!(browsers["kind"], "browser_status");
assert!(!browsers["data"].as_array().unwrap().is_empty());
}
#[test]
fn version_matches_the_binary() {
let cli = Cli::new();
assert_eq!(cli.ok(&["version"]), env!("CARGO_PKG_VERSION"));
}
#[test]
fn a_locked_focus_session_cannot_be_stopped_from_the_command_line() {
let cli = Cli::new();
let list = cli.json(&["list", "create", "Focus"])["data"]["id"]
.as_str()
.expect("list id")
.to_string();
cli.ok(&["pomodoro", "start", &list, "--lock-rounds", "2"]);
assert!(
cli.ok(&["pomodoro", "status"])
.contains("Locked for 2 more rounds")
);
for refused in ["stop", "skip", "pause"] {
let (code, stderr) = cli.fail(&["pomodoro", refused]);
assert_eq!(
code, 5,
"`pomodoro {refused}` should be refused as protected"
);
assert!(stderr.starts_with("protected:"), "got: {stderr}");
}
let (code, _) = cli.fail(&["pomodoro", "start", &list, "--lock-rounds", "13"]);
assert_eq!(code, 2, "more than 12 rounds is invalid");
}