2352 lines
76 KiB
C++
2352 lines
76 KiB
C++
#include "cli/handlers/agent/commands.h"
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <map>
|
|
#include <system_error>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_set>
|
|
#include <unordered_map>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "absl/status/status.h"
|
|
#include "absl/status/statusor.h"
|
|
#include "absl/strings/ascii.h"
|
|
#include "absl/strings/cord.h"
|
|
#include "absl/strings/match.h"
|
|
#include "absl/strings/numbers.h"
|
|
#include "absl/strings/str_cat.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/clock.h"
|
|
#include "absl/time/time.h"
|
|
#include "cli/handlers/agent/common.h"
|
|
#include "cli/service/gui_automation_client.h"
|
|
#include "cli/service/test_suite.h"
|
|
#include "cli/service/test_suite_loader.h"
|
|
#include "cli/service/test_suite_reporter.h"
|
|
#include "cli/service/test_suite_writer.h"
|
|
#include "cli/service/test_workflow_generator.h"
|
|
#include "util/macro.h"
|
|
|
|
#if defined(_WIN32)
|
|
#include <io.h>
|
|
#else
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
namespace yaze {
|
|
namespace cli {
|
|
namespace agent {
|
|
|
|
namespace {
|
|
|
|
constexpr char kExitCodePayloadKey[] = "yaze.cli.exit_code";
|
|
|
|
void AttachExitCode(absl::Status* status, int exit_code) {
|
|
if (!status || status->ok()) {
|
|
return;
|
|
}
|
|
status->SetPayload(kExitCodePayloadKey,
|
|
absl::Cord(std::to_string(exit_code)));
|
|
}
|
|
|
|
std::string TrimWhitespace(absl::string_view value) {
|
|
return std::string(absl::StripAsciiWhitespace(value));
|
|
}
|
|
|
|
bool IsInteractiveInput() {
|
|
#if defined(_WIN32)
|
|
return _isatty(_fileno(stdin)) != 0;
|
|
#else
|
|
return isatty(fileno(stdin)) != 0;
|
|
#endif
|
|
}
|
|
|
|
std::string PromptWithDefault(const std::string& prompt,
|
|
const std::string& default_value,
|
|
bool allow_empty = true) {
|
|
while (true) {
|
|
std::cout << prompt;
|
|
if (!default_value.empty()) {
|
|
std::cout << " [" << default_value << "]";
|
|
}
|
|
std::cout << ": ";
|
|
std::cout.flush();
|
|
|
|
std::string line;
|
|
if (!std::getline(std::cin, line)) {
|
|
return default_value;
|
|
}
|
|
std::string trimmed = TrimWhitespace(line);
|
|
if (!trimmed.empty()) {
|
|
return trimmed;
|
|
}
|
|
if (!default_value.empty()) {
|
|
return default_value;
|
|
}
|
|
if (allow_empty) {
|
|
return std::string();
|
|
}
|
|
std::cout << " Value is required." << std::endl;
|
|
}
|
|
}
|
|
|
|
std::string PromptRequired(const std::string& prompt,
|
|
const std::string& default_value = std::string()) {
|
|
return PromptWithDefault(prompt, default_value, /*allow_empty=*/false);
|
|
}
|
|
|
|
int PromptInt(const std::string& prompt, int default_value, int min_value) {
|
|
while (true) {
|
|
std::string default_str = absl::StrCat(default_value);
|
|
std::string input = PromptWithDefault(prompt, default_str);
|
|
if (input.empty()) {
|
|
return default_value;
|
|
}
|
|
int value = 0;
|
|
if (absl::SimpleAtoi(input, &value) && value >= min_value) {
|
|
return value;
|
|
}
|
|
std::cout << " Enter an integer >= " << min_value << "." << std::endl;
|
|
}
|
|
}
|
|
|
|
bool PromptYesNo(const std::string& prompt, bool default_value) {
|
|
while (true) {
|
|
std::cout << prompt << " [" << (default_value ? "Y/n" : "y/N")
|
|
<< "]: ";
|
|
std::cout.flush();
|
|
std::string line;
|
|
if (!std::getline(std::cin, line)) {
|
|
return default_value;
|
|
}
|
|
std::string trimmed = TrimWhitespace(line);
|
|
if (trimmed.empty()) {
|
|
return default_value;
|
|
}
|
|
char c = static_cast<char>(std::tolower(static_cast<unsigned char>(trimmed[0])));
|
|
if (c == 'y') {
|
|
return true;
|
|
}
|
|
if (c == 'n') {
|
|
return false;
|
|
}
|
|
std::cout << " Please respond with 'y' or 'n'." << std::endl;
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> ParseCommaSeparated(absl::string_view input) {
|
|
std::vector<std::string> values;
|
|
for (absl::string_view token : absl::StrSplit(input, ',')) {
|
|
std::string trimmed = TrimWhitespace(token);
|
|
if (!trimmed.empty()) {
|
|
values.push_back(trimmed);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
bool ParseKeyValueEntry(const std::string& input, std::string* key,
|
|
std::string* value) {
|
|
size_t equals = input.find('=');
|
|
if (equals == std::string::npos) {
|
|
return false;
|
|
}
|
|
*key = TrimWhitespace(absl::string_view(input.data(), equals));
|
|
*value = TrimWhitespace(absl::string_view(input.data() + equals + 1,
|
|
input.size() - equals - 1));
|
|
return !key->empty();
|
|
}
|
|
|
|
std::string DeriveTestNameFromPath(const std::string& path) {
|
|
if (path.empty()) {
|
|
return "";
|
|
}
|
|
std::filesystem::path fs_path(path);
|
|
std::string stem = fs_path.stem().string();
|
|
if (!stem.empty()) {
|
|
return stem;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
std::string OutcomeToLabel(TestCaseOutcome outcome) {
|
|
switch (outcome) {
|
|
case TestCaseOutcome::kPassed:
|
|
return "PASS";
|
|
case TestCaseOutcome::kFailed:
|
|
return "FAIL";
|
|
case TestCaseOutcome::kError:
|
|
return "ERROR";
|
|
case TestCaseOutcome::kSkipped:
|
|
return "SKIP";
|
|
}
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
std::string BuildWidgetCatalogJson(const DiscoverWidgetsResult& catalog) {
|
|
std::ostringstream oss;
|
|
oss << "{\n";
|
|
oss << " \"generated_at_ms\": ";
|
|
if (catalog.generated_at.has_value()) {
|
|
oss << absl::ToUnixMillis(catalog.generated_at.value());
|
|
} else {
|
|
oss << "null";
|
|
}
|
|
oss << ",\n";
|
|
oss << " \"total_widgets\": " << catalog.total_widgets << ",\n";
|
|
oss << " \"windows\": [\n";
|
|
for (size_t w = 0; w < catalog.windows.size(); ++w) {
|
|
const auto& window = catalog.windows[w];
|
|
oss << " {\n";
|
|
oss << " \"name\": \"" << JsonEscape(window.name) << "\",\n";
|
|
oss << " \"visible\": " << (window.visible ? "true" : "false")
|
|
<< ",\n";
|
|
oss << " \"widgets\": [\n";
|
|
for (size_t i = 0; i < window.widgets.size(); ++i) {
|
|
const auto& widget = window.widgets[i];
|
|
oss << " {\n";
|
|
oss << " \"path\": \"" << JsonEscape(widget.path) << "\",\n";
|
|
oss << " \"label\": \"" << JsonEscape(widget.label)
|
|
<< "\",\n";
|
|
oss << " \"type\": \"" << JsonEscape(widget.type) << "\",\n";
|
|
oss << " \"description\": \""
|
|
<< JsonEscape(widget.description) << "\",\n";
|
|
oss << " \"suggested_action\": \""
|
|
<< JsonEscape(widget.suggested_action) << "\",\n";
|
|
oss << " \"visible\": "
|
|
<< (widget.visible ? "true" : "false") << ",\n";
|
|
oss << " \"enabled\": "
|
|
<< (widget.enabled ? "true" : "false") << ",\n";
|
|
oss << " \"widget_id\": " << widget.widget_id << ",\n";
|
|
oss << " \"last_seen_frame\": " << widget.last_seen_frame
|
|
<< ",\n";
|
|
oss << " \"last_seen_at_ms\": ";
|
|
if (widget.last_seen_at.has_value()) {
|
|
oss << absl::ToUnixMillis(widget.last_seen_at.value());
|
|
} else {
|
|
oss << "null";
|
|
}
|
|
oss << ",\n";
|
|
oss << " \"stale\": "
|
|
<< (widget.stale ? "true" : "false") << ",\n";
|
|
oss << " \"bounds\": ";
|
|
if (widget.has_bounds) {
|
|
oss << "{\"min\": [" << widget.bounds.min_x << ", "
|
|
<< widget.bounds.min_y << "], \"max\": [" << widget.bounds.max_x
|
|
<< ", " << widget.bounds.max_y << "]}";
|
|
} else {
|
|
oss << "null";
|
|
}
|
|
oss << "\n }";
|
|
if (i + 1 < window.widgets.size()) {
|
|
oss << ',';
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " ]\n";
|
|
oss << " }";
|
|
if (w + 1 < catalog.windows.size()) {
|
|
oss << ',';
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " ]\n";
|
|
oss << "}\n";
|
|
return oss.str();
|
|
}
|
|
|
|
absl::Status WriteWidgetCatalog(const DiscoverWidgetsResult& catalog,
|
|
const std::string& output_path) {
|
|
std::filesystem::path path(output_path);
|
|
if (path.has_parent_path()) {
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(path.parent_path(), ec);
|
|
if (ec) {
|
|
return absl::InternalError(
|
|
absl::StrCat("Failed to create directories for widget catalog: ",
|
|
ec.message()));
|
|
}
|
|
}
|
|
std::ofstream out(path);
|
|
if (!out.is_open()) {
|
|
return absl::InternalError(
|
|
absl::StrCat("Unable to open widget catalog path '", output_path,
|
|
"'"));
|
|
}
|
|
out << BuildWidgetCatalogJson(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<std::string, std::string> parameters;
|
|
};
|
|
|
|
absl::StatusOr<ReplayCommandOptions> ParseReplayArgs(
|
|
const std::vector<std::string>& args) {
|
|
ReplayCommandOptions options;
|
|
|
|
auto parse_int = [](absl::string_view value,
|
|
const char* flag) -> absl::StatusOr<int> {
|
|
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 <script.json> [--ci-mode] [--host <host>] "
|
|
"[--port <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<std::string>& 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<std::string>& arg_vec) {
|
|
std::string prompt;
|
|
std::string host = "localhost";
|
|
int port = 50052;
|
|
int timeout_sec = 30;
|
|
std::string output_format = "text";
|
|
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
|
|
if (token == "--prompt" && i + 1 < arg_vec.size()) {
|
|
prompt = arg_vec[++i];
|
|
} else if (token == "--host" && i + 1 < arg_vec.size()) {
|
|
host = arg_vec[++i];
|
|
} else if (token == "--port" && i + 1 < arg_vec.size()) {
|
|
port = std::stoi(arg_vec[++i]);
|
|
} else if (token == "--timeout" && i + 1 < arg_vec.size()) {
|
|
timeout_sec = std::stoi(arg_vec[++i]);
|
|
} else if (token == "--output" && i + 1 < arg_vec.size()) {
|
|
output_format = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--prompt=")) {
|
|
prompt = token.substr(9);
|
|
} else if (absl::StartsWith(token, "--host=")) {
|
|
host = token.substr(7);
|
|
} else if (absl::StartsWith(token, "--port=")) {
|
|
port = std::stoi(token.substr(7));
|
|
} else if (absl::StartsWith(token, "--timeout=")) {
|
|
timeout_sec = std::stoi(token.substr(10));
|
|
} else if (absl::StartsWith(token, "--output=")) {
|
|
output_format = token.substr(9);
|
|
}
|
|
}
|
|
|
|
if (prompt.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test --prompt \"<prompt>\" [--host <host>] [--port "
|
|
"<port>] [--timeout <sec>] [--output text|json|yaml]\n\n"
|
|
"Examples:\n"
|
|
" z3ed agent test --prompt \"Open Overworld editor\"\n"
|
|
" z3ed agent test --prompt \"Open Dungeon editor and verify it "
|
|
"loads\"\n"
|
|
" z3ed agent test --prompt \"Click Open ROM button\" --output json");
|
|
}
|
|
|
|
output_format = absl::AsciiStrToLower(output_format);
|
|
bool text_output = (output_format == "text" || output_format == "human");
|
|
bool json_output = (output_format == "json");
|
|
bool yaml_output = (output_format == "yaml");
|
|
if (!text_output && !json_output && !yaml_output) {
|
|
return absl::InvalidArgumentError(
|
|
"--output must be one of: text, json, yaml");
|
|
}
|
|
bool machine_output = !text_output;
|
|
|
|
#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";
|
|
if (machine_output) {
|
|
if (json_output) {
|
|
std::cout << "{\n"
|
|
<< " \"prompt\": \"" << JsonEscape(prompt) << "\",\n"
|
|
<< " \"success\": false,\n"
|
|
<< " \"error\": \"" << JsonEscape(error) << "\"\n"
|
|
<< "}\n";
|
|
} else {
|
|
std::cout << "prompt: " << YamlQuote(prompt) << "\n"
|
|
<< "success: false\n"
|
|
<< "error: " << YamlQuote(error) << "\n";
|
|
}
|
|
} else {
|
|
std::cout << error << std::endl;
|
|
}
|
|
return absl::UnimplementedError(error);
|
|
#else
|
|
struct StepSummary {
|
|
std::string description;
|
|
bool success = false;
|
|
int64_t duration_ms = 0;
|
|
std::string message;
|
|
std::string test_id;
|
|
};
|
|
|
|
std::vector<StepSummary> step_summaries;
|
|
std::vector<std::string> emitted_test_ids;
|
|
std::chrono::steady_clock::time_point start_time;
|
|
bool timer_started = false;
|
|
|
|
auto EmitMachineSummary = [&](bool success, absl::string_view error_message,
|
|
int64_t elapsed_override_ms = -1) {
|
|
if (!machine_output) {
|
|
return;
|
|
}
|
|
|
|
int64_t elapsed_ms = elapsed_override_ms;
|
|
if (elapsed_ms < 0) {
|
|
if (timer_started) {
|
|
elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
std::chrono::steady_clock::now() - start_time)
|
|
.count();
|
|
} else {
|
|
elapsed_ms = 0;
|
|
}
|
|
}
|
|
|
|
std::string primary_test_id =
|
|
emitted_test_ids.empty() ? "" : emitted_test_ids.back();
|
|
|
|
if (json_output) {
|
|
std::cout << "{\n";
|
|
std::cout << " \"prompt\": \"" << JsonEscape(prompt) << "\",\n";
|
|
std::cout << " \"host\": \"" << JsonEscape(host) << "\",\n";
|
|
std::cout << " \"port\": " << port << ",\n";
|
|
std::cout << " \"success\": " << (success ? "true" : "false") << ",\n";
|
|
std::cout << " \"timeout_seconds\": " << timeout_sec << ",\n";
|
|
if (!primary_test_id.empty()) {
|
|
std::cout << " \"test_id\": \"" << JsonEscape(primary_test_id)
|
|
<< "\",\n";
|
|
} else {
|
|
std::cout << " \"test_id\": null,\n";
|
|
}
|
|
std::cout << " \"test_ids\": [";
|
|
for (size_t i = 0; i < emitted_test_ids.size(); ++i) {
|
|
if (i > 0) {
|
|
std::cout << ", ";
|
|
}
|
|
std::cout << "\"" << JsonEscape(emitted_test_ids[i]) << "\"";
|
|
}
|
|
std::cout << "],\n";
|
|
std::cout << " \"elapsed_ms\": " << elapsed_ms << ",\n";
|
|
std::cout << " \"steps\": [\n";
|
|
for (size_t i = 0; i < step_summaries.size(); ++i) {
|
|
const auto& step = step_summaries[i];
|
|
std::string message_json =
|
|
step.message.empty()
|
|
? "null"
|
|
: absl::StrCat("\"", JsonEscape(step.message), "\"");
|
|
std::string test_id_json =
|
|
step.test_id.empty()
|
|
? "null"
|
|
: absl::StrCat("\"", JsonEscape(step.test_id), "\"");
|
|
|
|
struct SuiteRunOptions {
|
|
std::string suite_path;
|
|
std::string host = "localhost";
|
|
int port = 50052;
|
|
bool ci_mode = false;
|
|
bool stop_on_failure = false;
|
|
std::string output_format = "text";
|
|
std::vector<std::string> group_filters;
|
|
std::vector<std::string> tag_filters;
|
|
std::map<std::string, std::string> parameter_overrides;
|
|
std::optional<int> retry_override;
|
|
std::string junit_output_path;
|
|
};
|
|
|
|
void AppendCsvList(absl::string_view csv,
|
|
std::vector<std::string>* output) {
|
|
for (absl::string_view part : absl::StrSplit(csv, ',', absl::SkipEmpty())) {
|
|
std::string value = std::string(absl::StripAsciiWhitespace(part));
|
|
if (!value.empty()) {
|
|
output->push_back(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
absl::StatusOr<SuiteRunOptions> ParseSuiteRunArgs(
|
|
const std::vector<std::string>& args) {
|
|
SuiteRunOptions options;
|
|
|
|
auto parse_int = [](absl::string_view value,
|
|
const char* flag) -> absl::StatusOr<int> {
|
|
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;
|
|
options.stop_on_failure = true;
|
|
continue;
|
|
}
|
|
if (token == "--stop-on-failure") {
|
|
options.stop_on_failure = true;
|
|
continue;
|
|
}
|
|
|
|
if ((token == "--host" || token == "-H") && i + 1 < args.size()) {
|
|
options.host = args[++i];
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--host=")) {
|
|
options.host = token.substr(7);
|
|
continue;
|
|
}
|
|
|
|
if ((token == "--port" || token == "-p") && 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 == "--group" || token == "-g") && i + 1 < args.size()) {
|
|
AppendCsvList(args[++i], &options.group_filters);
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--group=")) {
|
|
AppendCsvList(token.substr(8), &options.group_filters);
|
|
continue;
|
|
}
|
|
|
|
if ((token == "--tag" || token == "-t") && i + 1 < args.size()) {
|
|
AppendCsvList(args[++i], &options.tag_filters);
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--tag=")) {
|
|
AppendCsvList(token.substr(6), &options.tag_filters);
|
|
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.parameter_overrides[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.parameter_overrides[pair.substr(0, eq)] = pair.substr(eq + 1);
|
|
continue;
|
|
}
|
|
|
|
if ((token == "--retries" || token == "--retry") &&
|
|
i + 1 < args.size()) {
|
|
int value = 0;
|
|
if (!absl::SimpleAtoi(args[++i], &value) || value < 0) {
|
|
return absl::InvalidArgumentError(
|
|
"--retries expects a non-negative integer");
|
|
}
|
|
options.retry_override = value;
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--retries=") ||
|
|
absl::StartsWith(token, "--retry=")) {
|
|
std::string value = token.substr(token.find('=') + 1);
|
|
int retries = 0;
|
|
if (!absl::SimpleAtoi(value, &retries) || retries < 0) {
|
|
return absl::InvalidArgumentError(
|
|
"--retries expects a non-negative integer");
|
|
}
|
|
options.retry_override = retries;
|
|
continue;
|
|
}
|
|
|
|
if ((token == "--junit" || token == "--junit-output") &&
|
|
i + 1 < args.size()) {
|
|
options.junit_output_path = args[++i];
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--junit=") ||
|
|
absl::StartsWith(token, "--junit-output=")) {
|
|
options.junit_output_path = token.substr(token.find('=') + 1);
|
|
continue;
|
|
}
|
|
|
|
if (token == "--suite" && i + 1 < args.size()) {
|
|
options.suite_path = args[++i];
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--suite=")) {
|
|
options.suite_path = token.substr(8);
|
|
continue;
|
|
}
|
|
|
|
if (!absl::StartsWith(token, "--") && options.suite_path.empty()) {
|
|
options.suite_path = token;
|
|
continue;
|
|
}
|
|
|
|
if (!absl::StartsWith(token, "--")) {
|
|
return absl::InvalidArgumentError(
|
|
absl::StrCat("Unexpected argument: ", token));
|
|
}
|
|
|
|
return absl::InvalidArgumentError(
|
|
absl::StrCat("Unknown flag for agent test suite run: ", token));
|
|
}
|
|
|
|
if (options.suite_path.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test suite run <suite.yaml> [--group <name>] [--tag "
|
|
"<tag>] [--ci-mode] [--format text|json] [--junit <path>]"
|
|
" [--param KEY=VALUE] [--retries N]");
|
|
}
|
|
|
|
options.output_format = absl::AsciiStrToLower(options.output_format);
|
|
if (options.output_format != "text" && options.output_format != "json") {
|
|
return absl::InvalidArgumentError(
|
|
"--format must be either 'text' or 'json'");
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
bool MatchesFilter(const std::vector<std::string>& filters,
|
|
absl::string_view value) {
|
|
if (filters.empty()) {
|
|
return true;
|
|
}
|
|
for (const auto& filter : filters) {
|
|
if (absl::EqualsIgnoreCase(filter, value)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool ShouldRunGroup(const TestGroupDefinition& group,
|
|
const SuiteRunOptions& options) {
|
|
return MatchesFilter(options.group_filters, group.name);
|
|
}
|
|
|
|
bool ShouldRunTest(const TestCaseDefinition& test,
|
|
const SuiteRunOptions& options) {
|
|
if (options.tag_filters.empty()) {
|
|
return true;
|
|
}
|
|
for (const auto& tag : test.tags) {
|
|
for (const auto& filter : options.tag_filters) {
|
|
if (absl::EqualsIgnoreCase(filter, tag)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::map<std::string, std::string> MergeParameters(
|
|
const TestCaseDefinition& test, const SuiteRunOptions& options) {
|
|
std::map<std::string, std::string> merged = test.parameters;
|
|
for (const auto& [key, value] : options.parameter_overrides) {
|
|
merged[key] = value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
int DetermineMaxAttempts(const TestSuiteDefinition& suite,
|
|
const SuiteRunOptions& options) {
|
|
int retries = suite.config.retry_on_failure;
|
|
if (options.retry_override.has_value()) {
|
|
retries = options.retry_override.value();
|
|
}
|
|
if (retries < 0) {
|
|
retries = 0;
|
|
}
|
|
return retries + 1;
|
|
}
|
|
|
|
void AddResult(TestSuiteRunSummary* summary, TestCaseRunResult result) {
|
|
switch (result.outcome) {
|
|
case TestCaseOutcome::kPassed:
|
|
summary->passed++;
|
|
break;
|
|
case TestCaseOutcome::kFailed:
|
|
summary->failed++;
|
|
break;
|
|
case TestCaseOutcome::kError:
|
|
summary->errors++;
|
|
break;
|
|
case TestCaseOutcome::kSkipped:
|
|
summary->skipped++;
|
|
break;
|
|
}
|
|
summary->results.push_back(std::move(result));
|
|
}
|
|
|
|
TestCaseRunResult ExecuteTestCase(
|
|
GuiAutomationClient* client, const TestSuiteDefinition& suite,
|
|
const TestGroupDefinition& group, const TestCaseDefinition& test,
|
|
const SuiteRunOptions& options, int max_attempts) {
|
|
TestCaseRunResult result;
|
|
result.test = &test;
|
|
result.group = &group;
|
|
result.outcome = TestCaseOutcome::kError;
|
|
result.start_time = absl::Now();
|
|
|
|
std::map<std::string, std::string> parameters = MergeParameters(test, options);
|
|
|
|
for (int attempt = 1; attempt <= max_attempts; ++attempt) {
|
|
++result.attempts;
|
|
result.retries = attempt - 1;
|
|
|
|
absl::StatusOr<ReplayTestResult> replay =
|
|
client->ReplayTest(test.script_path, options.ci_mode, parameters);
|
|
|
|
if (!replay.ok()) {
|
|
result.outcome = TestCaseOutcome::kError;
|
|
result.message = replay.status().message();
|
|
break;
|
|
}
|
|
|
|
result.replay_session_id = replay->replay_session_id;
|
|
result.assertions = replay->assertions;
|
|
result.logs = replay->logs;
|
|
result.message = replay->message;
|
|
|
|
if (replay->success) {
|
|
result.outcome = TestCaseOutcome::kPassed;
|
|
break;
|
|
}
|
|
|
|
result.outcome = TestCaseOutcome::kFailed;
|
|
if (attempt < max_attempts) {
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
result.duration = absl::Now() - result.start_time;
|
|
if (result.outcome == TestCaseOutcome::kPassed && result.message.empty()) {
|
|
result.message = "Test passed";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string JoinStrings(const std::vector<std::string>& values,
|
|
absl::string_view delimiter) {
|
|
if (values.empty()) {
|
|
return "";
|
|
}
|
|
return absl::StrJoin(values, delimiter);
|
|
}
|
|
|
|
absl::StatusOr<TestSuiteRunSummary> ExecuteTestSuite(
|
|
GuiAutomationClient* client, const TestSuiteDefinition& suite,
|
|
const SuiteRunOptions& options) {
|
|
TestSuiteRunSummary summary;
|
|
summary.suite = &suite;
|
|
summary.started_at = absl::Now();
|
|
|
|
int max_attempts = DetermineMaxAttempts(suite, options);
|
|
std::unordered_map<std::string, bool> group_success;
|
|
bool interrupted = false;
|
|
|
|
for (const auto& group : suite.groups) {
|
|
bool group_selected = ShouldRunGroup(group, options);
|
|
if (!group_selected) {
|
|
group_success[group.name] = false;
|
|
continue;
|
|
}
|
|
|
|
bool dependencies_met = true;
|
|
std::vector<std::string> unmet_dependencies;
|
|
for (const std::string& dependency : group.depends_on) {
|
|
auto it = group_success.find(dependency);
|
|
if (it == group_success.end() || !it->second) {
|
|
dependencies_met = false;
|
|
unmet_dependencies.push_back(dependency);
|
|
}
|
|
}
|
|
|
|
if (!dependencies_met) {
|
|
for (const auto& test : group.tests) {
|
|
if (!ShouldRunTest(test, options)) {
|
|
continue;
|
|
}
|
|
TestCaseRunResult skipped;
|
|
skipped.test = &test;
|
|
skipped.group = &group;
|
|
skipped.outcome = TestCaseOutcome::kSkipped;
|
|
skipped.message =
|
|
absl::StrCat("Skipped because dependencies not satisfied: ",
|
|
JoinStrings(unmet_dependencies, ", "));
|
|
AddResult(&summary, std::move(skipped));
|
|
}
|
|
group_success[group.name] = false;
|
|
continue;
|
|
}
|
|
|
|
bool group_passed = true;
|
|
|
|
for (const auto& test : group.tests) {
|
|
if (!ShouldRunTest(test, options)) {
|
|
TestCaseRunResult skipped;
|
|
skipped.test = &test;
|
|
skipped.group = &group;
|
|
skipped.outcome = TestCaseOutcome::kSkipped;
|
|
skipped.message = "Skipped by CLI filter";
|
|
AddResult(&summary, std::move(skipped));
|
|
continue;
|
|
}
|
|
|
|
if (interrupted) {
|
|
TestCaseRunResult skipped;
|
|
skipped.test = &test;
|
|
skipped.group = &group;
|
|
skipped.outcome = TestCaseOutcome::kSkipped;
|
|
skipped.message =
|
|
"Skipped because stop-on-failure condition was triggered";
|
|
AddResult(&summary, std::move(skipped));
|
|
continue;
|
|
}
|
|
|
|
TestCaseRunResult result =
|
|
ExecuteTestCase(client, suite, group, test, options, max_attempts);
|
|
AddResult(&summary, std::move(result));
|
|
|
|
const auto& stored = summary.results.back();
|
|
if (stored.outcome == TestCaseOutcome::kFailed ||
|
|
stored.outcome == TestCaseOutcome::kError) {
|
|
group_passed = false;
|
|
if (options.stop_on_failure) {
|
|
interrupted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
group_success[group.name] = group_passed;
|
|
}
|
|
|
|
summary.total_duration = absl::Now() - summary.started_at;
|
|
if (summary.results.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"No tests were executed. Adjust filters or suite definition.");
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
std::string SanitizeFileComponent(absl::string_view input) {
|
|
std::string sanitized;
|
|
sanitized.reserve(input.size());
|
|
for (char c : input) {
|
|
if (std::isalnum(static_cast<unsigned char>(c))) {
|
|
sanitized.push_back(c);
|
|
} else if (c == '-' || c == '_') {
|
|
sanitized.push_back(c);
|
|
} else if (c == ' ') {
|
|
sanitized.push_back('_');
|
|
}
|
|
}
|
|
if (sanitized.empty()) {
|
|
sanitized = "suite";
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
std::string DefaultJUnitOutputPath(const TestSuiteDefinition& suite) {
|
|
std::string name = suite.name.empty() ? "suite" : suite.name;
|
|
std::string sanitized = SanitizeFileComponent(name);
|
|
return absl::StrCat("test-results/junit/", sanitized, ".xml");
|
|
}
|
|
|
|
std::string FormatRfc3339(absl::Time time) {
|
|
if (time == absl::InfinitePast()) {
|
|
return "";
|
|
}
|
|
return absl::FormatTime("%Y-%m-%dT%H:%M:%SZ", time, absl::UTCTimeZone());
|
|
}
|
|
|
|
std::string BuildSuiteJsonSummary(const TestSuiteRunSummary& summary,
|
|
const SuiteRunOptions& options,
|
|
absl::string_view junit_path) {
|
|
std::ostringstream oss;
|
|
std::string suite_name =
|
|
summary.suite ? summary.suite->name : "YAZE GUI Test Suite";
|
|
|
|
oss << "{\n";
|
|
oss << " \"suite_name\": \"" << JsonEscape(suite_name) << "\",\n";
|
|
oss << " \"suite_file\": \"" << JsonEscape(options.suite_path)
|
|
<< "\",\n";
|
|
oss << " \"host\": \"" << JsonEscape(options.host) << "\",\n";
|
|
oss << " \"port\": " << options.port << ",\n";
|
|
oss << " \"ci_mode\": " << (options.ci_mode ? "true" : "false")
|
|
<< ",\n";
|
|
oss << " \"started_at\": \""
|
|
<< JsonEscape(FormatRfc3339(summary.started_at)) << "\",\n";
|
|
oss << " \"duration_seconds\": "
|
|
<< absl::StrFormat("%.3f",
|
|
absl::ToDoubleSeconds(summary.total_duration))
|
|
<< ",\n";
|
|
oss << " \"totals\": {\n";
|
|
oss << " \"executed\": " << summary.results.size() << ",\n";
|
|
oss << " \"passed\": " << summary.passed << ",\n";
|
|
oss << " \"failed\": " << summary.failed << ",\n";
|
|
oss << " \"errors\": " << summary.errors << ",\n";
|
|
oss << " \"skipped\": " << summary.skipped << "\n";
|
|
oss << " },\n";
|
|
|
|
oss << " \"parameters\": {\n";
|
|
size_t param_index = 0;
|
|
for (const auto& [key, value] : options.parameter_overrides) {
|
|
oss << " \"" << JsonEscape(key) << "\": \""
|
|
<< JsonEscape(value) << "\"";
|
|
if (++param_index < options.parameter_overrides.size()) {
|
|
oss << ",";
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " },\n";
|
|
|
|
oss << " \"groups\": [\n";
|
|
for (size_t i = 0; i < summary.results.size(); ++i) {
|
|
const auto& result = summary.results[i];
|
|
const std::string group_name =
|
|
result.group ? result.group->name : (result.test ? result.test->group_name : "");
|
|
const std::string test_name =
|
|
result.test ? result.test->name : "Test";
|
|
oss << " {\n";
|
|
oss << " \"group\": \"" << JsonEscape(group_name) << "\",\n";
|
|
oss << " \"test\": \"" << JsonEscape(test_name) << "\",\n";
|
|
oss << " \"outcome\": \""
|
|
<< JsonEscape(OutcomeToLabel(result.outcome)) << "\",\n";
|
|
oss << " \"duration_seconds\": "
|
|
<< absl::StrFormat("%.3f", absl::ToDoubleSeconds(result.duration))
|
|
<< ",\n";
|
|
oss << " \"attempts\": " << result.attempts << ",\n";
|
|
oss << " \"message\": \"" << JsonEscape(result.message)
|
|
<< "\",\n";
|
|
if (result.replay_session_id.empty()) {
|
|
oss << " \"replay_session_id\": null,\n";
|
|
} else {
|
|
oss << " \"replay_session_id\": \""
|
|
<< JsonEscape(result.replay_session_id) << "\",\n";
|
|
}
|
|
oss << " \"assertions\": [\n";
|
|
for (size_t j = 0; j < result.assertions.size(); ++j) {
|
|
const auto& assertion = result.assertions[j];
|
|
oss << " {\"description\": \""
|
|
<< JsonEscape(assertion.description) << "\", \"passed\": "
|
|
<< (assertion.passed ? "true" : "false");
|
|
if (!assertion.error_message.empty()) {
|
|
oss << ", \"error\": \""
|
|
<< JsonEscape(assertion.error_message) << "\"";
|
|
}
|
|
oss << "}";
|
|
if (j + 1 < result.assertions.size()) {
|
|
oss << ",";
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " ],\n";
|
|
oss << " \"logs\": [\n";
|
|
for (size_t j = 0; j < result.logs.size(); ++j) {
|
|
oss << " \"" << JsonEscape(result.logs[j]) << "\"";
|
|
if (j + 1 < result.logs.size()) {
|
|
oss << ",";
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " ]\n";
|
|
oss << " }";
|
|
if (i + 1 < summary.results.size()) {
|
|
oss << ",";
|
|
}
|
|
oss << "\n";
|
|
}
|
|
oss << " ],\n";
|
|
|
|
if (junit_path.empty()) {
|
|
oss << " \"junit_report\": null\n";
|
|
} else {
|
|
oss << " \"junit_report\": \"" << JsonEscape(junit_path)
|
|
<< "\"\n";
|
|
}
|
|
oss << "}\n";
|
|
return oss.str();
|
|
}
|
|
std::cout << " {\n";
|
|
std::cout << " \"index\": " << (i + 1) << ",\n";
|
|
std::cout << " \"description\": \"" << JsonEscape(step.description)
|
|
<< "\",\n";
|
|
std::cout << " \"success\": " << (step.success ? "true" : "false")
|
|
<< ",\n";
|
|
std::cout << " \"duration_ms\": " << step.duration_ms << ",\n";
|
|
std::cout << " \"message\": " << message_json << ",\n";
|
|
std::cout << " \"test_id\": " << test_id_json << "\n";
|
|
std::cout << " }";
|
|
if (i + 1 < step_summaries.size()) {
|
|
std::cout << ",";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
std::cout << " ],\n";
|
|
if (!error_message.empty()) {
|
|
std::cout << " \"error\": \"" << JsonEscape(std::string(error_message))
|
|
<< "\"\n";
|
|
} else {
|
|
std::cout << " \"error\": null\n";
|
|
}
|
|
std::cout << "}\n";
|
|
} else if (yaml_output) {
|
|
std::cout << "prompt: " << YamlQuote(prompt) << "\n";
|
|
std::cout << "host: " << YamlQuote(host) << "\n";
|
|
std::cout << "port: " << port << "\n";
|
|
std::cout << "success: " << (success ? "true" : "false") << "\n";
|
|
std::cout << "timeout_seconds: " << timeout_sec << "\n";
|
|
if (primary_test_id.empty()) {
|
|
std::cout << "test_id: null\n";
|
|
} else {
|
|
std::cout << "test_id: " << YamlQuote(primary_test_id) << "\n";
|
|
}
|
|
if (emitted_test_ids.empty()) {
|
|
std::cout << "test_ids: []\n";
|
|
} else {
|
|
std::cout << "test_ids:\n";
|
|
for (const auto& id : emitted_test_ids) {
|
|
std::cout << " - " << YamlQuote(id) << "\n";
|
|
}
|
|
}
|
|
std::cout << "elapsed_ms: " << elapsed_ms << "\n";
|
|
if (step_summaries.empty()) {
|
|
std::cout << "steps: []\n";
|
|
} else {
|
|
std::cout << "steps:\n";
|
|
for (size_t i = 0; i < step_summaries.size(); ++i) {
|
|
const auto& step = step_summaries[i];
|
|
std::cout << " - index: " << (i + 1) << "\n";
|
|
std::cout << " description: " << YamlQuote(step.description)
|
|
<< "\n";
|
|
std::cout << " success: " << (step.success ? "true" : "false")
|
|
<< "\n";
|
|
std::cout << " duration_ms: " << step.duration_ms << "\n";
|
|
if (step.message.empty()) {
|
|
std::cout << " message: null\n";
|
|
} else {
|
|
std::cout << " message: " << YamlQuote(step.message) << "\n";
|
|
}
|
|
if (step.test_id.empty()) {
|
|
std::cout << " test_id: null\n";
|
|
} else {
|
|
std::cout << " test_id: " << YamlQuote(step.test_id) << "\n";
|
|
}
|
|
}
|
|
}
|
|
if (!error_message.empty()) {
|
|
std::cout << "error: " << YamlQuote(std::string(error_message)) << "\n";
|
|
} else {
|
|
std::cout << "error: null\n";
|
|
}
|
|
}
|
|
};
|
|
|
|
if (text_output) {
|
|
std::cout << "\n=== GUI Automation Test ===\n";
|
|
std::cout << "Prompt: " << prompt << "\n";
|
|
std::cout << "Server: " << host << ":" << port << "\n\n";
|
|
}
|
|
|
|
TestWorkflowGenerator generator;
|
|
auto workflow_or = generator.GenerateWorkflow(prompt);
|
|
if (!workflow_or.ok()) {
|
|
EmitMachineSummary(false, workflow_or.status().message());
|
|
return workflow_or.status();
|
|
}
|
|
auto workflow = workflow_or.value();
|
|
|
|
if (text_output) {
|
|
std::cout << "Generated workflow:\n" << workflow.ToString() << "\n";
|
|
}
|
|
|
|
GuiAutomationClient client(HarnessAddress(host, 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\n"
|
|
"Make sure YAZE is running with:\n"
|
|
" ./yaze --enable_test_harness --test_harness_port=%d "
|
|
"--rom_file=<rom>\n\n"
|
|
"Error: %s",
|
|
host, port, port, connect_status.message());
|
|
EmitMachineSummary(false, formatted_error);
|
|
return absl::UnavailableError(formatted_error);
|
|
}
|
|
|
|
if (text_output) {
|
|
std::cout << "✓ Connected to test harness\n\n";
|
|
}
|
|
|
|
start_time = std::chrono::steady_clock::now();
|
|
timer_started = true;
|
|
int step_num = 0;
|
|
|
|
for (const auto& step : workflow.steps) {
|
|
step_num++;
|
|
StepSummary summary;
|
|
summary.description = step.ToString();
|
|
|
|
if (text_output) {
|
|
std::cout << absl::StrFormat("[%d/%d] %s ... ", step_num,
|
|
workflow.steps.size(), summary.description);
|
|
std::cout.flush();
|
|
}
|
|
|
|
absl::StatusOr<AutomationResult> result;
|
|
|
|
switch (step.type) {
|
|
case TestStepType::kClick:
|
|
result = client.Click(step.target);
|
|
break;
|
|
case TestStepType::kType:
|
|
result = client.Type(step.target, step.text, step.clear_first);
|
|
break;
|
|
case TestStepType::kWait:
|
|
result = client.Wait(step.condition, step.timeout_ms);
|
|
break;
|
|
case TestStepType::kAssert:
|
|
result = client.Assert(step.condition);
|
|
break;
|
|
case TestStepType::kScreenshot:
|
|
result = client.Screenshot();
|
|
break;
|
|
}
|
|
|
|
if (!result.ok()) {
|
|
summary.success = false;
|
|
summary.message = result.status().message();
|
|
step_summaries.push_back(std::move(summary));
|
|
if (text_output) {
|
|
std::cout << "✗ FAILED\n";
|
|
}
|
|
EmitMachineSummary(false, result.status().message());
|
|
return absl::InternalError(absl::StrFormat("Step %d failed: %s", step_num,
|
|
result.status().message()));
|
|
}
|
|
|
|
summary.duration_ms = result->execution_time.count();
|
|
summary.message = result->message;
|
|
|
|
if (!result->success) {
|
|
summary.success = false;
|
|
if (!result->test_id.empty()) {
|
|
summary.test_id = result->test_id;
|
|
emitted_test_ids.push_back(result->test_id);
|
|
}
|
|
step_summaries.push_back(std::move(summary));
|
|
if (text_output) {
|
|
std::cout << "✗ FAILED\n";
|
|
std::cout << " Error: " << result->message << "\n";
|
|
}
|
|
EmitMachineSummary(false, result->message);
|
|
return absl::InternalError(
|
|
absl::StrFormat("Step %d failed: %s", step_num, result->message));
|
|
}
|
|
|
|
summary.success = true;
|
|
if (!result->test_id.empty()) {
|
|
summary.test_id = result->test_id;
|
|
emitted_test_ids.push_back(result->test_id);
|
|
}
|
|
step_summaries.push_back(summary);
|
|
|
|
if (text_output) {
|
|
std::cout << absl::StrFormat("✓ (%lldms)",
|
|
result->execution_time.count());
|
|
if (!result->test_id.empty()) {
|
|
std::cout << " [Test ID: " << result->test_id << "]";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
}
|
|
|
|
auto end_time = std::chrono::steady_clock::now();
|
|
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
|
end_time - start_time);
|
|
|
|
if (text_output) {
|
|
std::cout << "\n✅ Test passed in " << elapsed.count() << "ms\n";
|
|
|
|
if (!emitted_test_ids.empty()) {
|
|
std::cout << "Latest Test ID: " << emitted_test_ids.back() << "\n";
|
|
if (emitted_test_ids.size() > 1) {
|
|
std::cout << "Captured Test IDs:\n";
|
|
for (const auto& id : emitted_test_ids) {
|
|
std::cout << " - " << id << "\n";
|
|
}
|
|
}
|
|
std::cout << "Use 'z3ed agent test status --test-id "
|
|
<< emitted_test_ids.back() << "' for live status updates."
|
|
<< std::endl;
|
|
}
|
|
}
|
|
|
|
EmitMachineSummary(true, /*error_message=*/"", elapsed.count());
|
|
return absl::OkStatus();
|
|
#endif
|
|
}
|
|
|
|
absl::Status HandleTestStatusCommand(const std::vector<std::string>& arg_vec) {
|
|
std::string host = "localhost";
|
|
int port = 50052;
|
|
std::string test_id;
|
|
bool follow = false;
|
|
int interval_ms = 1000;
|
|
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
|
|
if (token == "--test-id" && i + 1 < arg_vec.size()) {
|
|
test_id = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--test-id=")) {
|
|
test_id = token.substr(10);
|
|
} else if (token == "--host" && i + 1 < arg_vec.size()) {
|
|
host = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--host=")) {
|
|
host = token.substr(7);
|
|
} else if (token == "--port" && i + 1 < arg_vec.size()) {
|
|
port = std::stoi(arg_vec[++i]);
|
|
} else if (absl::StartsWith(token, "--port=")) {
|
|
port = std::stoi(token.substr(7));
|
|
} else if (token == "--follow") {
|
|
follow = true;
|
|
} else if ((token == "--interval" || token == "--interval-ms") &&
|
|
i + 1 < arg_vec.size()) {
|
|
interval_ms = std::max(100, std::stoi(arg_vec[++i]));
|
|
} else if (absl::StartsWith(token, "--interval=") ||
|
|
absl::StartsWith(token, "--interval-ms=")) {
|
|
size_t prefix = token.find('=');
|
|
interval_ms = std::max(100, std::stoi(token.substr(prefix + 1)));
|
|
}
|
|
}
|
|
|
|
if (test_id.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test status --test-id <id> [--follow] [--host <host>] "
|
|
"[--port <port>] [--interval-ms <ms>]");
|
|
}
|
|
|
|
#ifndef YAZE_WITH_GRPC
|
|
return absl::UnimplementedError(
|
|
"GUI automation requires YAZE_WITH_GRPC=ON at build time.\n"
|
|
"Rebuild with: cmake -B build -DYAZE_WITH_GRPC=ON");
|
|
#else
|
|
GuiAutomationClient client(HarnessAddress(host, port));
|
|
RETURN_IF_ERROR(client.Connect());
|
|
|
|
std::cout << "\n=== Test Status ===\n";
|
|
std::cout << "Test ID: " << test_id << "\n";
|
|
std::cout << "Server: " << HarnessAddress(host, port) << "\n";
|
|
if (follow) {
|
|
std::cout << "Follow mode: polling every " << interval_ms << "ms\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
bool first_iteration = true;
|
|
while (true) {
|
|
ASSIGN_OR_RETURN(auto details, client.GetTestStatus(test_id));
|
|
|
|
if (!first_iteration) {
|
|
std::cout << "---\n";
|
|
}
|
|
|
|
std::cout << "Status: " << TestRunStatusToString(details.status) << "\n";
|
|
std::cout << "Queued At: " << FormatOptionalTime(details.queued_at) << "\n";
|
|
std::cout << "Started At: " << FormatOptionalTime(details.started_at)
|
|
<< "\n";
|
|
std::cout << "Completed At: " << FormatOptionalTime(details.completed_at)
|
|
<< "\n";
|
|
std::cout << "Execution Time (ms): " << details.execution_time_ms << "\n";
|
|
if (!details.error_message.empty()) {
|
|
std::cout << "Error: " << details.error_message << "\n";
|
|
}
|
|
|
|
if (!details.assertion_failures.empty()) {
|
|
std::cout << "Assertion Failures (" << details.assertion_failures.size()
|
|
<< "):\n";
|
|
for (const auto& failure : details.assertion_failures) {
|
|
std::cout << " - " << failure << "\n";
|
|
}
|
|
} else {
|
|
std::cout << "Assertion Failures: 0\n";
|
|
}
|
|
|
|
if (!follow || IsTerminalStatus(details.status)) {
|
|
break;
|
|
}
|
|
|
|
first_iteration = false;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms));
|
|
}
|
|
|
|
return absl::OkStatus();
|
|
#endif
|
|
}
|
|
|
|
absl::Status HandleTestListCommand(const std::vector<std::string>& arg_vec) {
|
|
std::string host = "localhost";
|
|
int port = 50052;
|
|
std::string category_filter;
|
|
std::optional<TestRunStatus> status_filter;
|
|
int page_size = 100;
|
|
int limit = -1;
|
|
bool fetch_all = false;
|
|
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
|
|
if (token == "--host" && i + 1 < arg_vec.size()) {
|
|
host = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--host=")) {
|
|
host = token.substr(7);
|
|
} else if (token == "--port" && i + 1 < arg_vec.size()) {
|
|
port = std::stoi(arg_vec[++i]);
|
|
} else if (absl::StartsWith(token, "--port=")) {
|
|
port = std::stoi(token.substr(7));
|
|
} else if (token == "--category" && i + 1 < arg_vec.size()) {
|
|
category_filter = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--category=")) {
|
|
category_filter = token.substr(11);
|
|
} else if (token == "--status" && i + 1 < arg_vec.size()) {
|
|
auto parsed = ParseStatusFilter(arg_vec[++i]);
|
|
if (!parsed.has_value()) {
|
|
return absl::InvalidArgumentError(
|
|
"Invalid status filter. Expected: queued, running, passed, failed, "
|
|
"timeout, unknown");
|
|
}
|
|
status_filter = parsed;
|
|
} else if (absl::StartsWith(token, "--status=")) {
|
|
auto parsed = ParseStatusFilter(token.substr(9));
|
|
if (!parsed.has_value()) {
|
|
return absl::InvalidArgumentError(
|
|
"Invalid status filter. Expected: queued, running, passed, failed, "
|
|
"timeout, unknown");
|
|
}
|
|
status_filter = parsed;
|
|
} else if (token == "--page-size" && i + 1 < arg_vec.size()) {
|
|
page_size = std::max(1, std::stoi(arg_vec[++i]));
|
|
} else if (absl::StartsWith(token, "--page-size=")) {
|
|
page_size = std::max(1, std::stoi(token.substr(12)));
|
|
} else if (token == "--limit" && i + 1 < arg_vec.size()) {
|
|
limit = std::stoi(arg_vec[++i]);
|
|
} else if (absl::StartsWith(token, "--limit=")) {
|
|
limit = std::stoi(token.substr(8));
|
|
} else if (token == "--all") {
|
|
fetch_all = true;
|
|
}
|
|
}
|
|
|
|
if (fetch_all) {
|
|
limit = -1;
|
|
}
|
|
|
|
#ifndef YAZE_WITH_GRPC
|
|
return absl::UnimplementedError(
|
|
"GUI automation requires YAZE_WITH_GRPC=ON at build time.\n"
|
|
"Rebuild with: cmake -B build -DYAZE_WITH_GRPC=ON");
|
|
#else
|
|
GuiAutomationClient client(HarnessAddress(host, port));
|
|
RETURN_IF_ERROR(client.Connect());
|
|
|
|
std::cout << "\n=== Harness Test Catalog ===\n";
|
|
std::cout << "Server: " << HarnessAddress(host, port) << "\n";
|
|
if (!category_filter.empty()) {
|
|
std::cout << "Category filter: " << category_filter << "\n";
|
|
}
|
|
if (status_filter.has_value()) {
|
|
std::cout << "Status filter: "
|
|
<< TestRunStatusToString(status_filter.value()) << "\n";
|
|
}
|
|
std::cout << "\n";
|
|
|
|
std::vector<HarnessTestSummary> collected;
|
|
collected.reserve(limit > 0 ? limit : page_size);
|
|
std::string page_token;
|
|
int total_count = 0;
|
|
|
|
while (true) {
|
|
int request_page_size = page_size > 0 ? page_size : 100;
|
|
if (limit > 0) {
|
|
int remaining = limit - static_cast<int>(collected.size());
|
|
if (remaining <= 0) {
|
|
break;
|
|
}
|
|
request_page_size = std::min(request_page_size, remaining);
|
|
}
|
|
|
|
ASSIGN_OR_RETURN(
|
|
auto batch,
|
|
client.ListTests(category_filter, request_page_size, page_token));
|
|
|
|
total_count = batch.total_count;
|
|
|
|
for (const auto& summary : batch.tests) {
|
|
if (status_filter.has_value()) {
|
|
ASSIGN_OR_RETURN(auto details, client.GetTestStatus(summary.test_id));
|
|
if (details.status != status_filter.value()) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
collected.push_back(summary);
|
|
if (limit > 0 && static_cast<int>(collected.size()) >= limit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (limit > 0 && static_cast<int>(collected.size()) >= limit) {
|
|
break;
|
|
}
|
|
|
|
if (batch.next_page_token.empty()) {
|
|
break;
|
|
}
|
|
page_token = batch.next_page_token;
|
|
}
|
|
|
|
if (collected.empty()) {
|
|
std::cout << "No tests found for the specified filters." << std::endl;
|
|
return absl::OkStatus();
|
|
}
|
|
|
|
for (const auto& summary : collected) {
|
|
std::cout << "Test ID: " << summary.test_id << "\n";
|
|
std::cout << " Name: " << summary.name << "\n";
|
|
std::cout << " Category: " << summary.category << "\n";
|
|
std::cout << " Last Run: " << FormatOptionalTime(summary.last_run_at)
|
|
<< "\n";
|
|
std::cout << " Runs: " << summary.total_runs << " (" << summary.pass_count
|
|
<< " pass / " << summary.fail_count << " fail)\n";
|
|
std::cout << " Average Duration (ms): " << summary.average_duration_ms
|
|
<< "\n\n";
|
|
}
|
|
|
|
std::cout << "Displayed " << collected.size() << " test(s)";
|
|
if (total_count > 0) {
|
|
std::cout << " (catalog size: " << total_count << ")";
|
|
}
|
|
std::cout << "." << std::endl;
|
|
|
|
return absl::OkStatus();
|
|
#endif
|
|
}
|
|
|
|
absl::Status HandleTestResultsCommand(const std::vector<std::string>& arg_vec) {
|
|
std::string host = "localhost";
|
|
int port = 50052;
|
|
std::string test_id;
|
|
bool include_logs = false;
|
|
std::string format = "yaml";
|
|
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
|
|
if (token == "--test-id" && i + 1 < arg_vec.size()) {
|
|
test_id = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--test-id=")) {
|
|
test_id = token.substr(10);
|
|
} else if (token == "--host" && i + 1 < arg_vec.size()) {
|
|
host = arg_vec[++i];
|
|
} else if (absl::StartsWith(token, "--host=")) {
|
|
host = token.substr(7);
|
|
} else if (token == "--port" && i + 1 < arg_vec.size()) {
|
|
port = std::stoi(arg_vec[++i]);
|
|
} else if (absl::StartsWith(token, "--port=")) {
|
|
port = std::stoi(token.substr(7));
|
|
} else if (token == "--include-logs") {
|
|
include_logs = true;
|
|
} else if (token == "--format" && i + 1 < arg_vec.size()) {
|
|
format = absl::AsciiStrToLower(arg_vec[++i]);
|
|
} else if (absl::StartsWith(token, "--format=")) {
|
|
format = absl::AsciiStrToLower(token.substr(9));
|
|
}
|
|
}
|
|
|
|
if (test_id.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test results --test-id <id> [--include-logs] [--format "
|
|
"yaml|json] [--host <host>] [--port <port>]");
|
|
}
|
|
|
|
if (format != "yaml" && format != "json") {
|
|
return absl::InvalidArgumentError(
|
|
"--format must be either 'yaml' or 'json'");
|
|
}
|
|
|
|
#ifndef YAZE_WITH_GRPC
|
|
return absl::UnimplementedError(
|
|
"GUI automation requires YAZE_WITH_GRPC=ON at build time.\n"
|
|
"Rebuild with: cmake -B build -DYAZE_WITH_GRPC=ON");
|
|
#else
|
|
GuiAutomationClient client(HarnessAddress(host, port));
|
|
RETURN_IF_ERROR(client.Connect());
|
|
|
|
ASSIGN_OR_RETURN(auto details, client.GetTestResults(test_id, include_logs));
|
|
|
|
if (format == "json") {
|
|
std::cout << "{\n";
|
|
std::cout << " \"test_id\": \"" << JsonEscape(details.test_id) << "\",\n";
|
|
std::cout << " \"success\": " << (details.success ? "true" : "false")
|
|
<< ",\n";
|
|
std::cout << " \"name\": \"" << JsonEscape(details.test_name) << "\",\n";
|
|
std::cout << " \"category\": \"" << JsonEscape(details.category)
|
|
<< "\",\n";
|
|
std::cout << " \"executed_at\": \""
|
|
<< JsonEscape(FormatOptionalTime(details.executed_at)) << "\",\n";
|
|
std::cout << " \"duration_ms\": " << details.duration_ms << ",\n";
|
|
|
|
std::cout << " \"assertions\": ";
|
|
if (details.assertions.empty()) {
|
|
std::cout << "[],\n";
|
|
} else {
|
|
std::cout << "[\n";
|
|
for (size_t i = 0; i < details.assertions.size(); ++i) {
|
|
const auto& assertion = details.assertions[i];
|
|
std::cout << " {\"description\": \""
|
|
<< JsonEscape(assertion.description) << "\", \"passed\": "
|
|
<< (assertion.passed ? "true" : "false");
|
|
if (!assertion.expected_value.empty()) {
|
|
std::cout << ", \"expected\": \""
|
|
<< JsonEscape(assertion.expected_value) << "\"";
|
|
}
|
|
if (!assertion.actual_value.empty()) {
|
|
std::cout << ", \"actual\": \"" << JsonEscape(assertion.actual_value)
|
|
<< "\"";
|
|
}
|
|
if (!assertion.error_message.empty()) {
|
|
std::cout << ", \"error\": \"" << JsonEscape(assertion.error_message)
|
|
<< "\"";
|
|
}
|
|
std::cout << "}";
|
|
if (i + 1 < details.assertions.size()) {
|
|
std::cout << ",";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
std::cout << " ],\n";
|
|
}
|
|
|
|
std::cout << " \"logs\": ";
|
|
if (include_logs && !details.logs.empty()) {
|
|
std::cout << "[\n";
|
|
for (size_t i = 0; i < details.logs.size(); ++i) {
|
|
std::cout << " \"" << JsonEscape(details.logs[i]) << "\"";
|
|
if (i + 1 < details.logs.size()) {
|
|
std::cout << ",";
|
|
}
|
|
std::cout << "\n";
|
|
}
|
|
std::cout << " ],\n";
|
|
} else {
|
|
std::cout << "[],\n";
|
|
}
|
|
|
|
std::cout << " \"screenshot_path\": ";
|
|
if (details.screenshot_path.empty()) {
|
|
std::cout << "null,\n";
|
|
} else {
|
|
std::cout << "\"" << JsonEscape(details.screenshot_path) << "\",\n";
|
|
}
|
|
|
|
std::cout << " \"screenshot_size_bytes\": "
|
|
<< details.screenshot_size_bytes << ",\n";
|
|
|
|
std::cout << " \"failure_context\": ";
|
|
if (details.failure_context.empty()) {
|
|
std::cout << "null,\n";
|
|
} else {
|
|
std::cout << "\"" << JsonEscape(details.failure_context) << "\",\n";
|
|
}
|
|
|
|
std::cout << " \"widget_state\": ";
|
|
if (details.widget_state.empty()) {
|
|
std::cout << "null,\n";
|
|
} else {
|
|
std::cout << "\"" << JsonEscape(details.widget_state) << "\",\n";
|
|
}
|
|
|
|
std::cout << " \"metrics\": ";
|
|
if (!details.metrics.empty()) {
|
|
std::cout << "{\n";
|
|
size_t index = 0;
|
|
for (const auto& [key, value] : details.metrics) {
|
|
std::cout << " \"" << JsonEscape(key) << "\": " << value;
|
|
if (index + 1 < details.metrics.size()) {
|
|
std::cout << ",";
|
|
}
|
|
std::cout << "\n";
|
|
++index;
|
|
}
|
|
std::cout << " }\n";
|
|
} else {
|
|
std::cout << "{}\n";
|
|
}
|
|
|
|
std::cout << "}" << std::endl;
|
|
} else {
|
|
std::cout << "test_id: " << details.test_id << "\n";
|
|
std::cout << "success: " << (details.success ? "true" : "false") << "\n";
|
|
std::cout << "name: " << YamlQuote(details.test_name) << "\n";
|
|
std::cout << "category: " << YamlQuote(details.category) << "\n";
|
|
std::cout << "executed_at: " << FormatOptionalTime(details.executed_at)
|
|
<< "\n";
|
|
std::cout << "duration_ms: " << details.duration_ms << "\n";
|
|
|
|
if (details.assertions.empty()) {
|
|
std::cout << "assertions: []\n";
|
|
} else {
|
|
std::cout << "assertions:\n";
|
|
for (const auto& assertion : details.assertions) {
|
|
std::cout << " - description: " << YamlQuote(assertion.description)
|
|
<< "\n";
|
|
std::cout << " passed: " << (assertion.passed ? "true" : "false")
|
|
<< "\n";
|
|
if (!assertion.expected_value.empty()) {
|
|
std::cout << " expected: " << YamlQuote(assertion.expected_value)
|
|
<< "\n";
|
|
}
|
|
if (!assertion.actual_value.empty()) {
|
|
std::cout << " actual: " << YamlQuote(assertion.actual_value)
|
|
<< "\n";
|
|
}
|
|
if (!assertion.error_message.empty()) {
|
|
std::cout << " error: " << YamlQuote(assertion.error_message)
|
|
<< "\n";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (include_logs && !details.logs.empty()) {
|
|
std::cout << "logs:\n";
|
|
for (const auto& log : details.logs) {
|
|
std::cout << " - " << YamlQuote(log) << "\n";
|
|
}
|
|
} else {
|
|
std::cout << "logs: []\n";
|
|
}
|
|
|
|
if (details.metrics.empty()) {
|
|
std::cout << "metrics: {}\n";
|
|
} else {
|
|
std::cout << "metrics:\n";
|
|
for (const auto& [key, value] : details.metrics) {
|
|
std::cout << " " << key << ": " << value << "\n";
|
|
}
|
|
}
|
|
|
|
if (details.screenshot_path.empty()) {
|
|
std::cout << "screenshot_path: null\n";
|
|
} else {
|
|
std::cout << "screenshot_path: "
|
|
<< YamlQuote(details.screenshot_path) << "\n";
|
|
}
|
|
std::cout << "screenshot_size_bytes: " << details.screenshot_size_bytes
|
|
<< "\n";
|
|
if (details.failure_context.empty()) {
|
|
std::cout << "failure_context: null\n";
|
|
} else {
|
|
std::cout << "failure_context: "
|
|
<< YamlQuote(details.failure_context) << "\n";
|
|
}
|
|
if (details.widget_state.empty()) {
|
|
std::cout << "widget_state: null\n";
|
|
} else {
|
|
std::cout << "widget_state: " << YamlQuote(details.widget_state)
|
|
<< "\n";
|
|
}
|
|
}
|
|
|
|
return absl::OkStatus();
|
|
#endif
|
|
}
|
|
|
|
absl::Status HandleTestSuiteRunCommand(const std::vector<std::string>& arg_vec) {
|
|
#ifndef YAZE_WITH_GRPC
|
|
return absl::UnimplementedError(
|
|
"GUI automation requires YAZE_WITH_GRPC=ON at build time.\n"
|
|
"Rebuild with: cmake -B build -DYAZE_WITH_GRPC=ON");
|
|
#else
|
|
ASSIGN_OR_RETURN(SuiteRunOptions options, ParseSuiteRunArgs(arg_vec));
|
|
auto suite_or = LoadTestSuiteFromFile(options.suite_path);
|
|
if (!suite_or.ok()) {
|
|
absl::Status status = suite_or.status();
|
|
AttachExitCode(&status, 2);
|
|
return status;
|
|
}
|
|
TestSuiteDefinition suite = std::move(suite_or.value());
|
|
|
|
if (options.junit_output_path.empty() && options.ci_mode) {
|
|
options.junit_output_path = DefaultJUnitOutputPath(suite);
|
|
}
|
|
|
|
GuiAutomationClient client(HarnessAddress(options.host, options.port));
|
|
RETURN_IF_ERROR(client.Connect());
|
|
|
|
auto summary_or = ExecuteTestSuite(&client, suite, options);
|
|
if (!summary_or.ok()) {
|
|
absl::Status status = summary_or.status();
|
|
AttachExitCode(&status, 2);
|
|
return status;
|
|
}
|
|
TestSuiteRunSummary summary = std::move(summary_or.value());
|
|
|
|
std::string junit_note;
|
|
if (!options.junit_output_path.empty()) {
|
|
absl::Status write_status =
|
|
WriteJUnitReport(summary, options.junit_output_path);
|
|
if (!write_status.ok()) {
|
|
std::cerr << "Failed to write JUnit report: "
|
|
<< write_status.message() << std::endl;
|
|
} else {
|
|
junit_note = options.junit_output_path;
|
|
}
|
|
}
|
|
|
|
if (options.output_format == "json") {
|
|
std::cout << BuildSuiteJsonSummary(summary, options, junit_note)
|
|
<< std::endl;
|
|
} else {
|
|
std::cout << BuildTextSummary(summary);
|
|
if (!junit_note.empty()) {
|
|
std::cout << "\nJUnit report: " << junit_note << "\n";
|
|
}
|
|
}
|
|
|
|
int exit_code = 0;
|
|
if (summary.errors > 0) {
|
|
exit_code = 2;
|
|
} else if (summary.failed > 0) {
|
|
exit_code = 1;
|
|
}
|
|
|
|
if (exit_code != 0) {
|
|
absl::Status status =
|
|
(summary.errors > 0)
|
|
? absl::InternalError("Suite run encountered errors")
|
|
: absl::UnknownError("Suite run reported failing tests");
|
|
AttachExitCode(&status, exit_code);
|
|
return status;
|
|
}
|
|
|
|
return absl::OkStatus();
|
|
#endif
|
|
}
|
|
|
|
absl::Status HandleTestSuiteValidateCommand(
|
|
const std::vector<std::string>& arg_vec) {
|
|
std::string suite_path;
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
if (token == "--suite" && i + 1 < arg_vec.size()) {
|
|
suite_path = arg_vec[++i];
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--suite=")) {
|
|
suite_path = token.substr(8);
|
|
continue;
|
|
}
|
|
if (!absl::StartsWith(token, "--") && suite_path.empty()) {
|
|
suite_path = token;
|
|
continue;
|
|
}
|
|
return absl::InvalidArgumentError(
|
|
absl::StrCat("Unknown or misplaced argument: ", token));
|
|
}
|
|
|
|
if (suite_path.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test suite validate <suite.yaml>");
|
|
}
|
|
|
|
auto suite_or = LoadTestSuiteFromFile(suite_path);
|
|
if (!suite_or.ok()) {
|
|
absl::Status status = suite_or.status();
|
|
AttachExitCode(&status, 2);
|
|
return status;
|
|
}
|
|
TestSuiteDefinition suite = std::move(suite_or.value());
|
|
|
|
int total_tests = 0;
|
|
for (const auto& group : suite.groups) {
|
|
total_tests += static_cast<int>(group.tests.size());
|
|
}
|
|
|
|
std::cout << "Suite validation succeeded\n";
|
|
std::cout << " File: " << suite_path << "\n";
|
|
std::cout << " Name: "
|
|
<< (suite.name.empty() ? "<unnamed>" : suite.name) << "\n";
|
|
std::cout << " Groups: " << suite.groups.size() << "\n";
|
|
std::cout << " Tests: " << total_tests << "\n";
|
|
|
|
return absl::OkStatus();
|
|
}
|
|
|
|
absl::Status HandleTestSuiteCreateCommand(
|
|
const std::vector<std::string>& arg_vec) {
|
|
if (!IsInteractiveInput()) {
|
|
return absl::FailedPreconditionError(
|
|
"agent test suite create requires an interactive terminal");
|
|
}
|
|
|
|
std::string target_arg;
|
|
bool force = false;
|
|
for (size_t i = 0; i < arg_vec.size(); ++i) {
|
|
const std::string& token = arg_vec[i];
|
|
if (token == "--force") {
|
|
force = true;
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--force=")) {
|
|
std::string value = TrimWhitespace(token.substr(8));
|
|
absl::AsciiStrToLower(&value);
|
|
force = (value == "1" || value == "true" || value == "yes" ||
|
|
value == "on");
|
|
continue;
|
|
}
|
|
if (absl::StartsWith(token, "--")) {
|
|
return absl::InvalidArgumentError(
|
|
absl::StrCat("Unknown flag for agent test suite create: ", token));
|
|
}
|
|
if (!target_arg.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"agent test suite create accepts a single <name> or <path>");
|
|
}
|
|
target_arg = token;
|
|
}
|
|
|
|
if (target_arg.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test suite create <name|path> [--force]");
|
|
}
|
|
|
|
std::filesystem::path output_path(target_arg);
|
|
bool looks_like_path = target_arg.find('/') != std::string::npos ||
|
|
target_arg.find('\\') != std::string::npos ||
|
|
target_arg.find('.') != std::string::npos;
|
|
if (!looks_like_path) {
|
|
output_path = std::filesystem::path("tests") /
|
|
std::filesystem::path(target_arg + ".yaml");
|
|
} else if (output_path.extension().empty()) {
|
|
output_path.replace_extension(".yaml");
|
|
}
|
|
|
|
std::string extension = output_path.extension().string();
|
|
absl::AsciiStrToLower(&extension);
|
|
if (!extension.empty() && extension != ".yaml" && extension != ".yml") {
|
|
return absl::InvalidArgumentError(
|
|
"Only .yaml/.yml suites are supported.");
|
|
}
|
|
if (extension == ".yml") {
|
|
output_path.replace_extension(".yaml");
|
|
}
|
|
|
|
std::string default_suite_name = output_path.stem().string();
|
|
if (default_suite_name.empty()) {
|
|
default_suite_name = "New Suite";
|
|
}
|
|
|
|
std::error_code exists_ec;
|
|
if (!force && std::filesystem::exists(output_path, exists_ec) && !exists_ec) {
|
|
std::string question =
|
|
absl::StrCat("File ", output_path.string(),
|
|
" already exists. Overwrite?");
|
|
if (!PromptYesNo(question, false)) {
|
|
return absl::CancelledError("Suite creation cancelled by user");
|
|
}
|
|
force = true;
|
|
}
|
|
|
|
std::cout << "=== Test Suite Metadata ===" << std::endl;
|
|
TestSuiteDefinition suite;
|
|
suite.name = TrimWhitespace(PromptRequired("Suite name", default_suite_name));
|
|
if (suite.name.empty()) {
|
|
suite.name = default_suite_name;
|
|
}
|
|
suite.description = TrimWhitespace(
|
|
PromptWithDefault("Suite description", std::string()));
|
|
suite.version = TrimWhitespace(PromptWithDefault("Suite version", "1.0"));
|
|
if (suite.version.empty()) {
|
|
suite.version = "1.0";
|
|
}
|
|
suite.config.timeout_seconds =
|
|
PromptInt("Timeout per test (seconds)", 30, 0);
|
|
suite.config.retry_on_failure =
|
|
PromptInt("Retries per test", 0, 0);
|
|
suite.config.parallel_execution =
|
|
PromptYesNo("Enable parallel execution?", false);
|
|
|
|
std::cout << "\n=== Define Test Groups ===" << std::endl;
|
|
while (true) {
|
|
std::string group_name = TrimWhitespace(
|
|
PromptWithDefault("Add group name (leave blank to finish)",
|
|
std::string()));
|
|
if (group_name.empty()) {
|
|
break;
|
|
}
|
|
|
|
TestGroupDefinition group;
|
|
group.name = group_name;
|
|
group.description = TrimWhitespace(
|
|
PromptWithDefault(" Group description", std::string()));
|
|
std::string deps_input = TrimWhitespace(
|
|
PromptWithDefault(" Depends on (comma separated)",
|
|
std::string()));
|
|
group.depends_on = ParseCommaSeparated(deps_input);
|
|
|
|
std::cout << " Adding tests for group '" << group.name << "'" << std::endl;
|
|
while (true) {
|
|
std::string script_prompt =
|
|
absl::StrCat(" Test script path (JSON) [blank to finish group] ");
|
|
std::string script_path = TrimWhitespace(
|
|
PromptWithDefault(script_prompt, std::string()));
|
|
if (script_path.empty()) {
|
|
break;
|
|
}
|
|
|
|
TestCaseDefinition test;
|
|
test.group_name = group.name;
|
|
test.script_path = script_path;
|
|
|
|
std::string default_test_name = DeriveTestNameFromPath(script_path);
|
|
std::string name_input = TrimWhitespace(
|
|
PromptWithDefault(" Display name", default_test_name));
|
|
test.name = name_input.empty() ? default_test_name : name_input;
|
|
test.description = TrimWhitespace(
|
|
PromptWithDefault(" Test description", std::string()));
|
|
std::string tags_input = TrimWhitespace(
|
|
PromptWithDefault(" Tags (comma separated)", std::string()));
|
|
test.tags = ParseCommaSeparated(tags_input);
|
|
|
|
while (true) {
|
|
std::string param_input = TrimWhitespace(PromptWithDefault(
|
|
" Parameter key=value (blank to finish)", std::string()));
|
|
if (param_input.empty()) {
|
|
break;
|
|
}
|
|
std::string key;
|
|
std::string value;
|
|
if (!ParseKeyValueEntry(param_input, &key, &value)) {
|
|
std::cout << " Expected key=value" << std::endl;
|
|
continue;
|
|
}
|
|
test.parameters[key] = value;
|
|
}
|
|
|
|
if (test.id.empty()) {
|
|
test.id = absl::StrCat(group.name, ":", test.name);
|
|
}
|
|
|
|
std::error_code file_check_ec;
|
|
if (!std::filesystem::exists(script_path, file_check_ec) || file_check_ec) {
|
|
std::cout << " (warning: file not found)" << std::endl;
|
|
}
|
|
|
|
group.tests.push_back(std::move(test));
|
|
std::cout << std::endl;
|
|
}
|
|
|
|
if (group.tests.empty()) {
|
|
if (!PromptYesNo(" No tests added. Keep empty group?", false)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
suite.groups.push_back(std::move(group));
|
|
std::cout << std::endl;
|
|
}
|
|
|
|
if (suite.groups.empty()) {
|
|
if (!PromptYesNo("No groups defined. Create empty suite anyway?", false)) {
|
|
return absl::CancelledError("Suite creation cancelled");
|
|
}
|
|
}
|
|
|
|
int total_tests = 0;
|
|
for (const auto& group : suite.groups) {
|
|
total_tests += static_cast<int>(group.tests.size());
|
|
}
|
|
|
|
absl::Status write_status =
|
|
WriteTestSuiteToFile(suite, output_path.string(), force);
|
|
if (!write_status.ok()) {
|
|
return write_status;
|
|
}
|
|
|
|
std::cout << "\nCreated suite '" << suite.name << "' at "
|
|
<< output_path.string() << "\n";
|
|
std::cout << " Groups: " << suite.groups.size() << "\n";
|
|
std::cout << " Tests: " << total_tests << "\n";
|
|
|
|
return absl::OkStatus();
|
|
}
|
|
|
|
absl::Status HandleTestSuiteCommand(const std::vector<std::string>& arg_vec) {
|
|
if (arg_vec.empty()) {
|
|
return absl::InvalidArgumentError(
|
|
"Usage: agent test suite <run|validate|create> [options]");
|
|
}
|
|
|
|
const std::string& action = arg_vec[0];
|
|
std::vector<std::string> tail(arg_vec.begin() + 1, arg_vec.end());
|
|
|
|
if (action == "run") {
|
|
return HandleTestSuiteRunCommand(tail);
|
|
}
|
|
if (action == "validate") {
|
|
return HandleTestSuiteValidateCommand(tail);
|
|
}
|
|
if (action == "create") {
|
|
return HandleTestSuiteCreateCommand(tail);
|
|
}
|
|
|
|
return absl::InvalidArgumentError(
|
|
absl::StrCat("Unknown test suite action: ", action));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
absl::Status HandleTestCommand(const std::vector<std::string>& arg_vec) {
|
|
if (!arg_vec.empty()) {
|
|
const std::string& subcommand = arg_vec[0];
|
|
std::vector<std::string> tail(arg_vec.begin() + 1, arg_vec.end());
|
|
|
|
if (subcommand == "replay") {
|
|
return HandleTestReplayCommand(tail);
|
|
}
|
|
if (subcommand == "suite") {
|
|
return HandleTestSuiteCommand(tail);
|
|
}
|
|
if (subcommand == "status") {
|
|
return HandleTestStatusCommand(tail);
|
|
}
|
|
if (subcommand == "list") {
|
|
return HandleTestListCommand(tail);
|
|
}
|
|
if (subcommand == "results") {
|
|
return HandleTestResultsCommand(tail);
|
|
}
|
|
}
|
|
|
|
return HandleTestRunCommand(arg_vec);
|
|
}
|
|
|
|
} // namespace agent
|
|
} // namespace cli
|
|
} // namespace yaze
|