feat: Add GUI automation client and test workflow generator

- Implemented GuiAutomationClient for gRPC communication with the test harness.
- Added methods for various GUI actions: Click, Type, Wait, Assert, and Screenshot.
- Created TestWorkflowGenerator to convert natural language prompts into structured test workflows.
- Enhanced HandleTestCommand to support new command-line arguments for GUI automation.
- Updated CMakeLists.txt to include new source files for GUI automation and workflow generation.
This commit is contained in:
scawful
2025-10-02 01:01:19 -04:00
parent 286efdec6a
commit 0465d07a55
11 changed files with 2585 additions and 85 deletions

View File

@@ -0,0 +1,251 @@
// gui_automation_client.cc
// Implementation of gRPC client for YAZE GUI automation
#include "cli/service/gui_automation_client.h"
#include "absl/strings/str_format.h"
namespace yaze {
namespace cli {
GuiAutomationClient::GuiAutomationClient(const std::string& server_address)
: server_address_(server_address) {}
absl::Status GuiAutomationClient::Connect() {
#ifdef YAZE_WITH_GRPC
auto channel = grpc::CreateChannel(server_address_,
grpc::InsecureChannelCredentials());
if (!channel) {
return absl::InternalError("Failed to create gRPC channel");
}
stub_ = yaze::test::ImGuiTestHarness::NewStub(channel);
if (!stub_) {
return absl::InternalError("Failed to create gRPC stub");
}
// Test connection with a ping
auto result = Ping("connection_test");
if (!result.ok()) {
return absl::UnavailableError(
absl::StrFormat("Failed to connect to test harness at %s: %s",
server_address_, result.status().message()));
}
connected_ = true;
return absl::OkStatus();
#else
return absl::UnimplementedError(
"GUI automation requires YAZE_WITH_GRPC=ON at build time");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Ping(
const std::string& message) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::PingRequest request;
request.set_message(message);
yaze::test::PingResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Ping(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Ping RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = true;
result.message = absl::StrFormat("Server version: %s (timestamp: %s)",
response.yaze_version(),
response.timestamp_ms());
result.execution_time = std::chrono::milliseconds(0);
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Click(
const std::string& target, ClickType type) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::ClickRequest request;
request.set_target(target);
switch (type) {
case ClickType::kLeft:
request.set_type(yaze::test::ClickRequest::LEFT);
break;
case ClickType::kRight:
request.set_type(yaze::test::ClickRequest::RIGHT);
break;
case ClickType::kMiddle:
request.set_type(yaze::test::ClickRequest::MIDDLE);
break;
case ClickType::kDouble:
request.set_type(yaze::test::ClickRequest::DOUBLE);
break;
}
yaze::test::ClickResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Click(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Click RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = response.success();
result.message = response.message();
result.execution_time = std::chrono::milliseconds(
std::stoll(response.execution_time_ms()));
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Type(
const std::string& target, const std::string& text, bool clear_first) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::TypeRequest request;
request.set_target(target);
request.set_text(text);
request.set_clear_first(clear_first);
yaze::test::TypeResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Type(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Type RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = response.success();
result.message = response.message();
result.execution_time = std::chrono::milliseconds(
std::stoll(response.execution_time_ms()));
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Wait(
const std::string& condition, int timeout_ms, int poll_interval_ms) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::WaitRequest request;
request.set_condition(condition);
request.set_timeout_ms(timeout_ms);
request.set_poll_interval_ms(poll_interval_ms);
yaze::test::WaitResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Wait(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Wait RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = response.success();
result.message = response.message();
result.execution_time = std::chrono::milliseconds(
std::stoll(response.elapsed_ms()));
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Assert(
const std::string& condition) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::AssertRequest request;
request.set_condition(condition);
yaze::test::AssertResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Assert(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Assert RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = response.success();
result.message = response.message();
result.actual_value = response.actual_value();
result.expected_value = response.expected_value();
result.execution_time = std::chrono::milliseconds(0);
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
absl::StatusOr<AutomationResult> GuiAutomationClient::Screenshot(
const std::string& region, const std::string& format) {
#ifdef YAZE_WITH_GRPC
if (!stub_) {
return absl::FailedPreconditionError("Not connected. Call Connect() first.");
}
yaze::test::ScreenshotRequest request;
request.set_region(region);
request.set_format(format);
yaze::test::ScreenshotResponse response;
grpc::ClientContext context;
grpc::Status status = stub_->Screenshot(&context, request, &response);
if (!status.ok()) {
return absl::InternalError(
absl::StrFormat("Screenshot RPC failed: %s", status.error_message()));
}
AutomationResult result;
result.success = response.success();
result.message = response.message();
result.execution_time = std::chrono::milliseconds(0);
return result;
#else
return absl::UnimplementedError("gRPC not available");
#endif
}
} // namespace cli
} // namespace yaze

View File

@@ -0,0 +1,152 @@
// gui_automation_client.h
// gRPC client for automating YAZE GUI through ImGuiTestHarness service
#ifndef YAZE_CLI_SERVICE_GUI_AUTOMATION_CLIENT_H
#define YAZE_CLI_SERVICE_GUI_AUTOMATION_CLIENT_H
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include <chrono>
#include <memory>
#include <string>
#include <vector>
#ifdef YAZE_WITH_GRPC
#include <grpcpp/grpcpp.h>
#include "app/core/proto/imgui_test_harness.grpc.pb.h"
#endif
namespace yaze {
namespace cli {
/**
* @brief Type of click action to perform
*/
enum class ClickType {
kLeft,
kRight,
kMiddle,
kDouble
};
/**
* @brief Result of a GUI automation action
*/
struct AutomationResult {
bool success;
std::string message;
std::chrono::milliseconds execution_time;
std::string actual_value; // For assertions
std::string expected_value; // For assertions
};
/**
* @brief Client for automating YAZE GUI through gRPC
*
* This client wraps the ImGuiTestHarness gRPC service and provides
* a C++ API for CLI commands to drive the YAZE GUI remotely.
*
* Example usage:
* @code
* GuiAutomationClient client("localhost:50052");
* RETURN_IF_ERROR(client.Connect());
*
* auto result = client.Click("button:Overworld", ClickType::kLeft);
* if (!result.ok()) return result.status();
*
* if (!result->success) {
* return absl::InternalError(result->message);
* }
* @endcode
*/
class GuiAutomationClient {
public:
/**
* @brief Construct a new GUI automation client
* @param server_address Address of the test harness server (e.g., "localhost:50052")
*/
explicit GuiAutomationClient(const std::string& server_address);
/**
* @brief Connect to the test harness server
* @return Status indicating success or failure
*/
absl::Status Connect();
/**
* @brief Check if the server is reachable and responsive
* @param message Optional message to send in ping
* @return Result with server version and timestamp
*/
absl::StatusOr<AutomationResult> Ping(const std::string& message = "ping");
/**
* @brief Click a GUI element
* @param target Target element (format: "button:Label" or "window:Name")
* @param type Type of click (left, right, middle, double)
* @return Result indicating success/failure and execution time
*/
absl::StatusOr<AutomationResult> Click(const std::string& target,
ClickType type = ClickType::kLeft);
/**
* @brief Type text into an input field
* @param target Target input field (format: "input:Label")
* @param text Text to type
* @param clear_first Whether to clear existing text before typing
* @return Result indicating success/failure and execution time
*/
absl::StatusOr<AutomationResult> Type(const std::string& target,
const std::string& text,
bool clear_first = false);
/**
* @brief Wait for a condition to be met
* @param condition Condition to wait for (e.g., "window_visible:Editor")
* @param timeout_ms Maximum time to wait in milliseconds
* @param poll_interval_ms How often to check the condition
* @return Result indicating whether condition was met
*/
absl::StatusOr<AutomationResult> Wait(const std::string& condition,
int timeout_ms = 5000,
int poll_interval_ms = 100);
/**
* @brief Assert a GUI state condition
* @param condition Condition to assert (e.g., "visible:Window Name")
* @return Result with actual vs expected values
*/
absl::StatusOr<AutomationResult> Assert(const std::string& condition);
/**
* @brief Capture a screenshot
* @param region Region to capture ("full", "window", "element")
* @param format Image format ("PNG", "JPEG")
* @return Result with file path if successful
*/
absl::StatusOr<AutomationResult> Screenshot(const std::string& region = "full",
const std::string& format = "PNG");
/**
* @brief Check if client is connected
*/
bool IsConnected() const { return connected_; }
/**
* @brief Get the server address
*/
const std::string& ServerAddress() const { return server_address_; }
private:
#ifdef YAZE_WITH_GRPC
std::unique_ptr<yaze::test::ImGuiTestHarness::Stub> stub_;
#endif
std::string server_address_;
bool connected_ = false;
};
} // namespace cli
} // namespace yaze
#endif // YAZE_CLI_SERVICE_GUI_AUTOMATION_CLIENT_H

View File

@@ -0,0 +1,227 @@
// test_workflow_generator.cc
// Implementation of natural language to test workflow conversion
#include "cli/service/test_workflow_generator.h"
#include "absl/strings/ascii.h"
#include "absl/strings/match.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/strings/str_replace.h"
#include <regex>
namespace yaze {
namespace cli {
std::string TestStep::ToString() const {
switch (type) {
case TestStepType::kClick:
return absl::StrFormat("Click(%s)", target);
case TestStepType::kType:
return absl::StrFormat("Type(%s, \"%s\"%s)", target, text,
clear_first ? ", clear_first" : "");
case TestStepType::kWait:
return absl::StrFormat("Wait(%s, %dms)", condition, timeout_ms);
case TestStepType::kAssert:
return absl::StrFormat("Assert(%s)", condition);
case TestStepType::kScreenshot:
return "Screenshot()";
}
return "Unknown";
}
std::string TestWorkflow::ToString() const {
std::string result = absl::StrCat("Workflow: ", description, "\n");
for (size_t i = 0; i < steps.size(); ++i) {
absl::StrAppend(&result, " ", i + 1, ". ", steps[i].ToString(), "\n");
}
return result;
}
absl::StatusOr<TestWorkflow> TestWorkflowGenerator::GenerateWorkflow(
const std::string& prompt) {
std::string normalized_prompt = absl::AsciiStrToLower(prompt);
// Try pattern matching in order of specificity
std::string editor_name, input_name, text, button_name;
// Pattern 1: "Open <Editor> and verify it loads"
if (MatchesOpenAndVerify(normalized_prompt, &editor_name)) {
return BuildOpenAndVerifyWorkflow(editor_name);
}
// Pattern 2: "Open <Editor> editor"
if (MatchesOpenEditor(normalized_prompt, &editor_name)) {
return BuildOpenEditorWorkflow(editor_name);
}
// Pattern 3: "Type '<text>' in <input>"
if (MatchesTypeInput(normalized_prompt, &input_name, &text)) {
return BuildTypeInputWorkflow(input_name, text);
}
// Pattern 4: "Click <button>"
if (MatchesClickButton(normalized_prompt, &button_name)) {
return BuildClickButtonWorkflow(button_name);
}
// If no patterns match, return helpful error
return absl::InvalidArgumentError(
absl::StrFormat(
"Unable to parse prompt: \"%s\"\n\n"
"Supported patterns:\n"
" - Open <Editor> editor\n"
" - Open <Editor> and verify it loads\n"
" - Type '<text>' in <input>\n"
" - Click <button>\n\n"
"Examples:\n"
" - Open Overworld editor\n"
" - Open Dungeon editor and verify it loads\n"
" - Type 'zelda3.sfc' in filename input\n"
" - Click Open ROM button",
prompt));
}
bool TestWorkflowGenerator::MatchesOpenEditor(const std::string& prompt,
std::string* editor_name) {
// Match: "open <name> editor" or "open <name>"
std::regex pattern(R"(open\s+(\w+)(?:\s+editor)?)");
std::smatch match;
if (std::regex_search(prompt, match, pattern) && match.size() > 1) {
*editor_name = match[1].str();
return true;
}
return false;
}
bool TestWorkflowGenerator::MatchesOpenAndVerify(const std::string& prompt,
std::string* editor_name) {
// Match: "open <name> and verify" or "open <name> editor and verify it loads"
std::regex pattern(R"(open\s+(\w+)(?:\s+editor)?\s+and\s+verify)");
std::smatch match;
if (std::regex_search(prompt, match, pattern) && match.size() > 1) {
*editor_name = match[1].str();
return true;
}
return false;
}
bool TestWorkflowGenerator::MatchesTypeInput(const std::string& prompt,
std::string* input_name,
std::string* text) {
// Match: "type 'text' in <input>" or "type \"text\" in <input>"
std::regex pattern(R"(type\s+['"]([^'"]+)['"]\s+in(?:to)?\s+(\w+))");
std::smatch match;
if (std::regex_search(prompt, match, pattern) && match.size() > 2) {
*text = match[1].str();
*input_name = match[2].str();
return true;
}
return false;
}
bool TestWorkflowGenerator::MatchesClickButton(const std::string& prompt,
std::string* button_name) {
// Match: "click <button>" or "click <button> button"
std::regex pattern(R"(click\s+([\w\s]+?)(?:\s+button)?\s*$)");
std::smatch match;
if (std::regex_search(prompt, match, pattern) && match.size() > 1) {
*button_name = match[1].str();
return true;
}
return false;
}
std::string TestWorkflowGenerator::NormalizeEditorName(const std::string& name) {
std::string normalized = name;
// Capitalize first letter
if (!normalized.empty()) {
normalized[0] = std::toupper(normalized[0]);
}
// Add " Editor" suffix if not present
if (!absl::StrContains(absl::AsciiStrToLower(normalized), "editor")) {
absl::StrAppend(&normalized, " Editor");
}
return normalized;
}
TestWorkflow TestWorkflowGenerator::BuildOpenEditorWorkflow(
const std::string& editor_name) {
std::string normalized_name = NormalizeEditorName(editor_name);
TestWorkflow workflow;
workflow.description = absl::StrFormat("Open %s", normalized_name);
// Step 1: Click the editor button
TestStep click_step;
click_step.type = TestStepType::kClick;
click_step.target = absl::StrFormat("button:%s",
absl::StrReplaceAll(normalized_name,
{{" Editor", ""}}));
workflow.steps.push_back(click_step);
// Step 2: Wait for editor window to appear
TestStep wait_step;
wait_step.type = TestStepType::kWait;
wait_step.condition = absl::StrFormat("window_visible:%s", normalized_name);
wait_step.timeout_ms = 5000;
workflow.steps.push_back(wait_step);
return workflow;
}
TestWorkflow TestWorkflowGenerator::BuildOpenAndVerifyWorkflow(
const std::string& editor_name) {
// Start with basic open workflow
TestWorkflow workflow = BuildOpenEditorWorkflow(editor_name);
workflow.description = absl::StrFormat("Open and verify %s",
NormalizeEditorName(editor_name));
// Add assertion step
TestStep assert_step;
assert_step.type = TestStepType::kAssert;
assert_step.condition = absl::StrFormat("visible:%s",
NormalizeEditorName(editor_name));
workflow.steps.push_back(assert_step);
return workflow;
}
TestWorkflow TestWorkflowGenerator::BuildTypeInputWorkflow(
const std::string& input_name, const std::string& text) {
TestWorkflow workflow;
workflow.description = absl::StrFormat("Type '%s' into %s", text, input_name);
// Step 1: Click input to focus
TestStep click_step;
click_step.type = TestStepType::kClick;
click_step.target = absl::StrFormat("input:%s", input_name);
workflow.steps.push_back(click_step);
// Step 2: Type the text
TestStep type_step;
type_step.type = TestStepType::kType;
type_step.target = absl::StrFormat("input:%s", input_name);
type_step.text = text;
type_step.clear_first = true;
workflow.steps.push_back(type_step);
return workflow;
}
TestWorkflow TestWorkflowGenerator::BuildClickButtonWorkflow(
const std::string& button_name) {
TestWorkflow workflow;
workflow.description = absl::StrFormat("Click '%s' button", button_name);
TestStep click_step;
click_step.type = TestStepType::kClick;
click_step.target = absl::StrFormat("button:%s", button_name);
workflow.steps.push_back(click_step);
return workflow;
}
} // namespace cli
} // namespace yaze

View File

@@ -0,0 +1,106 @@
// test_workflow_generator.h
// Converts natural language prompts into GUI automation workflows
#ifndef YAZE_CLI_SERVICE_TEST_WORKFLOW_GENERATOR_H
#define YAZE_CLI_SERVICE_TEST_WORKFLOW_GENERATOR_H
#include "absl/status/statusor.h"
#include <string>
#include <vector>
namespace yaze {
namespace cli {
/**
* @brief Type of test step to execute
*/
enum class TestStepType {
kClick, // Click a button or element
kType, // Type text into an input
kWait, // Wait for a condition
kAssert, // Assert a condition is true
kScreenshot // Capture a screenshot
};
/**
* @brief A single step in a GUI test workflow
*/
struct TestStep {
TestStepType type;
std::string target; // Widget/element target (e.g., "button:Overworld")
std::string text; // Text to type (for kType steps)
std::string condition; // Condition to wait for or assert
int timeout_ms = 5000; // Timeout for wait operations
bool clear_first = false; // Clear text before typing
std::string ToString() const;
};
/**
* @brief A complete GUI test workflow
*/
struct TestWorkflow {
std::string description;
std::vector<TestStep> steps;
std::string ToString() const;
};
/**
* @brief Generates GUI test workflows from natural language prompts
*
* This class uses pattern matching to convert user prompts into
* structured test workflows that can be executed by GuiAutomationClient.
*
* Example prompts:
* - "Open Overworld editor" → Click button, Wait for window
* - "Open Dungeon editor and verify it loads" → Click, Wait, Assert
* - "Type 'zelda3.sfc' in filename input" → Click input, Type text
*
* Usage:
* @code
* TestWorkflowGenerator generator;
* auto workflow = generator.GenerateWorkflow("Open Overworld editor");
* if (!workflow.ok()) return workflow.status();
*
* for (const auto& step : workflow->steps) {
* std::cout << step.ToString() << "\n";
* }
* @endcode
*/
class TestWorkflowGenerator {
public:
TestWorkflowGenerator() = default;
/**
* @brief Generate a test workflow from a natural language prompt
* @param prompt Natural language description of desired GUI actions
* @return TestWorkflow or error if prompt is unsupported
*/
absl::StatusOr<TestWorkflow> GenerateWorkflow(const std::string& prompt);
private:
// Pattern matchers for different prompt types
bool MatchesOpenEditor(const std::string& prompt, std::string* editor_name);
bool MatchesOpenAndVerify(const std::string& prompt, std::string* editor_name);
bool MatchesTypeInput(const std::string& prompt, std::string* input_name,
std::string* text);
bool MatchesClickButton(const std::string& prompt, std::string* button_name);
bool MatchesMultiStep(const std::string& prompt);
// Workflow builders
TestWorkflow BuildOpenEditorWorkflow(const std::string& editor_name);
TestWorkflow BuildOpenAndVerifyWorkflow(const std::string& editor_name);
TestWorkflow BuildTypeInputWorkflow(const std::string& input_name,
const std::string& text);
TestWorkflow BuildClickButtonWorkflow(const std::string& button_name);
// Helper to normalize editor names (e.g., "overworld" → "Overworld Editor")
std::string NormalizeEditorName(const std::string& name);
};
} // namespace cli
} // namespace yaze
#endif // YAZE_CLI_SERVICE_TEST_WORKFLOW_GENERATOR_H