// CLI Daemon Command tests // Catch2 migration from GTest (yams-3s4 / yams-cli) // // Tests for daemon command CLI parsing. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../../common/test_helpers_catch2.h" #ifndef _WIN32 #include #include #include #endif // Factory is declared in command registry translation unit namespace yams::cli { std::unique_ptr createDaemonCommand(); } using yams::cli::ICommand; namespace { namespace fs = std::filesystem; struct CliTestHelper { fs::path tempDir; fs::path socketPath; fs::path pidFile; std::optional configEnv; std::optional dataEnv; std::optional nonInteractiveEnv; std::optional disableDaemonEnv; CliTestHelper() { tempDir = yams::test::make_temp_dir("yams_cli_daemon_catch2_test_"); fs::create_directories(tempDir / "data"); socketPath = tempDir / "test-daemon.sock"; pidFile = tempDir / "configured-daemon.pid"; { std::ofstream cfg(tempDir / "config.toml"); cfg << "[version]\n"; cfg << "config_version = 3\n"; cfg << "[daemon]\n"; cfg << "socket_path = \"" << socketPath.string() << "\"\n"; cfg << "pid_file = \"" << pidFile.string() << "\"\n"; } configEnv.emplace("YAMS_CONFIG", (tempDir / "config.toml").string()); dataEnv.emplace("YAMS_DATA_DIR", (tempDir / "data").string()); nonInteractiveEnv.emplace(std::string("YAMS_NON_INTERACTIVE"), std::optional("1")); disableDaemonEnv.emplace(std::string("YAMS_CLI_DISABLE_DAEMON_AUTOSTART"), std::optional("1")); } ~CliTestHelper() { configEnv.reset(); dataEnv.reset(); nonInteractiveEnv.reset(); disableDaemonEnv.reset(); std::error_code ec; fs::remove_all(tempDir, ec); } int runCommand(const std::vector& args) { auto cli = std::make_unique(); std::vector argv; argv.reserve(args.size()); for (const auto& arg : args) { argv.push_back(const_cast(arg.c_str())); } return cli->run(static_cast(argv.size()), argv.data()); } }; class CaptureStdout { public: CaptureStdout() : oldCout_(std::cout.rdbuf(buffer_.rdbuf())) {} ~CaptureStdout() { std::cout.rdbuf(oldCout_); } std::string str() const { return buffer_.str(); } CaptureStdout(const CaptureStdout&) = delete; CaptureStdout& operator=(const CaptureStdout&) = delete; private: std::ostringstream buffer_; std::streambuf* oldCout_; }; } // namespace #ifndef _WIN32 struct ChildGuard { pid_t pid{-1}; ~ChildGuard() { if (pid <= 0) { return; } (void)::kill(pid, SIGKILL); int status = 0; (void)::waitpid(pid, &status, 0); } }; TEST_CASE("DaemonCommand - stale pid identity does not authorize termination", "[cli][daemon][lifecycle][catch2]") { CliTestHelper helper; const pid_t child = ::fork(); REQUIRE(child >= 0); if (child == 0) { for (;;) { ::pause(); } } ChildGuard childGuard{child}; nlohmann::json staleIdentity = { {"v", 1}, {"pid", child}, {"start_ns", std::uint64_t{1}}, {"token", "stale"}, {"exe", "/definitely/not/the/live/process"}, }; std::ofstream(helper.pidFile) << staleIdentity.dump(); CHECK_FALSE(yams::daemon::client::pidFileIdentifiesLiveDaemon(helper.pidFile, child)); CHECK(helper.runCommand({"yams", "daemon", "stop", "--force", "--socket", helper.socketPath.string(), "--pid-file", helper.pidFile.string()}) == 0); CHECK(::kill(child, 0) == 0); std::ofstream(helper.pidFile) << child; CHECK_FALSE(yams::daemon::client::pidFileIdentifiesLiveDaemon(helper.pidFile, child)); std::ofstream(helper.pidFile) << R"({"pid":18446744073709551615})"; CHECK(helper.runCommand({"yams", "daemon", "stop", "--force", "--socket", helper.socketPath.string(), "--pid-file", helper.pidFile.string()}) == 0); std::ofstream(helper.pidFile) << "123 trailing"; CHECK(helper.runCommand({"yams", "daemon", "stop", "--force", "--socket", helper.socketPath.string(), "--pid-file", helper.pidFile.string()}) == 0); } TEST_CASE("DaemonCommand - data lock metadata does not authorize termination", "[cli][daemon][lifecycle][catch2]") { CliTestHelper helper; const pid_t child = ::fork(); REQUIRE(child >= 0); if (child == 0) { ::execl("/bin/sleep", "yams-daemon", "30", nullptr); _exit(127); } ChildGuard childGuard{child}; std::this_thread::sleep_for(std::chrono::milliseconds(100)); REQUIRE(::kill(child, 0) == 0); CHECK_FALSE(yams::daemon::client::isLiveDaemonProcess(child)); nlohmann::json staleLock = { {"pid", child}, {"socket", helper.socketPath.string()}, {"timestamp", 0}, }; std::ofstream(helper.tempDir / "data" / ".yams-lock") << staleLock.dump(); CHECK(helper.runCommand({"yams", "daemon", "stop", "--force", "--socket", helper.socketPath.string(), "--pid-file", helper.pidFile.string()}) == 0); CHECK(::kill(child, 0) == 0); } #endif TEST_CASE("DaemonCommand - parses unordered flags with help without executing", "[cli][daemon][catch2]") { CLI::App app{"yams"}; auto daemonCmd = yams::cli::createDaemonCommand(); daemonCmd->registerCommand(app, /*cli*/ nullptr); std::vector argv = { "yams", "daemon", "start", "--foreground", "--log-level", "debug", "--socket", "/tmp/yams-test.sock", "--data-dir", "/tmp/yams-data", "--pid-file", "/tmp/yams.pid", "--config", "/tmp/yams.toml", "--daemon-binary", "/usr/bin/true", "--help" // ensure callback is not executed }; // Expect a help exception; parse should accept all options before emitting help bool gotHelp = false; bool gotParseError = false; std::string parseErrorMsg; try { app.parse(static_cast(argv.size()), const_cast(argv.data())); FAIL("Expected CLI::CallForHelp to be thrown"); } catch (const CLI::CallForHelp&) { gotHelp = true; } catch (const CLI::ParseError& e) { gotParseError = true; parseErrorMsg = e.what(); } CHECK(gotHelp); if (gotParseError) { FAIL("Unexpected parse error: " << parseErrorMsg); } } TEST_CASE("DaemonCommand - status reports non-running daemon cleanly", "[cli][daemon][catch2]") { CliTestHelper helper; CaptureStdout capture; const int rc = helper.runCommand({"yams", "daemon", "status", "--socket", helper.socketPath.string()}); const std::string output = capture.str(); CHECK(rc == 0); CHECK(output.find("YAMS daemon is ") != std::string::npos); CHECK(output.find("terminate") == std::string::npos); } TEST_CASE("DaemonCommand - doctor shows configured pid file", "[cli][daemon][catch2]") { CliTestHelper helper; CaptureStdout capture; const int rc = helper.runCommand({"yams", "daemon", "doctor"}); const std::string output = capture.str(); CHECK(rc == 0); CHECK(output.find(helper.pidFile.string()) != std::string::npos); } TEST_CASE("DaemonCommand - doctor prints diagnostics without daemon", "[cli][daemon][catch2]") { CliTestHelper helper; CaptureStdout capture; const int rc = helper.runCommand({"yams", "daemon", "doctor", "--socket", helper.socketPath.string()}); const std::string output = capture.str(); CHECK(rc == 0); CHECK(output.find("=== YAMS Daemon Doctor ===") != std::string::npos); CHECK(output.find("IPC & Files:") != std::string::npos); CHECK(output.find("PID Check:") != std::string::npos); } TEST_CASE("DaemonCommand - parses equals-style flags with help", "[cli][daemon][catch2]") { CLI::App app{"yams"}; auto daemonCmd = yams::cli::createDaemonCommand(); daemonCmd->registerCommand(app, /*cli*/ nullptr); std::vector argv = {"yams", "daemon", "start", "--log-level=debug", "--socket=/tmp/yams-test.sock", "--data-dir=/tmp/yams-data", "--pid-file=/tmp/yams.pid", "--config=/tmp/yams.toml", "--daemon-binary=/usr/bin/true", "--foreground", "--help"}; bool gotHelp = false; bool gotParseError = false; std::string parseErrorMsg; try { app.parse(static_cast(argv.size()), const_cast(argv.data())); FAIL("Expected CLI::CallForHelp to be thrown"); } catch (const CLI::CallForHelp&) { gotHelp = true; } catch (const CLI::ParseError& e) { gotParseError = true; parseErrorMsg = e.what(); } CHECK(gotHelp); if (gotParseError) { FAIL("Unexpected parse error: " << parseErrorMsg); } } TEST_CASE("DaemonCommand - parses status detailed UX flags", "[cli][daemon][catch2]") { CLI::App app{"yams"}; auto daemonCmd = yams::cli::createDaemonCommand(); daemonCmd->registerCommand(app, /*cli*/ nullptr); std::vector argv = { "yams", "daemon", "status", "--socket=/tmp/yams-test.sock", "-d", "--help" // ensure callback is not executed }; bool gotHelp = false; bool gotParseError = false; std::string parseErrorMsg; try { app.parse(static_cast(argv.size()), const_cast(argv.data())); FAIL("Expected CLI::CallForHelp to be thrown"); } catch (const CLI::CallForHelp&) { gotHelp = true; } catch (const CLI::ParseError& e) { gotParseError = true; parseErrorMsg = e.what(); } CHECK(gotHelp); if (gotParseError) { FAIL("Unexpected parse error: " << parseErrorMsg); } }