feat: Add test recording and replay functionality to ImGuiTestHarness

- Introduced TestRecorder class to capture GUI automation RPCs and export them as JSON test scripts.
- Implemented StartRecording, StopRecording, and ReplayTest RPCs in ImGuiTestHarnessService.
- Updated imgui_test_harness.proto to include new RPCs and message definitions for recording and replay.
- Created TestScriptParser for reading and writing test scripts in JSON format.
- Enhanced DiscoverWidgetsRequest and DiscoveredWidget messages with detailed comments for clarity.
- Added ScopedSuspension mechanism in TestRecorder to manage recording state.
This commit is contained in:
scawful
2025-10-02 18:00:58 -04:00
parent e0b308a93f
commit d8f863a9ce
8 changed files with 1351 additions and 35 deletions

View File

@@ -238,8 +238,15 @@ if(YAZE_WITH_GRPC)
target_sources(yaze PRIVATE
${CMAKE_SOURCE_DIR}/src/app/core/imgui_test_harness_service.cc
${CMAKE_SOURCE_DIR}/src/app/core/imgui_test_harness_service.h
${CMAKE_SOURCE_DIR}/src/app/core/test_recorder.cc
${CMAKE_SOURCE_DIR}/src/app/core/test_recorder.h
${CMAKE_SOURCE_DIR}/src/app/core/test_script_parser.cc
${CMAKE_SOURCE_DIR}/src/app/core/test_script_parser.h
${CMAKE_SOURCE_DIR}/src/app/core/widget_discovery_service.cc
${CMAKE_SOURCE_DIR}/src/app/core/widget_discovery_service.h)
target_include_directories(yaze PRIVATE
${CMAKE_SOURCE_DIR}/third_party/json/include)
# Link gRPC libraries
target_link_libraries(yaze PRIVATE

View File

@@ -9,15 +9,19 @@
#include <limits>
#include <thread>
#include "absl/container/flat_hash_map.h"
#include "absl/base/thread_annotations.h"
#include "absl/strings/ascii.h"
#include "absl/strings/numbers.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/strings/str_replace.h"
#include "absl/synchronization/mutex.h"
#include "absl/time/clock.h"
#include "absl/time/time.h"
#include "app/core/proto/imgui_test_harness.grpc.pb.h"
#include "app/core/proto/imgui_test_harness.pb.h"
#include "app/core/test_script_parser.h"
#include "app/test/test_manager.h"
#include "yaze.h" // For YAZE_VERSION_STRING
@@ -125,6 +129,136 @@ struct RPCState {
namespace yaze {
namespace test {
namespace {
std::string ClickTypeToString(ClickRequest::ClickType type) {
switch (type) {
case ClickRequest::CLICK_TYPE_RIGHT:
return "right";
case ClickRequest::CLICK_TYPE_MIDDLE:
return "middle";
case ClickRequest::CLICK_TYPE_DOUBLE:
return "double";
case ClickRequest::CLICK_TYPE_LEFT:
case ClickRequest::CLICK_TYPE_UNSPECIFIED:
default:
return "left";
}
}
ClickRequest::ClickType ClickTypeFromString(absl::string_view type) {
const std::string lower = absl::AsciiStrToLower(std::string(type));
if (lower == "right") {
return ClickRequest::CLICK_TYPE_RIGHT;
}
if (lower == "middle") {
return ClickRequest::CLICK_TYPE_MIDDLE;
}
if (lower == "double" || lower == "double_click" || lower == "dbl") {
return ClickRequest::CLICK_TYPE_DOUBLE;
}
return ClickRequest::CLICK_TYPE_LEFT;
}
HarnessTestStatus HarnessStatusFromString(absl::string_view status) {
const std::string lower = absl::AsciiStrToLower(std::string(status));
if (lower == "passed" || lower == "success") {
return HarnessTestStatus::kPassed;
}
if (lower == "failed" || lower == "fail") {
return HarnessTestStatus::kFailed;
}
if (lower == "timeout") {
return HarnessTestStatus::kTimeout;
}
if (lower == "queued") {
return HarnessTestStatus::kQueued;
}
if (lower == "running") {
return HarnessTestStatus::kRunning;
}
return HarnessTestStatus::kUnspecified;
}
const char* HarnessStatusToString(HarnessTestStatus status) {
switch (status) {
case HarnessTestStatus::kPassed:
return "passed";
case HarnessTestStatus::kFailed:
return "failed";
case HarnessTestStatus::kTimeout:
return "timeout";
case HarnessTestStatus::kQueued:
return "queued";
case HarnessTestStatus::kRunning:
return "running";
case HarnessTestStatus::kUnspecified:
default:
return "unknown";
}
}
std::string ApplyOverrides(
const std::string& value,
const absl::flat_hash_map<std::string, std::string>& overrides) {
if (overrides.empty() || value.empty()) {
return value;
}
std::string result = value;
for (const auto& [key, replacement] : overrides) {
const std::string placeholder = absl::StrCat("{{", key, "}}");
result = absl::StrReplaceAll(result, {{placeholder, replacement}});
}
return result;
}
void MaybeRecordStep(TestRecorder* recorder, TestRecorder::RecordedStep step) {
if (!recorder || !recorder->IsRecording()) {
return;
}
if (step.captured_at == absl::InfinitePast()) {
step.captured_at = absl::Now();
}
recorder->RecordStep(step);
}
absl::Status WaitForHarnessTestCompletion(TestManager* manager,
const std::string& test_id,
HarnessTestExecution* execution) {
if (!manager) {
return absl::FailedPreconditionError("TestManager unavailable");
}
if (test_id.empty()) {
return absl::InvalidArgumentError("Missing harness test identifier");
}
const absl::Time deadline = absl::Now() + absl::Seconds(20);
while (absl::Now() < deadline) {
absl::StatusOr<HarnessTestExecution> current =
manager->GetHarnessTestExecution(test_id);
if (!current.ok()) {
absl::SleepFor(absl::Milliseconds(75));
continue;
}
if (execution) {
*execution = std::move(current.value());
}
if (current->status == HarnessTestStatus::kQueued ||
current->status == HarnessTestStatus::kRunning) {
absl::SleepFor(absl::Milliseconds(75));
continue;
}
return absl::OkStatus();
}
return absl::DeadlineExceededError(absl::StrFormat(
"Harness test %s did not reach a terminal state", test_id));
}
} // namespace
// gRPC service wrapper that forwards to our implementation
class ImGuiTestHarnessServiceGrpc final : public ImGuiTestHarness::Service {
public:
@@ -187,6 +321,24 @@ class ImGuiTestHarnessServiceGrpc final : public ImGuiTestHarness::Service {
return ConvertStatus(impl_->DiscoverWidgets(request, response));
}
grpc::Status StartRecording(grpc::ServerContext* context,
const StartRecordingRequest* request,
StartRecordingResponse* response) override {
return ConvertStatus(impl_->StartRecording(request, response));
}
grpc::Status StopRecording(grpc::ServerContext* context,
const StopRecordingRequest* request,
StopRecordingResponse* response) override {
return ConvertStatus(impl_->StopRecording(request, response));
}
grpc::Status ReplayTest(grpc::ServerContext* context,
const ReplayTestRequest* request,
ReplayTestResponse* response) override {
return ConvertStatus(impl_->ReplayTest(request, response));
}
private:
static grpc::Status ConvertStatus(const absl::Status& status) {
if (status.ok()) {
@@ -276,19 +428,37 @@ absl::Status ImGuiTestHarnessServiceImpl::Ping(const PingRequest* request,
}
absl::Status ImGuiTestHarnessServiceImpl::Click(const ClickRequest* request,
ClickResponse* response) {
ClickResponse* response) {
auto start = std::chrono::steady_clock::now();
TestRecorder::RecordedStep recorded_step;
recorded_step.type = TestRecorder::ActionType::kClick;
if (request) {
recorded_step.target = request->target();
recorded_step.click_type = ClickTypeToString(request->type());
}
auto finalize = [&](const absl::Status& status) {
recorded_step.success = response->success();
recorded_step.message = response->message();
recorded_step.execution_time_ms = response->execution_time_ms();
recorded_step.test_id = response->test_id();
MaybeRecordStep(&test_recorder_, recorded_step);
return status;
};
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
response->set_execution_time_ms(0);
return absl::FailedPreconditionError("TestManager not available");
return finalize(
absl::FailedPreconditionError("TestManager not available"));
}
const std::string test_id = test_manager_->RegisterHarnessTest(
absl::StrFormat("Click: %s", request->target()), "grpc");
response->set_test_id(test_id);
recorded_step.test_id = test_id;
test_manager_->AppendHarnessTestLog(
test_id, absl::StrCat("Queued click request: ", request->target()));
@@ -303,7 +473,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Click(const ClickRequest* request,
response->set_execution_time_ms(elapsed.count());
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string target = request->target();
@@ -319,7 +489,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Click(const ClickRequest* request,
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
test_manager_->AppendHarnessTestLog(test_id, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string widget_type = target.substr(0, colon_pos);
@@ -406,7 +576,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Click(const ClickRequest* request,
HarnessTestStatus::kFailed,
message);
test_manager_->AppendHarnessTestLog(test_id, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string widget_type = target.substr(0, colon_pos);
@@ -428,23 +598,42 @@ absl::Status ImGuiTestHarnessServiceImpl::Click(const ClickRequest* request,
response->set_execution_time_ms(elapsed.count());
#endif
return absl::OkStatus();
return finalize(absl::OkStatus());
}
absl::Status ImGuiTestHarnessServiceImpl::Type(const TypeRequest* request,
TypeResponse* response) {
TypeResponse* response) {
auto start = std::chrono::steady_clock::now();
TestRecorder::RecordedStep recorded_step;
recorded_step.type = TestRecorder::ActionType::kType;
if (request) {
recorded_step.target = request->target();
recorded_step.text = request->text();
recorded_step.clear_first = request->clear_first();
}
auto finalize = [&](const absl::Status& status) {
recorded_step.success = response->success();
recorded_step.message = response->message();
recorded_step.execution_time_ms = response->execution_time_ms();
recorded_step.test_id = response->test_id();
MaybeRecordStep(&test_recorder_, recorded_step);
return status;
};
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
response->set_execution_time_ms(0);
return absl::FailedPreconditionError("TestManager not available");
return finalize(
absl::FailedPreconditionError("TestManager not available"));
}
const std::string test_id = test_manager_->RegisterHarnessTest(
absl::StrFormat("Type: %s", request->target()), "grpc");
response->set_test_id(test_id);
recorded_step.test_id = test_id;
test_manager_->AppendHarnessTestLog(
test_id, absl::StrFormat("Queued type request: %s", request->target()));
@@ -459,7 +648,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Type(const TypeRequest* request,
response->set_execution_time_ms(elapsed.count());
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string target = request->target();
@@ -475,7 +664,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Type(const TypeRequest* request,
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
test_manager_->AppendHarnessTestLog(test_id, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string widget_type = target.substr(0, colon_pos);
@@ -587,23 +776,41 @@ absl::Status ImGuiTestHarnessServiceImpl::Type(const TypeRequest* request,
response->set_execution_time_ms(elapsed.count());
#endif
return absl::OkStatus();
return finalize(absl::OkStatus());
}
absl::Status ImGuiTestHarnessServiceImpl::Wait(const WaitRequest* request,
WaitResponse* response) {
WaitResponse* response) {
auto start = std::chrono::steady_clock::now();
TestRecorder::RecordedStep recorded_step;
recorded_step.type = TestRecorder::ActionType::kWait;
if (request) {
recorded_step.condition = request->condition();
recorded_step.timeout_ms = request->timeout_ms();
}
auto finalize = [&](const absl::Status& status) {
recorded_step.success = response->success();
recorded_step.message = response->message();
recorded_step.execution_time_ms = response->elapsed_ms();
recorded_step.test_id = response->test_id();
MaybeRecordStep(&test_recorder_, recorded_step);
return status;
};
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
response->set_elapsed_ms(0);
return absl::FailedPreconditionError("TestManager not available");
return finalize(
absl::FailedPreconditionError("TestManager not available"));
}
const std::string test_id = test_manager_->RegisterHarnessTest(
absl::StrFormat("Wait: %s", request->condition()), "grpc");
response->set_test_id(test_id);
recorded_step.test_id = test_id;
test_manager_->AppendHarnessTestLog(
test_id, absl::StrFormat("Queued wait condition: %s",
request->condition()));
@@ -619,7 +826,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Wait(const WaitRequest* request,
response->set_elapsed_ms(elapsed.count());
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string condition = request->condition();
@@ -635,7 +842,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Wait(const WaitRequest* request,
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
test_manager_->AppendHarnessTestLog(test_id, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string condition_type = condition.substr(0, colon_pos);
@@ -759,22 +966,40 @@ absl::Status ImGuiTestHarnessServiceImpl::Wait(const WaitRequest* request,
response->set_elapsed_ms(elapsed.count());
#endif
return absl::OkStatus();
return finalize(absl::OkStatus());
}
absl::Status ImGuiTestHarnessServiceImpl::Assert(const AssertRequest* request,
AssertResponse* response) {
AssertResponse* response) {
TestRecorder::RecordedStep recorded_step;
recorded_step.type = TestRecorder::ActionType::kAssert;
if (request) {
recorded_step.condition = request->condition();
}
auto finalize = [&](const absl::Status& status) {
recorded_step.success = response->success();
recorded_step.message = response->message();
recorded_step.expected_value = response->expected_value();
recorded_step.actual_value = response->actual_value();
recorded_step.test_id = response->test_id();
MaybeRecordStep(&test_recorder_, recorded_step);
return status;
};
if (!test_manager_) {
response->set_success(false);
response->set_message("TestManager not available");
response->set_actual_value("N/A");
response->set_expected_value("N/A");
return absl::FailedPreconditionError("TestManager not available");
return finalize(
absl::FailedPreconditionError("TestManager not available"));
}
const std::string test_id = test_manager_->RegisterHarnessTest(
absl::StrFormat("Assert: %s", request->condition()), "grpc");
response->set_test_id(test_id);
recorded_step.test_id = test_id;
test_manager_->AppendHarnessTestLog(
test_id, absl::StrFormat("Queued assertion: %s", request->condition()));
@@ -788,7 +1013,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Assert(const AssertRequest* request,
response->set_expected_value("N/A");
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string condition = request->condition();
@@ -803,7 +1028,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Assert(const AssertRequest* request,
test_manager_->MarkHarnessTestCompleted(
test_id, HarnessTestStatus::kFailed, message);
test_manager_->AppendHarnessTestLog(test_id, message);
return absl::OkStatus();
return finalize(absl::OkStatus());
}
std::string assertion_type = condition.substr(0, colon_pos);
@@ -956,7 +1181,7 @@ absl::Status ImGuiTestHarnessServiceImpl::Assert(const AssertRequest* request,
response->set_expected_value("(stub)");
#endif
return absl::OkStatus();
return finalize(absl::OkStatus());
}
absl::Status ImGuiTestHarnessServiceImpl::Screenshot(
@@ -1171,6 +1396,335 @@ absl::Status ImGuiTestHarnessServiceImpl::DiscoverWidgets(
return absl::OkStatus();
}
absl::Status ImGuiTestHarnessServiceImpl::StartRecording(
const StartRecordingRequest* request, StartRecordingResponse* response) {
if (!request) {
return absl::InvalidArgumentError("request cannot be null");
}
if (!response) {
return absl::InvalidArgumentError("response cannot be null");
}
TestRecorder::RecordingOptions options;
options.output_path = request->output_path();
options.session_name = request->session_name();
options.description = request->description();
if (options.output_path.empty()) {
response->set_success(false);
response->set_message("output_path is required to start recording");
return absl::InvalidArgumentError("output_path cannot be empty");
}
absl::StatusOr<std::string> recording_id = test_recorder_.Start(options);
if (!recording_id.ok()) {
response->set_success(false);
response->set_message(std::string(recording_id.status().message()));
return recording_id.status();
}
response->set_success(true);
response->set_message("Recording started");
response->set_recording_id(*recording_id);
response->set_started_at_ms(absl::ToUnixMillis(absl::Now()));
return absl::OkStatus();
}
absl::Status ImGuiTestHarnessServiceImpl::StopRecording(
const StopRecordingRequest* request, StopRecordingResponse* response) {
if (!request) {
return absl::InvalidArgumentError("request cannot be null");
}
if (!response) {
return absl::InvalidArgumentError("response cannot be null");
}
absl::StatusOr<TestRecorder::StopRecordingSummary> summary =
test_recorder_.Stop(request->recording_id(), request->discard());
if (!summary.ok()) {
response->set_success(false);
response->set_message(std::string(summary.status().message()));
return summary.status();
}
response->set_success(true);
if (summary->saved) {
response->set_message("Recording saved");
} else {
response->set_message("Recording discarded");
}
response->set_output_path(summary->output_path);
response->set_step_count(summary->step_count);
response->set_duration_ms(absl::ToInt64Milliseconds(summary->duration));
return absl::OkStatus();
}
absl::Status ImGuiTestHarnessServiceImpl::ReplayTest(
const ReplayTestRequest* request, ReplayTestResponse* response) {
if (!request) {
return absl::InvalidArgumentError("request cannot be null");
}
if (!response) {
return absl::InvalidArgumentError("response cannot be null");
}
response->clear_logs();
response->clear_assertions();
if (request->script_path().empty()) {
response->set_success(false);
response->set_message("script_path is required");
return absl::InvalidArgumentError("script_path cannot be empty");
}
absl::StatusOr<TestScript> script_or =
TestScriptParser::ParseFromFile(request->script_path());
if (!script_or.ok()) {
response->set_success(false);
response->set_message(std::string(script_or.status().message()));
return script_or.status();
}
TestScript script = std::move(*script_or);
absl::flat_hash_map<std::string, std::string> overrides;
for (const auto& entry : request->parameter_overrides()) {
overrides[entry.first] = entry.second;
}
response->set_replay_session_id(
absl::StrFormat("replay_%s",
absl::FormatTime("%Y%m%dT%H%M%S", absl::Now(),
absl::UTCTimeZone())));
auto suspension = test_recorder_.Suspend();
std::vector<std::string> logs;
logs.reserve(script.steps.size() * 2 + 4);
bool overall_success = true;
std::string overall_message = "Replay completed successfully";
int steps_executed = 0;
absl::Status overall_rpc_status = absl::OkStatus();
for (const auto& step : script.steps) {
++steps_executed;
std::string action_label =
absl::StrFormat("Step %d: %s", steps_executed, step.action);
logs.push_back(action_label);
absl::Status status = absl::OkStatus();
bool step_success = false;
std::string step_message;
HarnessTestExecution execution;
bool have_execution = false;
if (step.action == "click") {
ClickRequest sub_request;
sub_request.set_target(ApplyOverrides(step.target, overrides));
sub_request.set_type(ClickTypeFromString(step.click_type));
ClickResponse sub_response;
status = Click(&sub_request, &sub_response);
step_success = sub_response.success();
step_message = sub_response.message();
if (status.ok() && !sub_response.test_id().empty()) {
absl::Status wait_status = WaitForHarnessTestCompletion(
test_manager_, sub_response.test_id(), &execution);
if (wait_status.ok()) {
have_execution = true;
if (!execution.error_message.empty()) {
step_message = execution.error_message;
}
} else {
status = wait_status;
step_success = false;
step_message = std::string(wait_status.message());
}
}
} else if (step.action == "type") {
TypeRequest sub_request;
sub_request.set_target(ApplyOverrides(step.target, overrides));
sub_request.set_text(ApplyOverrides(step.text, overrides));
sub_request.set_clear_first(step.clear_first);
TypeResponse sub_response;
status = Type(&sub_request, &sub_response);
step_success = sub_response.success();
step_message = sub_response.message();
if (status.ok() && !sub_response.test_id().empty()) {
absl::Status wait_status = WaitForHarnessTestCompletion(
test_manager_, sub_response.test_id(), &execution);
if (wait_status.ok()) {
have_execution = true;
if (!execution.error_message.empty()) {
step_message = execution.error_message;
}
} else {
status = wait_status;
step_success = false;
step_message = std::string(wait_status.message());
}
}
} else if (step.action == "wait") {
WaitRequest sub_request;
sub_request.set_condition(ApplyOverrides(step.condition, overrides));
if (step.timeout_ms > 0) {
sub_request.set_timeout_ms(step.timeout_ms);
}
WaitResponse sub_response;
status = Wait(&sub_request, &sub_response);
step_success = sub_response.success();
step_message = sub_response.message();
if (status.ok() && !sub_response.test_id().empty()) {
absl::Status wait_status = WaitForHarnessTestCompletion(
test_manager_, sub_response.test_id(), &execution);
if (wait_status.ok()) {
have_execution = true;
if (!execution.error_message.empty()) {
step_message = execution.error_message;
}
} else {
status = wait_status;
step_success = false;
step_message = std::string(wait_status.message());
}
}
} else if (step.action == "assert") {
AssertRequest sub_request;
sub_request.set_condition(ApplyOverrides(step.condition, overrides));
AssertResponse sub_response;
status = Assert(&sub_request, &sub_response);
step_success = sub_response.success();
step_message = sub_response.message();
if (status.ok() && !sub_response.test_id().empty()) {
absl::Status wait_status = WaitForHarnessTestCompletion(
test_manager_, sub_response.test_id(), &execution);
if (wait_status.ok()) {
have_execution = true;
if (!execution.error_message.empty()) {
step_message = execution.error_message;
}
} else {
status = wait_status;
step_success = false;
step_message = std::string(wait_status.message());
}
}
} else if (step.action == "screenshot") {
ScreenshotRequest sub_request;
sub_request.set_window_title(ApplyOverrides(step.target, overrides));
if (!step.region.empty()) {
sub_request.set_output_path(ApplyOverrides(step.region, overrides));
}
ScreenshotResponse sub_response;
status = Screenshot(&sub_request, &sub_response);
step_success = sub_response.success();
step_message = sub_response.message();
} else {
status = absl::InvalidArgumentError(
absl::StrFormat("Unsupported action '%s'", step.action));
step_message = std::string(status.message());
}
auto* assertion = response->add_assertions();
assertion->set_description(
absl::StrFormat("Step %d (%s)", steps_executed, step.action));
if (!status.ok()) {
assertion->set_passed(false);
assertion->set_error_message(std::string(status.message()));
overall_success = false;
overall_message = step_message;
logs.push_back(absl::StrFormat(" Error: %s", status.message()));
overall_rpc_status = status;
break;
}
bool expectations_met = (step_success == step.expect_success);
std::string expectation_error;
if (!expectations_met) {
expectation_error = absl::StrFormat(
"Expected success=%s but got %s",
step.expect_success ? "true" : "false",
step_success ? "true" : "false");
}
if (!step.expect_status.empty()) {
HarnessTestStatus expected_status =
HarnessStatusFromString(step.expect_status);
if (!have_execution) {
expectations_met = false;
if (!expectation_error.empty()) {
expectation_error.append("; ");
}
expectation_error.append("No execution details available");
} else if (expected_status != HarnessTestStatus::kUnspecified &&
execution.status != expected_status) {
expectations_met = false;
if (!expectation_error.empty()) {
expectation_error.append("; ");
}
expectation_error.append(absl::StrFormat(
"Expected status %s but observed %s",
step.expect_status, HarnessStatusToString(execution.status)));
}
if (have_execution) {
assertion->set_actual_value(HarnessStatusToString(execution.status));
assertion->set_expected_value(step.expect_status);
}
}
if (!step.expect_message.empty()) {
std::string actual_message = step_message;
if (have_execution && !execution.error_message.empty()) {
actual_message = execution.error_message;
}
if (actual_message.find(step.expect_message) == std::string::npos) {
expectations_met = false;
if (!expectation_error.empty()) {
expectation_error.append("; ");
}
expectation_error.append(absl::StrFormat(
"Expected message containing '%s' but got '%s'",
step.expect_message, actual_message));
}
}
if (!expectations_met) {
assertion->set_passed(false);
assertion->set_error_message(expectation_error);
overall_success = false;
overall_message = expectation_error;
logs.push_back(absl::StrFormat(" Failed expectations: %s",
expectation_error));
if (request->ci_mode()) {
break;
}
} else {
assertion->set_passed(true);
logs.push_back(absl::StrFormat(" Result: %s", step_message));
}
if (have_execution && !execution.assertion_failures.empty()) {
for (const auto& failure : execution.assertion_failures) {
logs.push_back(absl::StrFormat(" Assertion failure: %s", failure));
}
}
if (!overall_success && request->ci_mode()) {
break;
}
}
response->set_steps_executed(steps_executed);
response->set_success(overall_success);
response->set_message(overall_message);
for (const auto& log_entry : logs) {
response->add_logs(log_entry);
}
return overall_rpc_status;
}
// ============================================================================
// ImGuiTestHarnessServer - Server Lifecycle
// ============================================================================

View File

@@ -8,6 +8,7 @@
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "app/core/test_recorder.h"
#include "app/core/widget_discovery_service.h"
// Include grpcpp headers for unique_ptr<Server> in member variable
@@ -45,6 +46,12 @@ class GetTestResultsRequest;
class GetTestResultsResponse;
class DiscoverWidgetsRequest;
class DiscoverWidgetsResponse;
class StartRecordingRequest;
class StartRecordingResponse;
class StopRecordingRequest;
class StopRecordingResponse;
class ReplayTestRequest;
class ReplayTestResponse;
// Implementation of ImGuiTestHarness gRPC service
// This class provides the actual RPC handlers for automated GUI testing
@@ -52,7 +59,7 @@ class ImGuiTestHarnessServiceImpl {
public:
// Constructor now takes TestManager reference for ImGuiTestEngine access
explicit ImGuiTestHarnessServiceImpl(TestManager* test_manager)
: test_manager_(test_manager) {}
: test_manager_(test_manager), test_recorder_(test_manager) {}
~ImGuiTestHarnessServiceImpl() = default;
// Disable copy and move
@@ -90,10 +97,17 @@ class ImGuiTestHarnessServiceImpl {
GetTestResultsResponse* response);
absl::Status DiscoverWidgets(const DiscoverWidgetsRequest* request,
DiscoverWidgetsResponse* response);
absl::Status StartRecording(const StartRecordingRequest* request,
StartRecordingResponse* response);
absl::Status StopRecording(const StopRecordingRequest* request,
StopRecordingResponse* response);
absl::Status ReplayTest(const ReplayTestRequest* request,
ReplayTestResponse* response);
private:
TestManager* test_manager_; // Non-owning pointer to access ImGuiTestEngine
WidgetDiscoveryService widget_discovery_service_;
TestRecorder test_recorder_;
};
// Forward declaration of the gRPC service wrapper

View File

@@ -30,6 +30,11 @@ service ImGuiTestHarness {
// Widget discovery API (IT-06)
rpc DiscoverWidgets(DiscoverWidgetsRequest) returns (DiscoverWidgetsResponse);
// Test recording & replay (IT-07)
rpc StartRecording(StartRecordingRequest) returns (StartRecordingResponse);
rpc StopRecording(StopRecordingRequest) returns (StopRecordingResponse);
rpc ReplayTest(ReplayTestRequest) returns (ReplayTestResponse);
}
// ============================================================================
@@ -231,11 +236,16 @@ message AssertionResult {
// ============================================================================
message DiscoverWidgetsRequest {
string window_filter = 1; // Optional: Limit to a window name (case-insensitive)
WidgetType type_filter = 2; // Optional: Limit to widget type
string path_prefix = 3; // Optional: Require widget path to start with prefix
bool include_invisible = 4; // Include widgets that are currently not visible
bool include_disabled = 5; // Include widgets that are currently disabled
// Optional: Limit to a window name (case-insensitive)
string window_filter = 1;
// Optional: Limit to widget type
WidgetType type_filter = 2;
// Optional: Require widget path to start with prefix
string path_prefix = 3;
// Include widgets that are currently not visible
bool include_invisible = 4;
// Include widgets that are currently disabled
bool include_disabled = 5;
}
enum WidgetType {
@@ -260,11 +270,16 @@ message WidgetBounds {
}
message DiscoveredWidget {
string path = 1; // Full hierarchical path (e.g. Overworld/Toolset/button:Pan)
string label = 2; // Human-readable label (e.g. Pan)
string type = 3; // Widget type string (button, input, ...)
string description = 4; // Description provided by registry (if any)
string suggested_action = 5; // Suggested action for automation (e.g. "Click button:Pan")
// Full hierarchical path (e.g. Overworld/Toolset/button:Pan)
string path = 1;
// Human-readable label (e.g. Pan)
string label = 2;
// Widget type string (button, input, ...)
string type = 3;
// Description provided by registry (if any)
string description = 4;
// Suggested action for automation (e.g. "Click button:Pan")
string suggested_action = 5;
bool visible = 6; // Currently visible in UI
bool enabled = 7; // Currently enabled for interaction
WidgetBounds bounds = 8; // Bounding rectangle in screen coordinates
@@ -272,9 +287,12 @@ message DiscoveredWidget {
}
message DiscoveredWindow {
string name = 1; // Window name (first segment of path)
bool visible = 2; // Whether the window is currently visible
repeated DiscoveredWidget widgets = 3; // Widgets contained in this window
// Window name (first segment of path)
string name = 1;
// Whether the window is currently visible
bool visible = 2;
// Widgets contained in this window
repeated DiscoveredWidget widgets = 3;
}
message DiscoverWidgetsResponse {
@@ -282,3 +300,48 @@ message DiscoverWidgetsResponse {
int32 total_widgets = 2; // Total number of widgets returned
int64 generated_at_ms = 3; // Snapshot timestamp (Unix ms)
}
// ============================================================================
// Test Recording & Replay (IT-07)
// ============================================================================
message StartRecordingRequest {
string output_path = 1; // Where to store the JSON script
string session_name = 2; // Optional friendly name for the recording
string description = 3; // Optional description stored alongside metadata
}
message StartRecordingResponse {
bool success = 1;
string message = 2;
string recording_id = 3; // Identifier required when stopping
int64 started_at_ms = 4;
}
message StopRecordingRequest {
string recording_id = 1; // Recording session to stop
bool discard = 2; // If true, delete steps instead of writing file
}
message StopRecordingResponse {
bool success = 1;
string message = 2;
string output_path = 3; // Final location of saved script (if any)
int32 step_count = 4; // Total steps captured during session
int64 duration_ms = 5; // Duration of the recording session
}
message ReplayTestRequest {
string script_path = 1; // Path to JSON script
bool ci_mode = 2; // Suppress interactive prompts
map<string, string> parameter_overrides = 3; // Optional parameter overrides
}
message ReplayTestResponse {
bool success = 1;
string message = 2;
string replay_session_id = 3;
int32 steps_executed = 4;
repeated AssertionResult assertions = 5; // Aggregated assertion outcomes
repeated string logs = 6; // Replay log entries
}

View File

@@ -0,0 +1,253 @@
#include "app/core/test_recorder.h"
#include <utility>
#include "absl/strings/ascii.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
#include "absl/time/clock.h"
#include "absl/time/time.h"
#include "app/core/test_script_parser.h"
#include "app/test/test_manager.h"
namespace yaze {
namespace test {
namespace {
constexpr absl::Duration kTestCompletionTimeout = absl::Seconds(10);
constexpr absl::Duration kPollInterval = absl::Milliseconds(50);
const char* HarnessStatusToString(HarnessTestStatus status) {
switch (status) {
case HarnessTestStatus::kQueued:
return "queued";
case HarnessTestStatus::kRunning:
return "running";
case HarnessTestStatus::kPassed:
return "passed";
case HarnessTestStatus::kFailed:
return "failed";
case HarnessTestStatus::kTimeout:
return "timeout";
case HarnessTestStatus::kUnspecified:
default:
return "unknown";
}
}
} // namespace
TestRecorder::ScopedSuspension::ScopedSuspension(TestRecorder* recorder,
bool active)
: recorder_(recorder), active_(active) {}
TestRecorder::ScopedSuspension::~ScopedSuspension() {
if (!recorder_ || !active_) {
return;
}
absl::MutexLock lock(&recorder_->mu_);
recorder_->suspended_ = false;
}
TestRecorder::TestRecorder(TestManager* test_manager)
: test_manager_(test_manager) {}
absl::StatusOr<std::string> TestRecorder::Start(
const RecordingOptions& options) {
absl::MutexLock lock(&mu_);
return StartLocked(options);
}
absl::StatusOr<TestRecorder::StopRecordingSummary> TestRecorder::Stop(
const std::string& recording_id, bool discard) {
absl::MutexLock lock(&mu_);
return StopLocked(recording_id, discard);
}
void TestRecorder::RecordStep(const RecordedStep& step) {
absl::MutexLock lock(&mu_);
RecordStepLocked(step);
}
bool TestRecorder::IsRecording() const {
absl::MutexLock lock(&mu_);
return recording_ && !suspended_;
}
std::string TestRecorder::CurrentRecordingId() const {
absl::MutexLock lock(&mu_);
return recording_id_;
}
TestRecorder::ScopedSuspension TestRecorder::Suspend() {
absl::MutexLock lock(&mu_);
bool activate = false;
if (!suspended_) {
suspended_ = true;
activate = true;
}
return ScopedSuspension(this, activate);
}
absl::StatusOr<std::string> TestRecorder::StartLocked(
const RecordingOptions& options) {
if (recording_) {
return absl::FailedPreconditionError(
"A recording session is already active");
}
if (!test_manager_) {
return absl::FailedPreconditionError("TestManager unavailable");
}
if (options.output_path.empty()) {
return absl::InvalidArgumentError(
"Recording requires a non-empty output path");
}
recording_ = true;
suspended_ = false;
options_ = options;
if (options_.session_name.empty()) {
options_.session_name = "Untitled Recording";
}
started_at_ = absl::Now();
steps_.clear();
recording_id_ = GenerateRecordingId();
return recording_id_;
}
absl::StatusOr<TestRecorder::StopRecordingSummary> TestRecorder::StopLocked(
const std::string& recording_id, bool discard) {
if (!recording_) {
return absl::FailedPreconditionError("No active recording session");
}
if (!recording_id.empty() && recording_id != recording_id_) {
return absl::InvalidArgumentError(
absl::StrFormat("Recording ID mismatch (expected %s)", recording_id_));
}
StopRecordingSummary summary;
summary.step_count = static_cast<int>(steps_.size());
summary.duration = absl::Now() - started_at_;
summary.output_path = options_.output_path;
summary.saved = !discard;
if (!discard) {
RETURN_IF_ERROR(PopulateFinalStatusLocked());
TestScript script;
script.recording_id = recording_id_;
script.name = options_.session_name;
script.description = options_.description;
script.created_at = started_at_;
script.duration = summary.duration;
for (const auto& step : steps_) {
TestScriptStep script_step;
script_step.action = ActionTypeToString(step.type);
script_step.target = step.target;
script_step.click_type = absl::AsciiStrToLower(step.click_type);
script_step.text = step.text;
script_step.clear_first = step.clear_first;
script_step.condition = step.condition;
script_step.timeout_ms = step.timeout_ms;
script_step.region = step.region;
script_step.format = step.format;
script_step.expect_success = step.success;
script_step.expect_status = HarnessStatusToString(step.final_status);
if (!step.final_error_message.empty()) {
script_step.expect_message = step.final_error_message;
} else {
script_step.expect_message = step.message;
}
script_step.expect_assertion_failures = step.assertion_failures;
script_step.expect_metrics = step.metrics;
script.steps.push_back(std::move(script_step));
}
RETURN_IF_ERROR(
TestScriptParser::WriteToFile(script, options_.output_path));
}
// Reset state
recording_ = false;
suspended_ = false;
recording_id_.clear();
options_ = RecordingOptions{};
started_at_ = absl::InfinitePast();
steps_.clear();
return summary;
}
void TestRecorder::RecordStepLocked(const RecordedStep& step) {
if (!recording_ || suspended_) {
return;
}
RecordedStep copy = step;
if (copy.captured_at == absl::InfinitePast()) {
copy.captured_at = absl::Now();
}
steps_.push_back(std::move(copy));
}
absl::Status TestRecorder::PopulateFinalStatusLocked() {
if (!test_manager_) {
return absl::FailedPreconditionError("TestManager unavailable");
}
for (auto& step : steps_) {
if (step.test_id.empty()) {
continue;
}
const absl::Time deadline = absl::Now() + kTestCompletionTimeout;
while (absl::Now() < deadline) {
absl::StatusOr<HarnessTestExecution> execution =
test_manager_->GetHarnessTestExecution(step.test_id);
if (!execution.ok()) {
absl::SleepFor(kPollInterval);
continue;
}
step.final_status = execution->status;
step.final_error_message = execution->error_message;
step.assertion_failures = execution->assertion_failures;
step.metrics = execution->metrics;
if (execution->status == HarnessTestStatus::kQueued ||
execution->status == HarnessTestStatus::kRunning) {
absl::SleepFor(kPollInterval);
continue;
}
break;
}
}
return absl::OkStatus();
}
std::string TestRecorder::GenerateRecordingId() {
return absl::StrFormat(
"rec_%s", absl::FormatTime("%Y%m%dT%H%M%S", absl::Now(),
absl::UTCTimeZone()));
}
const char* TestRecorder::ActionTypeToString(ActionType type) {
switch (type) {
case ActionType::kClick:
return "click";
case ActionType::kType:
return "type";
case ActionType::kWait:
return "wait";
case ActionType::kAssert:
return "assert";
case ActionType::kScreenshot:
return "screenshot";
case ActionType::kUnknown:
default:
return "unknown";
}
}
} // namespace test
} // namespace yaze

View File

@@ -0,0 +1,121 @@
#ifndef YAZE_APP_CORE_TEST_RECORDER_H_
#define YAZE_APP_CORE_TEST_RECORDER_H_
#include <map>
#include <string>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/synchronization/mutex.h"
#include "absl/time/time.h"
#include "app/test/test_manager.h"
namespace yaze {
namespace test {
class TestManager;
// Recorder responsible for capturing GUI automation RPCs and exporting them as
// replayable JSON test scripts.
class TestRecorder {
public:
enum class ActionType {
kUnknown,
kClick,
kType,
kWait,
kAssert,
kScreenshot,
};
struct RecordingOptions {
std::string output_path;
std::string session_name;
std::string description;
};
struct RecordedStep {
ActionType type = ActionType::kUnknown;
std::string target;
std::string text;
std::string condition;
std::string click_type;
std::string region;
std::string format;
int timeout_ms = 0;
bool clear_first = false;
bool success = false;
int execution_time_ms = 0;
std::string message;
std::string test_id;
std::vector<std::string> assertion_failures;
std::string expected_value;
std::string actual_value;
HarnessTestStatus final_status = HarnessTestStatus::kUnspecified;
std::string final_error_message;
std::map<std::string, int32_t> metrics;
absl::Time captured_at = absl::InfinitePast();
};
struct StopRecordingSummary {
bool saved = false;
std::string output_path;
int step_count = 0;
absl::Duration duration = absl::ZeroDuration();
};
class ScopedSuspension {
public:
ScopedSuspension(TestRecorder* recorder, bool active);
ScopedSuspension(const ScopedSuspension&) = delete;
ScopedSuspension& operator=(const ScopedSuspension&) = delete;
~ScopedSuspension();
private:
TestRecorder* recorder_;
bool active_ = false;
};
explicit TestRecorder(TestManager* test_manager);
absl::StatusOr<std::string> Start(const RecordingOptions& options);
absl::StatusOr<StopRecordingSummary> Stop(const std::string& recording_id,
bool discard);
void RecordStep(const RecordedStep& step);
bool IsRecording() const;
std::string CurrentRecordingId() const;
ScopedSuspension Suspend();
private:
absl::StatusOr<std::string> StartLocked(const RecordingOptions& options)
ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_);
absl::StatusOr<StopRecordingSummary> StopLocked(const std::string& recording_id,
bool discard)
ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_);
void RecordStepLocked(const RecordedStep& step)
ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_);
absl::Status PopulateFinalStatusLocked()
ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_);
static std::string GenerateRecordingId();
static const char* ActionTypeToString(ActionType type);
mutable absl::Mutex mu_;
TestManager* const test_manager_; // Not owned
bool recording_ ABSL_GUARDED_BY(mu_) = false;
bool suspended_ ABSL_GUARDED_BY(mu_) = false;
std::string recording_id_ ABSL_GUARDED_BY(mu_);
RecordingOptions options_ ABSL_GUARDED_BY(mu_);
absl::Time started_at_ ABSL_GUARDED_BY(mu_) = absl::InfinitePast();
std::vector<RecordedStep> steps_ ABSL_GUARDED_BY(mu_);
};
} // namespace test
} // namespace yaze
#endif // YAZE_APP_CORE_TEST_RECORDER_H_

View File

@@ -0,0 +1,251 @@
#include "app/core/test_script_parser.h"
#include <filesystem>
#include <fstream>
#include <sstream>
#include "absl/strings/ascii.h"
#include "absl/strings/str_format.h"
#include "absl/time/clock.h"
#include "nlohmann/json.hpp"
#include "util/macro.h"
namespace yaze {
namespace test {
namespace {
constexpr int kSupportedSchemaVersion = 1;
std::string FormatIsoTimestamp(absl::Time time) {
if (time == absl::InfinitePast()) {
return "";
}
return absl::FormatTime("%Y-%m-%dT%H:%M:%S%Ez", time, absl::UTCTimeZone());
}
absl::StatusOr<absl::Time> ParseIsoTimestamp(const nlohmann::json& node,
const char* field_name) {
if (!node.contains(field_name)) {
return absl::InfinitePast();
}
const std::string value = node.at(field_name).get<std::string>();
if (value.empty()) {
return absl::InfinitePast();
}
absl::Time parsed;
std::string err;
if (!absl::ParseTime("%Y-%m-%dT%H:%M:%S%Ez", value, &parsed, &err)) {
return absl::InvalidArgumentError(
absl::StrFormat("Failed to parse timestamp '%s': %s", value, err));
}
return parsed;
}
void WriteExpectSection(const TestScriptStep& step, nlohmann::json* node) {
nlohmann::json expect;
expect["success"] = step.expect_success;
if (!step.expect_status.empty()) {
expect["status"] = step.expect_status;
}
if (!step.expect_message.empty()) {
expect["message"] = step.expect_message;
}
if (!step.expect_assertion_failures.empty()) {
expect["assertion_failures"] = step.expect_assertion_failures;
}
if (!step.expect_metrics.empty()) {
nlohmann::json metrics(nlohmann::json::value_t::object);
for (const auto& [key, value] : step.expect_metrics) {
metrics[key] = value;
}
expect["metrics"] = std::move(metrics);
}
(*node)["expect"] = std::move(expect);
}
void PopulateStepNode(const TestScriptStep& step, nlohmann::json* node) {
(*node)["action"] = step.action;
if (!step.target.empty()) {
(*node)["target"] = step.target;
}
if (!step.click_type.empty()) {
(*node)["click_type"] = step.click_type;
}
if (!step.text.empty()) {
(*node)["text"] = step.text;
}
if (step.clear_first) {
(*node)["clear_first"] = step.clear_first;
}
if (!step.condition.empty()) {
(*node)["condition"] = step.condition;
}
if (step.timeout_ms > 0) {
(*node)["timeout_ms"] = step.timeout_ms;
}
if (!step.region.empty()) {
(*node)["region"] = step.region;
}
if (!step.format.empty()) {
(*node)["format"] = step.format;
}
WriteExpectSection(step, node);
}
absl::StatusOr<TestScriptStep> ParseStep(const nlohmann::json& node) {
if (!node.contains("action")) {
return absl::InvalidArgumentError(
"Test script step missing required field 'action'");
}
TestScriptStep step;
step.action = absl::AsciiStrToLower(node.at("action").get<std::string>());
if (node.contains("target")) {
step.target = node.at("target").get<std::string>();
}
if (node.contains("click_type")) {
step.click_type =
absl::AsciiStrToLower(node.at("click_type").get<std::string>());
}
if (node.contains("text")) {
step.text = node.at("text").get<std::string>();
}
if (node.contains("clear_first")) {
step.clear_first = node.at("clear_first").get<bool>();
}
if (node.contains("condition")) {
step.condition = node.at("condition").get<std::string>();
}
if (node.contains("timeout_ms")) {
step.timeout_ms = node.at("timeout_ms").get<int>();
}
if (node.contains("region")) {
step.region = node.at("region").get<std::string>();
}
if (node.contains("format")) {
step.format = node.at("format").get<std::string>();
}
if (node.contains("expect")) {
const auto& expect = node.at("expect");
if (expect.contains("success")) {
step.expect_success = expect.at("success").get<bool>();
}
if (expect.contains("status")) {
step.expect_status =
absl::AsciiStrToLower(expect.at("status").get<std::string>());
}
if (expect.contains("message")) {
step.expect_message = expect.at("message").get<std::string>();
}
if (expect.contains("assertion_failures")) {
for (const auto& value : expect.at("assertion_failures")) {
step.expect_assertion_failures.push_back(value.get<std::string>());
}
}
if (expect.contains("metrics")) {
for (auto it = expect.at("metrics").begin();
it != expect.at("metrics").end(); ++it) {
step.expect_metrics[it.key()] = it.value().get<int32_t>();
}
}
}
return step;
}
} // namespace
absl::Status TestScriptParser::WriteToFile(const TestScript& script,
const std::string& path) {
nlohmann::json root;
root["schema_version"] = script.schema_version;
root["recording_id"] = script.recording_id;
root["name"] = script.name;
root["description"] = script.description;
root["created_at"] = FormatIsoTimestamp(script.created_at);
root["duration_ms"] = absl::ToInt64Milliseconds(script.duration);
nlohmann::json steps_json = nlohmann::json::array();
for (const auto& step : script.steps) {
nlohmann::json step_node(nlohmann::json::value_t::object);
PopulateStepNode(step, &step_node);
steps_json.push_back(std::move(step_node));
}
root["steps"] = std::move(steps_json);
std::filesystem::path output_path(path);
std::error_code ec;
auto parent = output_path.parent_path();
if (!parent.empty() && !std::filesystem::exists(parent)) {
if (!std::filesystem::create_directories(parent, ec)) {
return absl::InternalError(absl::StrFormat(
"Failed to create directory '%s': %s", parent.string(), ec.message()));
}
}
std::ofstream ofs(output_path, std::ios::out | std::ios::trunc);
if (!ofs.good()) {
return absl::InternalError(
absl::StrFormat("Failed to open '%s' for writing", path));
}
ofs << root.dump(2) << '\n';
return absl::OkStatus();
}
absl::StatusOr<TestScript> TestScriptParser::ParseFromFile(
const std::string& path) {
std::ifstream ifs(path);
if (!ifs.good()) {
return absl::NotFoundError(
absl::StrFormat("Test script '%s' not found", path));
}
nlohmann::json root;
try {
ifs >> root;
} catch (const nlohmann::json::exception& e) {
return absl::InvalidArgumentError(
absl::StrFormat("Failed to parse JSON: %s", e.what()));
}
TestScript script;
script.schema_version =
root.contains("schema_version") ? root["schema_version"].get<int>() : 1;
if (script.schema_version != kSupportedSchemaVersion) {
return absl::InvalidArgumentError(absl::StrFormat(
"Unsupported test script schema version %d", script.schema_version));
}
if (root.contains("recording_id")) {
script.recording_id = root["recording_id"].get<std::string>();
}
if (root.contains("name")) {
script.name = root["name"].get<std::string>();
}
if (root.contains("description")) {
script.description = root["description"].get<std::string>();
}
ASSIGN_OR_RETURN(script.created_at,
ParseIsoTimestamp(root, "created_at"));
if (root.contains("duration_ms")) {
script.duration = absl::Milliseconds(root["duration_ms"].get<int64_t>());
}
if (!root.contains("steps") || !root["steps"].is_array()) {
return absl::InvalidArgumentError(
"Test script missing required array field 'steps'");
}
for (const auto& step_node : root["steps"]) {
ASSIGN_OR_RETURN(auto step, ParseStep(step_node));
script.steps.push_back(std::move(step));
}
return script;
}
} // namespace test
} // namespace yaze

View File

@@ -0,0 +1,53 @@
#ifndef YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_
#define YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_
#include <map>
#include <string>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/time/time.h"
namespace yaze {
namespace test {
struct TestScriptStep {
std::string action;
std::string target;
std::string click_type;
std::string text;
bool clear_first = false;
std::string condition;
int timeout_ms = 0;
std::string region;
std::string format;
bool expect_success = true;
std::string expect_status;
std::string expect_message;
std::vector<std::string> expect_assertion_failures;
std::map<std::string, int32_t> expect_metrics;
};
struct TestScript {
int schema_version = 1;
std::string recording_id;
std::string name;
std::string description;
absl::Time created_at = absl::InfinitePast();
absl::Duration duration = absl::ZeroDuration();
std::vector<TestScriptStep> steps;
};
class TestScriptParser {
public:
static absl::Status WriteToFile(const TestScript& script,
const std::string& path);
static absl::StatusOr<TestScript> ParseFromFile(const std::string& path);
};
} // namespace test
} // namespace yaze
#endif // YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_