diff --git a/src/cli/handlers/agent/test_commands.cc b/src/cli/handlers/agent/test_commands.cc index 7168fc9c..5f8712f6 100644 --- a/src/cli/handlers/agent/test_commands.cc +++ b/src/cli/handlers/agent/test_commands.cc @@ -16,9 +16,11 @@ #include "absl/strings/ascii.h" #include "absl/strings/cord.h" #include "absl/strings/match.h" +#include "absl/strings/numbers.h" #include "absl/strings/str_format.h" #include "absl/strings/str_join.h" #include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" #include "absl/strings/strip.h" #include "absl/time/time.h" #include "cli/handlers/agent/common.h" @@ -153,6 +155,292 @@ absl::Status WriteWidgetCatalog(const DiscoverWidgetsResult& catalog, return absl::OkStatus(); } +struct ReplayCommandOptions { + std::string script_path; + std::string host = "localhost"; + int port = 50052; + bool ci_mode = false; + std::string output_format = "text"; + std::map parameters; +}; + +absl::StatusOr ParseReplayArgs( + const std::vector& args) { + ReplayCommandOptions options; + + auto parse_int = [](absl::string_view value, + const char* flag) -> absl::StatusOr { + int result = 0; + if (!absl::SimpleAtoi(value, &result)) { + return absl::InvalidArgumentError( + absl::StrCat(flag, " requires an integer value")); + } + if (result <= 0 || result > 65535) { + return absl::InvalidArgumentError( + absl::StrCat(flag, " must be between 1 and 65535")); + } + return result; + }; + + for (size_t i = 0; i < args.size(); ++i) { + const std::string& token = args[i]; + + if (token == "--ci-mode" || token == "--ci") { + options.ci_mode = true; + continue; + } + + if (token == "--host" && i + 1 < args.size()) { + options.host = args[++i]; + continue; + } + if (absl::StartsWith(token, "--host=")) { + options.host = token.substr(7); + continue; + } + + if (token == "--port" && i + 1 < args.size()) { + ASSIGN_OR_RETURN(options.port, parse_int(args[++i], "--port")); + continue; + } + if (absl::StartsWith(token, "--port=")) { + ASSIGN_OR_RETURN(options.port, + parse_int(token.substr(7), "--port")); + continue; + } + + if ((token == "--format" || token == "--output") && + i + 1 < args.size()) { + options.output_format = absl::AsciiStrToLower(args[++i]); + continue; + } + if (absl::StartsWith(token, "--format=") || + absl::StartsWith(token, "--output=")) { + options.output_format = + absl::AsciiStrToLower(token.substr(token.find('=') + 1)); + continue; + } + + if (token == "--param" && i + 1 < args.size()) { + std::string pair = args[++i]; + auto eq = pair.find('='); + if (eq == std::string::npos) { + return absl::InvalidArgumentError( + "--param expects KEY=VALUE format"); + } + options.parameters[pair.substr(0, eq)] = pair.substr(eq + 1); + continue; + } + if (absl::StartsWith(token, "--param=")) { + std::string pair = token.substr(8); + auto eq = pair.find('='); + if (eq == std::string::npos) { + return absl::InvalidArgumentError( + "--param expects KEY=VALUE format"); + } + options.parameters[pair.substr(0, eq)] = pair.substr(eq + 1); + continue; + } + + if (token == "--script" && i + 1 < args.size()) { + options.script_path = args[++i]; + continue; + } + if (absl::StartsWith(token, "--script=")) { + options.script_path = token.substr(9); + continue; + } + + if (absl::StartsWith(token, "--")) { + return absl::InvalidArgumentError( + absl::StrCat("Unknown flag for agent test replay: ", token)); + } + + if (options.script_path.empty()) { + options.script_path = token; + continue; + } + + return absl::InvalidArgumentError( + absl::StrCat("Unexpected argument: ", token)); + } + + if (options.script_path.empty()) { + return absl::InvalidArgumentError( + "Usage: agent test replay [--ci-mode] [--host ] " + "[--port ] [--format text|json] [--param KEY=VALUE]"); + } + + if (options.output_format != "text" && options.output_format != "json") { + return absl::InvalidArgumentError( + "--format must be either 'text' or 'json'"); + } + + return options; +} + +void PrintReplayTextSummary(const ReplayCommandOptions& options, + const ReplayTestResult& result) { + std::cout << "\n=== Replay Test ===\n"; + std::cout << "Script: " << options.script_path << "\n"; + std::cout << "Server: " << HarnessAddress(options.host, options.port) + << "\n"; + if (!options.parameters.empty()) { + std::cout << "Parameters:\n"; + for (const auto& [key, value] : options.parameters) { + std::cout << " • " << key << "=" << value << "\n"; + } + } + std::cout << "Steps Executed: " << result.steps_executed << "\n"; + if (!result.replay_session_id.empty()) { + std::cout << "Replay Session: " << result.replay_session_id << "\n"; + } + if (result.success) { + std::cout << "✅ Replay succeeded\n"; + } else { + std::cout << "❌ Replay failed: " << result.message << "\n"; + } + if (!result.assertions.empty()) { + std::cout << "Assertions (" << result.assertions.size() << "):\n"; + for (const auto& assertion : result.assertions) { + std::cout << " - " << assertion.description << ": " + << (assertion.passed ? "PASS" : "FAIL"); + if (!assertion.error_message.empty()) { + std::cout << " (" << assertion.error_message << ")"; + } + std::cout << "\n"; + } + } + if (!result.logs.empty()) { + std::cout << "Logs:\n"; + for (const auto& log : result.logs) { + std::cout << " • " << log << "\n"; + } + } +} + +void PrintReplayJsonSummary(const ReplayCommandOptions& options, + const ReplayTestResult& result) { + std::cout << "{\n"; + std::cout << " \"script_path\": \"" << JsonEscape(options.script_path) + << "\",\n"; + std::cout << " \"host\": \"" << JsonEscape(options.host) << "\",\n"; + std::cout << " \"port\": " << options.port << ",\n"; + std::cout << " \"ci_mode\": " << (options.ci_mode ? "true" : "false") + << ",\n"; + std::cout << " \"parameters\": {"; + size_t param_index = 0; + for (const auto& [key, value] : options.parameters) { + if (param_index > 0) { + std::cout << ", "; + } + std::cout << "\"" << JsonEscape(key) << "\": \"" + << JsonEscape(value) << "\""; + ++param_index; + } + std::cout << "},\n"; + std::cout << " \"success\": " << (result.success ? "true" : "false") + << ",\n"; + std::cout << " \"message\": \"" << JsonEscape(result.message) + << "\",\n"; + std::cout << " \"steps_executed\": " << result.steps_executed + << ",\n"; + if (result.replay_session_id.empty()) { + std::cout << " \"replay_session_id\": null,\n"; + } else { + std::cout << " \"replay_session_id\": \"" + << JsonEscape(result.replay_session_id) << "\",\n"; + } + std::cout << " \"assertions\": [\n"; + for (size_t i = 0; i < result.assertions.size(); ++i) { + const auto& assertion = result.assertions[i]; + std::cout << " {\"description\": \"" + << JsonEscape(assertion.description) << "\", \"passed\": " + << (assertion.passed ? "true" : "false"); + if (!assertion.error_message.empty()) { + std::cout << ", \"error\": \"" + << JsonEscape(assertion.error_message) << "\""; + } + std::cout << "}"; + if (i + 1 < result.assertions.size()) { + std::cout << ','; + } + std::cout << "\n"; + } + std::cout << " ],\n"; + std::cout << " \"logs\": [\n"; + for (size_t i = 0; i < result.logs.size(); ++i) { + std::cout << " \"" << JsonEscape(result.logs[i]) << "\""; + if (i + 1 < result.logs.size()) { + std::cout << ','; + } + std::cout << "\n"; + } + std::cout << " ]\n"; + std::cout << "}\n"; +} + +absl::Status HandleTestReplayCommand(const std::vector& arg_vec) { + ASSIGN_OR_RETURN(auto options, ParseReplayArgs(arg_vec)); + + bool text_output = options.output_format == "text"; + bool json_output = options.output_format == "json"; + +#ifndef YAZE_WITH_GRPC + std::string error = + "GUI automation requires YAZE_WITH_GRPC=ON at build time.\n" + "Rebuild with: cmake -B build -DYAZE_WITH_GRPC=ON"; + ReplayTestResult result; + result.success = false; + result.message = error; + if (json_output) { + PrintReplayJsonSummary(options, result); + } else { + PrintReplayTextSummary(options, result); + } + absl::Status status = absl::UnimplementedError(error); + AttachExitCode(&status, 2); + return status; +#else + GuiAutomationClient client(HarnessAddress(options.host, options.port)); + auto connect_status = client.Connect(); + if (!connect_status.ok()) { + std::string formatted_error = absl::StrFormat( + "Failed to connect to test harness at %s:%d -- %s", options.host, + options.port, connect_status.message()); + ReplayTestResult result; + result.success = false; + result.message = formatted_error; + if (json_output) { + PrintReplayJsonSummary(options, result); + } else { + PrintReplayTextSummary(options, result); + } + absl::Status status = absl::UnavailableError(formatted_error); + AttachExitCode(&status, 2); + return status; + } + + ASSIGN_OR_RETURN(ReplayTestResult result, + client.ReplayTest(options.script_path, options.ci_mode, + options.parameters)); + + if (json_output) { + PrintReplayJsonSummary(options, result); + } else { + PrintReplayTextSummary(options, result); + } + + if (!result.success) { + absl::Status status = absl::InternalError(result.message); + AttachExitCode(&status, options.ci_mode ? 2 : 1); + return status; + } + + return absl::OkStatus(); +#endif +} + absl::Status HandleTestRunCommand(const std::vector& arg_vec) { std::string prompt; std::string host = "localhost"; @@ -995,6 +1283,9 @@ absl::Status HandleTestCommand(const std::vector& arg_vec) { const std::string& subcommand = arg_vec[0]; std::vector tail(arg_vec.begin() + 1, arg_vec.end()); + if (subcommand == "replay") { + return HandleTestReplayCommand(tail); + } if (subcommand == "status") { return HandleTestStatusCommand(tail); }