feat: Add test introspection APIs and harness test management

- Introduced new gRPC service methods: GetTestStatus, ListTests, and GetTestResults for enhanced test introspection.
- Defined corresponding request and response message types in the proto file.
- Implemented test harness execution tracking in TestManager, including methods to register, mark, and retrieve test execution details.
- Enhanced test logging and summary capabilities to support introspection features.
- Updated existing structures to accommodate new test management functionalities.
This commit is contained in:
scawful
2025-10-02 15:42:07 -04:00
parent 3a573c0764
commit b3bcd801a0
8 changed files with 1217 additions and 621 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,12 @@ class AssertRequest;
class AssertResponse;
class ScreenshotRequest;
class ScreenshotResponse;
class GetTestStatusRequest;
class GetTestStatusResponse;
class ListTestsRequest;
class ListTestsResponse;
class GetTestResultsRequest;
class GetTestResultsResponse;
// Implementation of ImGuiTestHarness gRPC service
// This class provides the actual RPC handlers for automated GUI testing
@@ -72,6 +78,14 @@ class ImGuiTestHarnessServiceImpl {
absl::Status Screenshot(const ScreenshotRequest* request,
ScreenshotResponse* response);
// Test introspection APIs
absl::Status GetTestStatus(const GetTestStatusRequest* request,
GetTestStatusResponse* response);
absl::Status ListTests(const ListTestsRequest* request,
ListTestsResponse* response);
absl::Status GetTestResults(const GetTestResultsRequest* request,
GetTestResultsResponse* response);
private:
TestManager* test_manager_; // Non-owning pointer to access ImGuiTestEngine
};

View File

@@ -22,6 +22,11 @@ service ImGuiTestHarness {
// Capture a screenshot
rpc Screenshot(ScreenshotRequest) returns (ScreenshotResponse);
// Test introspection APIs (IT-05)
rpc GetTestStatus(GetTestStatusRequest) returns (GetTestStatusResponse);
rpc ListTests(ListTestsRequest) returns (ListTestsResponse);
rpc GetTestResults(GetTestResultsRequest) returns (GetTestResultsResponse);
}
// ============================================================================
@@ -43,14 +48,15 @@ message PingResponse {
// ============================================================================
message ClickRequest {
string target = 1; // Target element (e.g., "button:Open ROM", "menu:File/Open")
string target = 1; // Target element (e.g., "button:Open ROM")
ClickType type = 2; // Type of click
enum ClickType {
LEFT = 0; // Single left click
RIGHT = 1; // Single right click
DOUBLE = 2; // Double click
MIDDLE = 3; // Middle mouse button
CLICK_TYPE_UNSPECIFIED = 0; // Default/unspecified click type
CLICK_TYPE_LEFT = 1; // Single left click
CLICK_TYPE_RIGHT = 2; // Single right click
CLICK_TYPE_DOUBLE = 3; // Double click
CLICK_TYPE_MIDDLE = 4; // Middle mouse button
}
}
@@ -58,6 +64,7 @@ message ClickResponse {
bool success = 1; // Whether the click succeeded
string message = 2; // Human-readable result message
int32 execution_time_ms = 3; // Time taken to execute (for debugging)
string test_id = 4; // Unique test identifier for introspection
}
// ============================================================================
@@ -74,6 +81,7 @@ message TypeResponse {
bool success = 1;
string message = 2;
int32 execution_time_ms = 3;
string test_id = 4;
}
// ============================================================================
@@ -81,7 +89,7 @@ message TypeResponse {
// ============================================================================
message WaitRequest {
string condition = 1; // Condition to wait for (e.g., "window:Overworld Editor", "enabled:button:Save")
string condition = 1; // Condition to wait for (e.g., "window:Overworld")
int32 timeout_ms = 2; // Maximum time to wait (default 5000ms)
int32 poll_interval_ms = 3; // How often to check (default 100ms)
}
@@ -90,6 +98,7 @@ message WaitResponse {
bool success = 1; // Whether condition was met before timeout
string message = 2;
int32 elapsed_ms = 3; // Time taken before condition met (or timeout)
string test_id = 4; // Unique test identifier for introspection
}
// ============================================================================
@@ -97,7 +106,7 @@ message WaitResponse {
// ============================================================================
message AssertRequest {
string condition = 1; // Condition to assert (e.g., "visible:button:Save", "text:label:Version:0.3.2")
string condition = 1; // Condition to assert (e.g., "visible:button:Save")
string failure_message = 2; // Custom message if assertion fails
}
@@ -106,6 +115,7 @@ message AssertResponse {
string message = 2; // Diagnostic message
string actual_value = 3; // Actual value found (for debugging)
string expected_value = 4; // Expected value (for debugging)
string test_id = 5; // Unique test identifier for introspection
}
// ============================================================================
@@ -118,8 +128,9 @@ message ScreenshotRequest {
ImageFormat format = 3; // Image format
enum ImageFormat {
PNG = 0;
JPEG = 1;
IMAGE_FORMAT_UNSPECIFIED = 0;
IMAGE_FORMAT_PNG = 1;
IMAGE_FORMAT_JPEG = 2;
}
}
@@ -129,3 +140,85 @@ message ScreenshotResponse {
string file_path = 3; // Absolute path to saved screenshot
int64 file_size_bytes = 4;
}
// ============================================================================
// GetTestStatus - Query test execution state
// ============================================================================
message GetTestStatusRequest {
string test_id = 1; // Test ID from Click/Type/Wait/Assert response
}
message GetTestStatusResponse {
enum Status {
STATUS_UNSPECIFIED = 0; // Test ID not found or unspecified
STATUS_QUEUED = 1; // Waiting to execute
STATUS_RUNNING = 2; // Currently executing
STATUS_PASSED = 3; // Completed successfully
STATUS_FAILED = 4; // Assertion failed or error
STATUS_TIMEOUT = 5; // Exceeded timeout
}
Status status = 1;
int64 queued_at_ms = 2; // When test was queued
int64 started_at_ms = 3; // When test started (0 if not started)
int64 completed_at_ms = 4; // When test completed (0 if not complete)
int32 execution_time_ms = 5; // Total execution time
string error_message = 6; // Error details if FAILED/TIMEOUT
repeated string assertion_failures = 7; // Failed assertion details
}
// ============================================================================
// ListTests - Enumerate available tests
// ============================================================================
message ListTestsRequest {
string category_filter = 1; // Optional: "grpc", "unit", "integration", "e2e"
int32 page_size = 2; // Number of results per page (default 100)
string page_token = 3; // Pagination token from previous response
}
message ListTestsResponse {
repeated TestInfo tests = 1;
string next_page_token = 2; // Token for next page (empty if no more)
int32 total_count = 3; // Total number of matching tests
}
message TestInfo {
string test_id = 1; // Unique test identifier
string name = 2; // Human-readable test name
string category = 3; // Category: grpc, unit, integration, e2e
int64 last_run_timestamp_ms = 4; // When test last executed
int32 total_runs = 5; // Total number of executions
int32 pass_count = 6; // Number of successful runs
int32 fail_count = 7; // Number of failed runs
int32 average_duration_ms = 8; // Average execution time
}
// ============================================================================
// GetTestResults - Retrieve detailed results
// ============================================================================
message GetTestResultsRequest {
string test_id = 1;
bool include_logs = 2; // Include full execution logs
}
message GetTestResultsResponse {
bool success = 1; // Overall test result
string test_name = 2;
string category = 3;
int64 executed_at_ms = 4;
int32 duration_ms = 5;
repeated AssertionResult assertions = 6;
repeated string logs = 7; // If include_logs=true
map<string, int32> metrics = 8; // e.g., "frame_count": 123
}
message AssertionResult {
string description = 1;
bool passed = 2;
string expected_value = 3;
string actual_value = 4;
string error_message = 5;
}

View File

@@ -1,7 +1,13 @@
#include "app/test/test_manager.h"
#include <algorithm>
#include <random>
#include "absl/strings/str_format.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_replace.h"
#include "absl/time/clock.h"
#include "absl/time/time.h"
#include "app/core/features.h"
#include "app/core/platform/file_dialog.h"
#include "app/gfx/arena.h"
@@ -1281,5 +1287,199 @@ absl::Status TestManager::TestRomDataIntegrity(Rom* rom) {
});
}
std::string TestManager::RegisterHarnessTest(const std::string& name,
const std::string& category) {
absl::MutexLock lock(&harness_history_mutex_);
const std::string sanitized_category = category.empty() ? "grpc" : category;
std::string test_id = GenerateHarnessTestIdLocked(sanitized_category);
HarnessTestExecution execution;
execution.test_id = test_id;
execution.name = name;
execution.category = sanitized_category;
execution.status = HarnessTestStatus::kQueued;
execution.queued_at = absl::Now();
execution.started_at = absl::InfinitePast();
execution.completed_at = absl::InfinitePast();
harness_history_[test_id] = execution;
harness_history_order_.push_back(test_id);
TrimHarnessHistoryLocked();
HarnessAggregate& aggregate = harness_aggregates_[name];
if (aggregate.category.empty()) {
aggregate.category = sanitized_category;
}
aggregate.last_run = execution.queued_at;
aggregate.latest_execution = execution;
return test_id;
}
void TestManager::MarkHarnessTestRunning(const std::string& test_id) {
absl::MutexLock lock(&harness_history_mutex_);
auto it = harness_history_.find(test_id);
if (it == harness_history_.end()) {
return;
}
HarnessTestExecution& execution = it->second;
execution.status = HarnessTestStatus::kRunning;
execution.started_at = absl::Now();
HarnessAggregate& aggregate = harness_aggregates_[execution.name];
if (aggregate.category.empty()) {
aggregate.category = execution.category;
}
aggregate.latest_execution = execution;
}
void TestManager::MarkHarnessTestCompleted(
const std::string& test_id, HarnessTestStatus status,
const std::string& error_message,
const std::vector<std::string>& assertion_failures,
const std::vector<std::string>& logs,
const std::map<std::string, int32_t>& metrics) {
absl::MutexLock lock(&harness_history_mutex_);
auto it = harness_history_.find(test_id);
if (it == harness_history_.end()) {
return;
}
HarnessTestExecution& execution = it->second;
execution.status = status;
if (execution.started_at == absl::InfinitePast()) {
execution.started_at = execution.queued_at;
}
execution.completed_at = absl::Now();
execution.duration = execution.completed_at - execution.started_at;
execution.error_message = error_message;
if (!assertion_failures.empty()) {
execution.assertion_failures = assertion_failures;
}
if (!logs.empty()) {
execution.logs.insert(execution.logs.end(), logs.begin(), logs.end());
}
if (!metrics.empty()) {
execution.metrics.insert(metrics.begin(), metrics.end());
}
HarnessAggregate& aggregate = harness_aggregates_[execution.name];
if (aggregate.category.empty()) {
aggregate.category = execution.category;
}
aggregate.total_runs += 1;
if (status == HarnessTestStatus::kPassed) {
aggregate.pass_count += 1;
} else if (status == HarnessTestStatus::kFailed ||
status == HarnessTestStatus::kTimeout) {
aggregate.fail_count += 1;
}
aggregate.total_duration += execution.duration;
aggregate.last_run = execution.completed_at;
aggregate.latest_execution = execution;
}
void TestManager::AppendHarnessTestLog(const std::string& test_id,
const std::string& log_entry) {
absl::MutexLock lock(&harness_history_mutex_);
auto it = harness_history_.find(test_id);
if (it == harness_history_.end()) {
return;
}
HarnessTestExecution& execution = it->second;
execution.logs.push_back(log_entry);
HarnessAggregate& aggregate = harness_aggregates_[execution.name];
aggregate.latest_execution.logs = execution.logs;
}
absl::StatusOr<HarnessTestExecution> TestManager::GetHarnessTestExecution(
const std::string& test_id) const {
absl::MutexLock lock(&harness_history_mutex_);
auto it = harness_history_.find(test_id);
if (it == harness_history_.end()) {
return absl::NotFoundError(
absl::StrFormat("Test ID '%s' not found", test_id));
}
return it->second;
}
std::vector<HarnessTestSummary> TestManager::ListHarnessTestSummaries(
const std::string& category_filter) const {
absl::MutexLock lock(&harness_history_mutex_);
std::vector<HarnessTestSummary> summaries;
summaries.reserve(harness_aggregates_.size());
for (const auto& [name, aggregate] : harness_aggregates_) {
if (!category_filter.empty() && aggregate.category != category_filter) {
continue;
}
HarnessTestSummary summary;
summary.latest_execution = aggregate.latest_execution;
summary.total_runs = aggregate.total_runs;
summary.pass_count = aggregate.pass_count;
summary.fail_count = aggregate.fail_count;
summary.total_duration = aggregate.total_duration;
summaries.push_back(summary);
}
std::sort(summaries.begin(), summaries.end(),
[](const HarnessTestSummary& a, const HarnessTestSummary& b) {
absl::Time time_a = a.latest_execution.completed_at;
if (time_a == absl::InfinitePast()) {
time_a = a.latest_execution.queued_at;
}
absl::Time time_b = b.latest_execution.completed_at;
if (time_b == absl::InfinitePast()) {
time_b = b.latest_execution.queued_at;
}
return time_a > time_b;
});
return summaries;
}
std::string TestManager::GenerateHarnessTestIdLocked(absl::string_view prefix) {
static std::mt19937 rng(std::random_device{}());
static std::uniform_int_distribution<uint32_t> dist(0, 0xFFFFFF);
std::string sanitized = absl::StrReplaceAll(std::string(prefix),
{{" ", "_"}, {":", "_"}});
if (sanitized.empty()) {
sanitized = "test";
}
for (int attempt = 0; attempt < 8; ++attempt) {
std::string candidate =
absl::StrFormat("%s_%08x", sanitized, dist(rng));
if (harness_history_.find(candidate) == harness_history_.end()) {
return candidate;
}
}
return absl::StrFormat("%s_%lld", sanitized,
static_cast<long long>(absl::ToUnixMillis(absl::Now())));
}
void TestManager::TrimHarnessHistoryLocked() {
while (harness_history_order_.size() > harness_history_limit_) {
const std::string& oldest_id = harness_history_order_.front();
auto it = harness_history_.find(oldest_id);
if (it != harness_history_.end()) {
harness_history_.erase(it);
}
harness_history_order_.pop_front();
}
}
} // namespace test
} // namespace yaze

View File

@@ -2,13 +2,19 @@
#define YAZE_APP_TEST_TEST_MANAGER_H
#include <chrono>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/synchronization/mutex.h"
#include "absl/strings/string_view.h"
#include "absl/time/time.h"
#include "app/rom.h"
#include "imgui.h"
#include "util/log.h"
@@ -111,6 +117,39 @@ struct ResourceStats {
std::chrono::time_point<std::chrono::steady_clock> timestamp;
};
// Test harness execution tracking for gRPC automation (IT-05)
enum class HarnessTestStatus {
kUnspecified,
kQueued,
kRunning,
kPassed,
kFailed,
kTimeout,
};
struct HarnessTestExecution {
std::string test_id;
std::string name;
std::string category;
HarnessTestStatus status = HarnessTestStatus::kUnspecified;
absl::Time queued_at;
absl::Time started_at;
absl::Time completed_at;
absl::Duration duration = absl::ZeroDuration();
std::string error_message;
std::vector<std::string> assertion_failures;
std::vector<std::string> logs;
std::map<std::string, int32_t> metrics;
};
struct HarnessTestSummary {
HarnessTestExecution latest_execution;
int total_runs = 0;
int pass_count = 0;
int fail_count = 0;
absl::Duration total_duration = absl::ZeroDuration();
};
// Main test manager - singleton
class TestManager {
public:
@@ -209,6 +248,29 @@ class TestManager {
}
// File dialog mode now uses global feature flags
// Harness test introspection (IT-05)
std::string RegisterHarnessTest(const std::string& name,
const std::string& category)
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
void MarkHarnessTestRunning(const std::string& test_id)
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
void MarkHarnessTestCompleted(
const std::string& test_id, HarnessTestStatus status,
const std::string& error_message = "",
const std::vector<std::string>& assertion_failures = {},
const std::vector<std::string>& logs = {},
const std::map<std::string, int32_t>& metrics = {})
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
void AppendHarnessTestLog(const std::string& test_id,
const std::string& log_entry)
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
absl::StatusOr<HarnessTestExecution> GetHarnessTestExecution(
const std::string& test_id) const
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
std::vector<HarnessTestSummary> ListHarnessTestSummaries(
const std::string& category_filter = "") const
ABSL_LOCKS_EXCLUDED(harness_history_mutex_);
private:
TestManager();
~TestManager();
@@ -263,6 +325,31 @@ class TestManager {
// Test selection and configuration
std::unordered_map<std::string, bool> disabled_tests_;
// Harness test tracking
struct HarnessAggregate {
int total_runs = 0;
int pass_count = 0;
int fail_count = 0;
absl::Duration total_duration = absl::ZeroDuration();
std::string category;
absl::Time last_run;
HarnessTestExecution latest_execution;
};
std::unordered_map<std::string, HarnessTestExecution> harness_history_
ABSL_GUARDED_BY(harness_history_mutex_);
std::unordered_map<std::string, HarnessAggregate> harness_aggregates_
ABSL_GUARDED_BY(harness_history_mutex_);
std::deque<std::string> harness_history_order_
ABSL_GUARDED_BY(harness_history_mutex_);
size_t harness_history_limit_ = 200;
mutable absl::Mutex harness_history_mutex_;
std::string GenerateHarnessTestIdLocked(absl::string_view prefix)
ABSL_EXCLUSIVE_LOCKS_REQUIRED(harness_history_mutex_);
void TrimHarnessHistoryLocked()
ABSL_EXCLUSIVE_LOCKS_REQUIRED(harness_history_mutex_);
};
// Utility functions for test result formatting