- Updated README.md to reflect the completion of IT-01 and the transition to end-to-end validation phase. - Introduced a new end-to-end test script (scripts/test_harness_e2e.sh) for validating all RPC methods of the ImGuiTestHarness gRPC service. - Implemented dynamic test functionality in ImGuiTestHarnessService for Type, Wait, and Assert methods, utilizing ImGuiTestEngine. - Enhanced error handling and response messages for better clarity during test execution. - Updated existing methods to support dynamic test registration and execution, ensuring robust interaction with the GUI elements.
30 KiB
IT-01 Phase 2: ImGuiTestEngine Integration Guide
Date: October 1, 2025
Status: Implementation Ready
Estimated Time: 6-8 hours
Prerequisites: ✅ Phase 1 Complete (gRPC infrastructure working)
📋 Overview
This guide walks through implementing actual GUI automation in the gRPC test harness by integrating with YAZE's existing ImGuiTestEngine infrastructure.
What We're Building: Transform stub RPC handlers into real GUI automation that can:
- Click buttons and UI elements
- Type text into input fields
- Wait for windows/elements to appear
- Assert UI state
- Capture screenshots
🎯 Success Criteria
By the end of this implementation:
ClickRPC can click actual ImGui widgetsTypeRPC can input text into fieldsWaitRPC polls for conditions with timeoutAssertRPC validates UI stateScreenshotRPC captures framebuffer (basic implementation)- End-to-end test: "Open ROM via gRPC" works
📚 Architecture Review
Current State
gRPC Client (grpcurl/z3ed)
↓ RPC call
ImGuiTestHarnessServer
↓ calls
ImGuiTestHarnessServiceImpl::Click()
↓ CURRENTLY: stub implementation
✅ TODO: integrate with ImGuiTestEngine
Target State
gRPC Client (grpcurl/z3ed)
↓ RPC call
ImGuiTestHarnessServer
↓ calls
ImGuiTestHarnessServiceImpl::Click()
↓ accesses
TestManager::GetUITestEngine()
↓ calls
ImGuiTestEngine_ItemClick()
↓ interacts with
ImGui Widgets (actual GUI)
🔍 Understanding ImGuiTestEngine
YAZE already has ImGuiTestEngine integrated. Let's understand how it works:
Key Components
-
TestManager (
src/app/test/test_manager.h)- Singleton:
TestManager::GetInstance() - Provides:
GetUITestEngine()→ returnsImGuiTestEngine*
- Singleton:
-
ImGuiTestEngine (from
src/lib/imgui_test_engine/)- C API for GUI automation
- Key functions:
ImGuiTestEngine_FindItemByLabel()- Find widget by labelImGuiTestEngine_ItemClick()- Simulate clickImGuiTestEngine_ItemInputValue()- Input textImGuiTestEngine_GetWindowByRef()- Check window existence
-
ImGuiTestContext
- Per-test context object
- Needed for most test engine calls
- Created via
ImGuiTestEngine_CreateContext()
Sample ImGuiTestEngine Usage
// From existing YAZE tests (conceptual example):
#include "imgui_test_engine/imgui_te_engine.h"
#include "imgui_test_engine/imgui_te_context.h"
// 1. Get engine instance
ImGuiTestEngine* engine = test_manager->GetUITestEngine();
// 2. Create test context
ImGuiTestContext* ctx = ImGuiTestEngine_CreateContext(engine, "test_name");
// 3. Find a button by label
ImGuiTestItemInfo* button = ImGuiTestEngine_FindItemByLabel(
ctx,
"Open ROM", // Button label
NULL // Parent window (NULL = search all)
);
// 4. Click the button
if (button) {
ImGuiTestEngine_ItemClick(ctx, button->ID, ImGuiMouseButton_Left);
}
// 5. Wait for a window to appear
ImGuiTestEngine_GetWindowByRef(ctx, "Overworld Editor", ImGuiTestOpFlags_None);
// 6. Input text
ImGuiTestItemInfo* input = ImGuiTestEngine_FindItemByLabel(ctx, "Filename", NULL);
if (input) {
ImGuiTestEngine_ItemInputValue(ctx, input->ID, "zelda3.sfc");
}
// 7. Cleanup
ImGuiTestEngine_DestroyContext(ctx);
🛠️ Implementation Plan
Task 1: Access TestManager from gRPC Service (30 min)
Goal: Connect gRPC service to YAZE's TestManager singleton.
Challenge: The gRPC service is a standalone component that needs to access YAZE's core systems.
Solution: Pass TestManager reference during server initialization.
Step 1.1: Update Service Interface
Edit src/app/core/imgui_test_harness_service.h:
#ifndef YAZE_APP_CORE_IMGUI_TEST_HARNESS_SERVICE_H_
#define YAZE_APP_CORE_IMGUI_TEST_HARNESS_SERVICE_H_
#ifdef YAZE_WITH_GRPC
#include <memory>
#include <grpcpp/grpcpp.h>
#include "proto/imgui_test_harness.grpc.pb.h"
#include "absl/status/status.h"
// Forward declarations
namespace yaze {
namespace test {
class TestManager;
}
}
namespace yaze {
namespace test {
class ImGuiTestHarnessServiceImpl {
public:
// Constructor now takes TestManager reference
explicit ImGuiTestHarnessServiceImpl(TestManager* test_manager)
: test_manager_(test_manager) {}
absl::Status Ping(const PingRequest* request, PingResponse* response);
absl::Status Click(const ClickRequest* request, ClickResponse* response);
absl::Status Type(const TypeRequest* request, TypeResponse* response);
absl::Status Wait(const WaitRequest* request, WaitResponse* response);
absl::Status Assert(const AssertRequest* request, AssertResponse* response);
absl::Status Screenshot(const ScreenshotRequest* request,
ScreenshotResponse* response);
private:
TestManager* test_manager_; // Non-owning pointer
};
class ImGuiTestHarnessServer {
public:
static ImGuiTestHarnessServer& Instance();
// Updated: now requires TestManager
absl::Status Start(int port, TestManager* test_manager);
void Shutdown();
bool IsRunning() const { return server_ != nullptr; }
int Port() const { return port_; }
private:
ImGuiTestHarnessServer() = default;
~ImGuiTestHarnessServer();
std::unique_ptr<grpc::Server> server_;
std::unique_ptr<ImGuiTestHarnessServiceImpl> service_;
std::unique_ptr<class ImGuiTestHarnessServiceGrpc> grpc_service_;
int port_ = 0;
};
} // namespace test
} // namespace yaze
#endif // YAZE_WITH_GRPC
#endif // YAZE_APP_CORE_IMGUI_TEST_HARNESS_SERVICE_H_
Step 1.2: Update Server Startup
Edit src/app/core/imgui_test_harness_service.cc:
absl::Status ImGuiTestHarnessServer::Start(int port, TestManager* test_manager) {
if (server_) {
return absl::FailedPreconditionError("Server already running");
}
if (!test_manager) {
return absl::InvalidArgumentError("TestManager cannot be null");
}
// Create service with TestManager reference
service_ = std::make_unique<ImGuiTestHarnessServiceImpl>(test_manager);
// ... rest of startup code remains the same
grpc_service_ = std::make_unique<ImGuiTestHarnessServiceGrpc>(service_.get());
std::string server_address = absl::StrFormat("0.0.0.0:%d", port);
grpc::ServerBuilder builder;
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
builder.RegisterService(grpc_service_.get());
server_ = builder.BuildAndStart();
if (!server_) {
return absl::InternalError(
absl::StrFormat("Failed to start gRPC server on %s", server_address));
}
port_ = port;
std::cout << "✓ ImGuiTestHarness gRPC server listening on " << server_address
<< " (with TestManager integration)\n";
return absl::OkStatus();
}
Step 1.3: Update main.cc Server Startup
Edit src/app/main.cc (around the flag handling section):
#ifdef YAZE_WITH_GRPC
if (absl::GetFlag(FLAGS_enable_test_harness)) {
// Get TestManager instance
auto* test_manager = yaze::test::TestManager::GetInstance();
if (!test_manager) {
std::cerr << "ERROR: TestManager not initialized. "
<< "Cannot start test harness.\n";
return 1;
}
auto& harness = yaze::test::ImGuiTestHarnessServer::Instance();
auto status = harness.Start(
absl::GetFlag(FLAGS_test_harness_port),
test_manager // Pass TestManager reference
);
if (!status.ok()) {
std::cerr << "Failed to start test harness: "
<< status.message() << "\n";
return 1;
}
}
#endif
Task 2: Implement Click Handler (2-3 hours)
Goal: Make Click RPC actually click ImGui widgets.
Step 2.1: Understand Target Format
We'll support these target formats:
"button:Open ROM"- Click a button with label "Open ROM""menu:File→Open"- Click menu item"checkbox:Enable Feature"- Toggle checkbox"item:#widget_id"- Click by ImGui ID
Step 2.2: Implement Click Handler
Edit src/app/core/imgui_test_harness_service.cc:
#include "app/test/test_manager.h"
#include "imgui_test_engine/imgui_te_engine.h"
#include "imgui_test_engine/imgui_te_context.h"
absl::Status ImGuiTestHarnessServiceImpl::Click(
const ClickRequest* request,
ClickResponse* response) {
auto start = std::chrono::steady_clock::now();
// Validate test manager
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
return absl::OkStatus();
}
// Get ImGuiTestEngine
ImGuiTestEngine* engine = test_manager_->GetUITestEngine();
if (!engine) {
response->set_success(false);
response->set_message("ImGuiTestEngine not initialized");
return absl::OkStatus();
}
// Parse target: "button:Open ROM" -> type=button, label="Open ROM"
std::string target = request->target();
size_t colon_pos = target.find(':');
if (colon_pos == std::string::npos) {
response->set_success(false);
response->set_message("Invalid target format. Use 'type:label' (e.g. 'button:Open ROM')");
return absl::OkStatus();
}
std::string widget_type = target.substr(0, colon_pos);
std::string widget_label = target.substr(colon_pos + 1);
// Create temporary test context
std::string context_name = absl::StrFormat("grpc_click_%lld",
std::chrono::system_clock::now().time_since_epoch().count());
ImGuiTestContext* ctx = ImGuiTestEngine_CreateContext(engine, context_name.c_str());
if (!ctx) {
response->set_success(false);
response->set_message("Failed to create test context");
return absl::OkStatus();
}
// Find the widget by label
ImGuiTestItemInfo* item = ImGuiTestEngine_FindItemByLabel(
ctx,
widget_label.c_str(),
NULL // Search all windows
);
if (!item) {
// Try searching with prefix if it's a menu item (e.g., "File→Open")
if (widget_label.find("→") != std::string::npos ||
widget_label.find("->") != std::string::npos) {
// For menu items, ImGui might need the full path
std::string menu_path = widget_label;
// Replace → with / for ImGui test engine format
size_t arrow_pos = menu_path.find("→");
if (arrow_pos != std::string::npos) {
menu_path.replace(arrow_pos, 3, "/"); // UTF-8 arrow is 3 bytes
}
item = ImGuiTestEngine_FindItemByLabel(ctx, menu_path.c_str(), NULL);
}
}
bool success = false;
std::string message;
if (!item) {
message = absl::StrFormat(
"Widget not found: %s '%s'. "
"Hint: Use ImGui label text exactly as shown in UI.",
widget_type, widget_label);
} else {
// Convert click type
ImGuiMouseButton mouse_button = ImGuiMouseButton_Left;
switch (request->type()) {
case ClickRequest::LEFT:
mouse_button = ImGuiMouseButton_Left;
break;
case ClickRequest::RIGHT:
mouse_button = ImGuiMouseButton_Right;
break;
case ClickRequest::DOUBLE:
// ImGui doesn't have direct double-click, simulate two clicks
ImGuiTestEngine_ItemClick(ctx, item->ID, ImGuiMouseButton_Left);
ImGuiTestEngine_ItemClick(ctx, item->ID, ImGuiMouseButton_Left);
success = true;
message = absl::StrFormat("Double-clicked %s '%s'", widget_type, widget_label);
goto cleanup; // Skip single click below
case ClickRequest::MIDDLE:
mouse_button = ImGuiMouseButton_Middle;
break;
}
// Perform the click
ImGuiTestEngine_ItemClick(ctx, item->ID, mouse_button);
success = true;
message = absl::StrFormat("Clicked %s '%s'", widget_type, widget_label);
}
cleanup:
// Cleanup context
ImGuiTestEngine_DestroyContext(ctx);
// Calculate execution time
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
response->set_success(success);
response->set_message(message);
response->set_execution_time_ms(elapsed.count());
return absl::OkStatus();
}
Step 2.3: Test Click Implementation
# Rebuild with changes
cmake --build build-grpc-test --target yaze -j8
# Start YAZE with test harness
./build-grpc-test/bin/yaze.app/Contents/MacOS/yaze --enable_test_harness &
# Test clicking a button (adjust label to match actual YAZE UI)
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
-d '{"target":"button:Open ROM","type":"LEFT"}' \
127.0.0.1:50051 yaze.test.ImGuiTestHarness/Click
# Expected: If "Open ROM" button exists, it will be clicked!
# You should see the file dialog open in YAZE
Task 3: Implement Type Handler (1-2 hours)
Goal: Input text into ImGui input fields.
Edit src/app/core/imgui_test_harness_service.cc:
absl::Status ImGuiTestHarnessServiceImpl::Type(
const TypeRequest* request,
TypeResponse* response) {
auto start = std::chrono::steady_clock::now();
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
return absl::OkStatus();
}
ImGuiTestEngine* engine = test_manager_->GetUITestEngine();
if (!engine) {
response->set_success(false);
response->set_message("ImGuiTestEngine not initialized");
return absl::OkStatus();
}
// Parse target
std::string target = request->target();
size_t colon_pos = target.find(':');
if (colon_pos == std::string::npos) {
response->set_success(false);
response->set_message("Invalid target format. Use 'type:label'");
return absl::OkStatus();
}
std::string widget_type = target.substr(0, colon_pos);
std::string widget_label = target.substr(colon_pos + 1);
// Create test context
std::string context_name = absl::StrFormat("grpc_type_%lld",
std::chrono::system_clock::now().time_since_epoch().count());
ImGuiTestContext* ctx = ImGuiTestEngine_CreateContext(engine, context_name.c_str());
if (!ctx) {
response->set_success(false);
response->set_message("Failed to create test context");
return absl::OkStatus();
}
// Find the input field
ImGuiTestItemInfo* item = ImGuiTestEngine_FindItemByLabel(
ctx, widget_label.c_str(), NULL);
bool success = false;
std::string message;
if (!item) {
message = absl::StrFormat("Input field not found: %s", widget_label);
} else {
// Clear existing text if requested
if (request->clear_first()) {
// Click to focus
ImGuiTestEngine_ItemClick(ctx, item->ID, ImGuiMouseButton_Left);
// Select all (Ctrl+A or Cmd+A on macOS)
#ifdef __APPLE__
ImGuiTestEngine_KeyDown(ctx, ImGuiMod_Super); // Cmd key
#else
ImGuiTestEngine_KeyDown(ctx, ImGuiMod_Ctrl);
#endif
ImGuiTestEngine_KeyPress(ctx, ImGuiKey_A);
#ifdef __APPLE__
ImGuiTestEngine_KeyUp(ctx, ImGuiMod_Super);
#else
ImGuiTestEngine_KeyUp(ctx, ImGuiMod_Ctrl);
#endif
// Delete
ImGuiTestEngine_KeyPress(ctx, ImGuiKey_Delete);
}
// Input the new text
ImGuiTestEngine_ItemInputValue(ctx, item->ID, request->text().c_str());
success = true;
message = absl::StrFormat("Typed '%s' into %s",
request->text(), widget_label);
}
ImGuiTestEngine_DestroyContext(ctx);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
response->set_success(success);
response->set_message(message);
response->set_execution_time_ms(elapsed.count());
return absl::OkStatus();
}
Task 4: Implement Wait Handler (2 hours)
Goal: Poll for conditions with timeout.
absl::Status ImGuiTestHarnessServiceImpl::Wait(
const WaitRequest* request,
WaitResponse* response) {
auto start = std::chrono::steady_clock::now();
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
return absl::OkStatus();
}
ImGuiTestEngine* engine = test_manager_->GetUITestEngine();
if (!engine) {
response->set_success(false);
response->set_message("ImGuiTestEngine not initialized");
return absl::OkStatus();
}
// Parse condition: "window_visible:Overworld Editor"
std::string condition = request->condition();
size_t colon_pos = condition.find(':');
if (colon_pos == std::string::npos) {
response->set_success(false);
response->set_message("Invalid condition format. Use 'type:value'");
return absl::OkStatus();
}
std::string condition_type = condition.substr(0, colon_pos);
std::string condition_value = condition.substr(colon_pos + 1);
// Get timeout and poll interval
int timeout_ms = request->timeout_ms() > 0 ? request->timeout_ms() : 5000;
int poll_interval_ms = request->poll_interval_ms() > 0 ?
request->poll_interval_ms() : 100;
// Create test context
std::string context_name = absl::StrFormat("grpc_wait_%lld",
std::chrono::system_clock::now().time_since_epoch().count());
ImGuiTestContext* ctx = ImGuiTestEngine_CreateContext(engine, context_name.c_str());
if (!ctx) {
response->set_success(false);
response->set_message("Failed to create test context");
return absl::OkStatus();
}
bool condition_met = false;
auto poll_start = std::chrono::steady_clock::now();
// Poll loop
while (true) {
auto elapsed_poll = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - poll_start);
if (elapsed_poll.count() >= timeout_ms) {
// Timeout
break;
}
// Check condition based on type
if (condition_type == "window_visible") {
ImGuiWindow* window = ImGuiTestEngine_GetWindowByRef(
ctx, condition_value.c_str(), ImGuiTestOpFlags_None);
if (window != nullptr) {
condition_met = true;
break;
}
} else if (condition_type == "element_visible") {
ImGuiTestItemInfo* item = ImGuiTestEngine_FindItemByLabel(
ctx, condition_value.c_str(), NULL);
if (item != nullptr) {
condition_met = true;
break;
}
} else if (condition_type == "element_enabled") {
ImGuiTestItemInfo* item = ImGuiTestEngine_FindItemByLabel(
ctx, condition_value.c_str(), NULL);
if (item != nullptr && !(item->StatusFlags & ImGuiItemStatusFlags_Disabled)) {
condition_met = true;
break;
}
} else {
// Unknown condition type
response->set_success(false);
response->set_message(
absl::StrFormat("Unknown condition type: %s. "
"Supported: window_visible, element_visible, element_enabled",
condition_type));
ImGuiTestEngine_DestroyContext(ctx);
return absl::OkStatus();
}
// Sleep before next poll
std::this_thread::sleep_for(std::chrono::milliseconds(poll_interval_ms));
}
ImGuiTestEngine_DestroyContext(ctx);
auto total_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
response->set_success(condition_met);
response->set_message(
condition_met
? absl::StrFormat("Condition '%s' met after %lld ms",
condition, total_elapsed.count())
: absl::StrFormat("Timeout waiting for condition '%s' (waited %d ms)",
condition, timeout_ms));
response->set_elapsed_ms(total_elapsed.count());
return absl::OkStatus();
}
Task 5: Implement Assert Handler (1-2 hours)
Goal: Validate UI state and return detailed results.
absl::Status ImGuiTestHarnessServiceImpl::Assert(
const AssertRequest* request,
AssertResponse* response) {
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
return absl::OkStatus();
}
ImGuiTestEngine* engine = test_manager_->GetUITestEngine();
if (!engine) {
response->set_success(false);
response->set_message("ImGuiTestEngine not initialized");
return absl::OkStatus();
}
// Parse condition: "visible:MainWindow" or "enabled:button:Save"
std::string condition = request->condition();
size_t colon_pos = condition.find(':');
if (colon_pos == std::string::npos) {
response->set_success(false);
response->set_message("Invalid condition format");
return absl::OkStatus();
}
std::string assertion_type = condition.substr(0, colon_pos);
std::string target = condition.substr(colon_pos + 1);
// Create test context
std::string context_name = absl::StrFormat("grpc_assert_%lld",
std::chrono::system_clock::now().time_since_epoch().count());
ImGuiTestContext* ctx = ImGuiTestEngine_CreateContext(engine, context_name.c_str());
if (!ctx) {
response->set_success(false);
response->set_message("Failed to create test context");
return absl::OkStatus();
}
bool assertion_passed = false;
std::string actual_value;
std::string message;
if (assertion_type == "visible") {
// Check if window or element is visible
ImGuiWindow* window = ImGuiTestEngine_GetWindowByRef(
ctx, target.c_str(), ImGuiTestOpFlags_None);
assertion_passed = (window != nullptr);
actual_value = assertion_passed ? "visible" : "not visible";
message = absl::StrFormat("Assertion '%s' %s",
condition,
assertion_passed ? "passed" : "failed");
} else if (assertion_type == "enabled") {
// Check if element is enabled
ImGuiTestItemInfo* item = ImGuiTestEngine_FindItemByLabel(
ctx, target.c_str(), NULL);
if (item) {
assertion_passed = !(item->StatusFlags & ImGuiItemStatusFlags_Disabled);
actual_value = assertion_passed ? "enabled" : "disabled";
} else {
assertion_passed = false;
actual_value = "not found";
}
message = absl::StrFormat("Assertion '%s' %s (actual: %s)",
condition,
assertion_passed ? "passed" : "failed",
actual_value);
} else {
message = absl::StrFormat("Unknown assertion type: %s", assertion_type);
}
ImGuiTestEngine_DestroyContext(ctx);
response->set_success(assertion_passed);
response->set_message(message);
response->set_actual_value(actual_value);
response->set_expected_value(request->expected());
return absl::OkStatus();
}
Task 6: Implement Screenshot Handler (2-3 hours)
Goal: Capture framebuffer and return as PNG/JPEG.
Note: This is more complex and requires access to OpenGL/rendering context. For now, implement a basic version.
#include <fstream>
#include "imgui_impl_opengl3.h" // Or appropriate backend
absl::Status ImGuiTestHarnessServiceImpl::Screenshot(
const ScreenshotRequest* request,
ScreenshotResponse* response) {
// TODO: Implement actual screenshot capture
// This requires:
// 1. Access to OpenGL framebuffer or SDL surface
// 2. Image encoding library (stb_image_write or similar)
// 3. Base64 encoding for transmission via gRPC
// For Phase 2, return a placeholder
response->set_success(false);
response->set_message("Screenshot capture not yet implemented. "
"Requires framebuffer access and image encoding.");
response->set_file_path("");
response->set_file_size_bytes(0);
return absl::OkStatus();
}
Future Enhancement: Full screenshot implementation
// Pseudo-code for future implementation:
// 1. Get framebuffer dimensions
// 2. Read pixels: glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels)
// 3. Flip vertically (OpenGL origin is bottom-left)
// 4. Encode as PNG: stbi_write_png(filename, width, height, 3, pixels, width*3)
// 5. Read file and return bytes in response
// 6. Optionally: base64 encode for direct inclusion in response
🧪 Testing & Validation
Test 1: Click a Button
# Start YAZE with test harness
./build-grpc-test/bin/yaze.app/Contents/MacOS/yaze --enable_test_harness --rom assets/zelda3.sfc &
# Wait for GUI to load (2-3 seconds)
sleep 3
# Try clicking the "Overworld" button (adjust label as needed)
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
-d '{"target":"button:Overworld","type":"LEFT"}' \
127.0.0.1:50051 yaze.test.ImGuiTestHarness/Click
# Expected: Overworld editor opens in YAZE
Test 2: Type into Input Field
# Click "Open ROM" to open file dialog
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
-d '{"target":"button:Open ROM","type":"LEFT"}' \
127.0.0.1:50051 yaze.test.ImGuiTestHarness/Click
# Wait for dialog to appear
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
-d '{"condition":"window_visible:Open File","timeout_ms":3000,"poll_interval_ms":100}' \
127.0.0.1:50051 yaze.test.ImGuiTestHarness/Wait
# Type filename
grpcurl -plaintext -import-path src/app/core/proto -proto imgui_test_harness.proto \
-d '{"target":"input:Filename","text":"zelda3.sfc","clear_first":true}' \
127.0.0.1:50051 yaze.test.ImGuiTestHarness/Type
Test 3: End-to-End Workflow
Create test_scripts/open_rom_via_grpc.sh:
#!/bin/bash
# Test script: Open ROM file via gRPC
set -e # Exit on error
SERVER="127.0.0.1:50051"
PROTO_PATH="src/app/core/proto"
PROTO_FILE="imgui_test_harness.proto"
echo "🧪 Testing: Open ROM via gRPC"
echo "1. Ping test harness..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"message":"test"}' $SERVER yaze.test.ImGuiTestHarness/Ping
echo "2. Click 'Open ROM' button..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"target":"button:Open ROM","type":"LEFT"}' \
$SERVER yaze.test.ImGuiTestHarness/Click
echo "3. Wait for file dialog..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"condition":"window_visible:Open File","timeout_ms":5000}' \
$SERVER yaze.test.ImGuiTestHarness/Wait
echo "4. Type filename..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"target":"input:File path","text":"assets/zelda3.sfc","clear_first":true}' \
$SERVER yaze.test.ImGuiTestHarness/Type
echo "5. Click 'OK' button..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"target":"button:OK","type":"LEFT"}' \
$SERVER yaze.test.ImGuiTestHarness/Click
echo "6. Assert ROM is loaded..."
grpcurl -plaintext -import-path $PROTO_PATH -proto $PROTO_FILE \
-d '{"condition":"visible:Overworld Editor","expected":"true"}' \
$SERVER yaze.test.ImGuiTestHarness/Assert
echo "✅ Test complete!"
Run test:
chmod +x test_scripts/open_rom_via_grpc.sh
./test_scripts/open_rom_via_grpc.sh
🐛 Troubleshooting
Issue 1: "ImGuiTestEngine not initialized"
Symptom: RPC returns error about TestEngine not available.
Solution: Ensure TestManager initializes TestEngine during startup.
Check src/app/test/test_manager.cc:
void TestManager::Initialize() {
// Should create ImGuiTestEngine
ui_test_engine_ = ImGuiTestEngine_CreateContext();
// ...
}
Issue 2: "Widget not found"
Symptom: Click RPC can't find button/element.
Debug Steps:
- Check exact label in ImGui code (case-sensitive)
- Try with window prefix:
"window:MainWindow/button:Save" - Use ImGui Demo to see ID format
- Enable ImGui test engine debug output
Solution: Match label exactly:
// ImGui code:
if (ImGui::Button("Open ROM##file_menu")) { ... }
// gRPC target should be:
"button:Open ROM##file_menu"
// Or without ID suffix if unique:
"button:Open ROM"
Issue 3: Click happens too fast
Symptom: Click doesn't register, element not ready yet.
Solution: Add small delay before click:
// In Click handler, before ItemClick:
std::this_thread::sleep_for(std::chrono::milliseconds(50));
ImGuiTestEngine_ItemClick(ctx, item->ID, mouse_button);
Issue 4: Context creation fails
Symptom: ImGuiTestEngine_CreateContext() returns NULL.
Solution: Check TestEngine is properly initialized:
// In TestManager initialization:
if (ui_test_engine_) {
ImGuiTestEngine_Start(ui_test_engine_, ImGui::GetCurrentContext());
}
📊 Performance Considerations
Memory Management
- Test contexts are created and destroyed per RPC
- Each context ~10KB overhead
- For high-frequency operations, consider context pooling
Thread Safety
- ImGui is NOT thread-safe
- All ImGui operations must run on main thread
- TODO: Consider message queue for cross-thread RPC handling
Thread-Safe Implementation (future enhancement):
// Add message queue in TestManager:
class TestManager {
struct GuiCommand {
std::function<void()> action;
std::promise<bool> result;
};
std::queue<GuiCommand> gui_command_queue_;
std::mutex queue_mutex_;
void ProcessGuiCommands() {
// Called every frame on main thread
std::lock_guard<std::mutex> lock(queue_mutex_);
while (!gui_command_queue_.empty()) {
auto cmd = gui_command_queue_.front();
gui_command_queue_.pop();
cmd.action();
}
}
};
// In RPC handler:
std::promise<bool> promise;
auto future = promise.get_future();
test_manager_->EnqueueGuiCommand([&]() {
// This runs on main thread
ImGuiTestEngine_ItemClick(...);
promise.set_value(true);
});
future.wait(); // Block RPC until GUI operation completes
✅ Success Checklist
After completing implementation, verify:
- All RPC handlers compile without errors
- Server starts with
--enable_test_harnessflag - Ping RPC returns YAZE version
- Click RPC can click at least one button successfully
- Type RPC can input text into at least one field
- Wait RPC can wait for window to appear
- Assert RPC can validate window visibility
- End-to-end test script runs without errors
- No crashes when clicking non-existent widgets
- Error messages are helpful for debugging
🚀 Next Steps
After IT-01 Phase 2 completion:
- Integration with z3ed CLI (
agent testcommand) - Python client library for easier scripting
- Policy evaluation (AW-04) for gating operations
- Windows testing to verify cross-platform
- CI integration for automated GUI tests
📚 References
- ImGuiTestEngine API:
src/lib/imgui_test_engine/imgui_te_engine.h - TestManager:
src/app/test/test_manager.h - gRPC Proto:
src/app/core/proto/imgui_test_harness.proto - YAZE Test Examples:
src/app/test/*_test.cc
Document Version: 1.0
Last Updated: October 1, 2025
Estimated Completion: 6-8 hours active coding
Status: Ready for implementation 🚀