diff --git a/docs/plans/2026-03-09-add-restart-command-test-plan.md b/docs/plans/2026-03-09-add-restart-command-test-plan.md new file mode 100644 index 0000000..c47a936 --- /dev/null +++ b/docs/plans/2026-03-09-add-restart-command-test-plan.md @@ -0,0 +1,63 @@ +# Test Plan: `devproxy restart` command + +## Strategy reconciliation + +The implementation plan describes a thin wrapper around `platform::restart_daemon()` with three touchpoints: CLI parsing (`cli.rs`), command dispatch (`main.rs`), and the command module (`commands/restart.rs`). The agreed medium-fidelity strategy assumed exactly this shape and holds without modification. No external dependencies, no new harnesses needed. + +## Test plan + +### 1. `test_restart_no_daemon` — restart reports error when no platform daemon exists + +- **Name**: Restart without a platform-managed daemon reports error and exits non-zero +- **Type**: scenario +- **Harness**: Direct binary invocation with `DEVPROXY_NO_SOCKET_ACTIVATION=1` +- **Preconditions**: No platform-managed daemon (launchd/systemd) is running. Socket activation is disabled via env var. A valid config dir with `config.json` exists. +- **Actions**: Run `devproxy restart` with isolated config dir and `DEVPROXY_NO_SOCKET_ACTIVATION=1`. +- **Expected outcome**: Exit code is non-zero. Stderr contains "no platform-managed daemon found". Source of truth: `commands/restart.rs` prints this message when `restart_daemon()` returns `Ok(false)`, and the implementation plan specifies this behavior. +- **Interactions**: `platform::restart_daemon()` checks env var and returns `Ok(false)` without touching launchd/systemd. + +### 2. `test_restart_running_daemon` — restart reports error when daemon is running but not platform-managed + +- **Name**: Restart with a running non-platform daemon still reports no platform daemon +- **Type**: scenario +- **Harness**: Test daemon harness (existing `start_test_daemon`) + direct binary invocation +- **Preconditions**: A daemon is running on an ephemeral port via direct spawn (not launchd/systemd). `DEVPROXY_NO_SOCKET_ACTIVATION=1` is set. +- **Actions**: Start a test daemon. Run `devproxy restart` against the same config dir. +- **Expected outcome**: Exit code is non-zero. Stderr contains "no platform-managed daemon found". The running daemon is unaffected. Source of truth: implementation plan states "run restart, verify it reports 'no platform-managed daemon'" for this scenario. +- **Interactions**: Tests that `restart` does not accidentally kill a non-platform-managed daemon process. + +### 3. `test_parse_restart_command` — CLI parses "restart" subcommand + +- **Name**: `devproxy restart` parses as the Restart command variant +- **Type**: unit +- **Harness**: In-process clap parsing via `Cli::try_parse_from` +- **Preconditions**: None. +- **Actions**: Call `Cli::try_parse_from(["devproxy", "restart"])`. +- **Expected outcome**: Parsing succeeds. Returned `Commands` variant matches `Commands::Restart`. Source of truth: implementation plan specifies `Restart` variant with no arguments. +- **Interactions**: None. + +### 4. `test_cli_help` — help output includes restart (existing test, extended) + +- **Name**: Help output lists the restart command +- **Type**: boundary +- **Harness**: Direct binary invocation +- **Preconditions**: Binary is built. +- **Actions**: Run `devproxy --help`. +- **Expected outcome**: Stdout contains "restart". Source of truth: the `Restart` variant in `Commands` enum has no `#[command(hide = true)]` attribute, so clap will list it. +- **Interactions**: None beyond clap help generation. + +## Coverage summary + +**Covered:** +- User-visible restart command (both success-path messaging and error-path messaging) +- CLI parsing of the `restart` subcommand +- Behavior when daemon is running but not platform-managed +- Behavior when no daemon exists at all +- Discoverability via `--help` + +**Explicitly excluded per agreed strategy:** +- Actual launchd/systemd restart (would require platform-managed daemon installation, which is impractical in CI and covered by the existing `platform::restart_daemon()` unit-level logic) +- Testing on Linux (systemd path) -- covered by existing `tests/linux-docker/` infrastructure for the `update` command which exercises the same `restart_daemon()` codepath + +**Residual risk:** +- Low. The command is a direct delegation to `platform::restart_daemon()` which is already exercised by the `update` command's e2e tests. The only new code is the 20-line `commands/restart.rs` wrapper. diff --git a/src/cli.rs b/src/cli.rs index d0ec02b..0bdcdcb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -37,6 +37,8 @@ pub enum Commands { Open, /// Show daemon health and active route count Status, + /// Restart the daemon + Restart, /// Check for updates and self-update the binary Update, /// Run the proxy daemon (internal, hidden) @@ -47,3 +49,17 @@ pub enum Commands { port: u16, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_restart_command() { + let cli = Cli::try_parse_from(["devproxy", "restart"]).expect("should parse restart"); + assert!( + matches!(cli.command, Commands::Restart), + "should parse as Restart variant" + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f8bdd6a..99093be 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod get_url; pub mod init; pub mod ls; pub mod open; +pub mod restart; pub mod status; pub mod up; pub mod update; diff --git a/src/commands/restart.rs b/src/commands/restart.rs new file mode 100644 index 0000000..32e8c3d --- /dev/null +++ b/src/commands/restart.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use colored::Colorize; + +pub fn run() -> Result<()> { + match crate::platform::restart_daemon() { + Ok(true) => { + eprintln!("{} daemon restarted", "ok:".green()); + Ok(()) + } + Ok(false) => { + eprintln!( + "{} no platform-managed daemon found. Run {} to set one up", + "error:".red(), + "devproxy init".bold() + ); + std::process::exit(1); + } + Err(e) => Err(e), + } +} diff --git a/src/main.rs b/src/main.rs index 280b328..302be11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ async fn main() -> anyhow::Result<()> { Commands::Ls => commands::ls::run().await, Commands::Open => commands::open::run().await, Commands::Status => commands::status::run().await, + Commands::Restart => commands::restart::run(), Commands::Update => commands::update::run().await, Commands::Daemon { port } => commands::daemon::run(port).await, } diff --git a/tests/e2e.rs b/tests/e2e.rs index 24f9a4b..6f3586c 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -220,6 +220,10 @@ fn test_cli_help() { assert!(stdout.contains("down")); assert!(stdout.contains("ls")); assert!(stdout.contains("status")); + assert!( + stdout.contains("restart"), + "help should list the restart command" + ); assert!( stdout.contains("update"), "help should list the update command" @@ -338,6 +342,66 @@ fn test_status_without_daemon() { let _ = std::fs::remove_dir_all(&config_dir); } +#[test] +fn test_restart_no_daemon() { + // Without a platform-managed daemon (DEVPROXY_NO_SOCKET_ACTIVATION=1), + // restart should report that no daemon was found and exit non-zero. + let config_dir = + std::env::temp_dir().join(format!("devproxy-restart-nodaemon-{}", std::process::id())); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.json"), + format!(r#"{{"domain":"{TEST_DOMAIN}"}}"#), + ) + .unwrap(); + + let output = Command::new(devproxy_bin()) + .args(["restart"]) + .env("DEVPROXY_CONFIG_DIR", &config_dir) + .env("DEVPROXY_NO_SOCKET_ACTIVATION", "1") + .output() + .expect("failed to run restart"); + + assert!( + !output.status.success(), + "restart without a platform-managed daemon should exit non-zero" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no platform-managed daemon found"), + "should report no daemon found: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&config_dir); +} + +#[test] +fn test_restart_running_daemon() { + // Start a daemon with DEVPROXY_NO_SOCKET_ACTIVATION=1 (no launchd/systemd). + // `restart` should still report "no platform-managed daemon" because the + // daemon is running directly, not via launchd/systemd. + let config_dir = create_test_config_dir("restart-running"); + let port = find_free_port(); + let _guard = start_test_daemon(&config_dir, port); + + let output = Command::new(devproxy_bin()) + .args(["restart"]) + .env("DEVPROXY_CONFIG_DIR", &config_dir) + .env("DEVPROXY_NO_SOCKET_ACTIVATION", "1") + .output() + .expect("failed to run restart"); + + assert!( + !output.status.success(), + "restart should exit non-zero when daemon is not platform-managed" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no platform-managed daemon found"), + "should report no platform-managed daemon: {stderr}" + ); +} + #[test] fn test_up_without_label() { let config_dir = std::env::temp_dir().join(format!("devproxy-nolabel-{}", std::process::id()));