From d8f863a9cee1517d0f01f05cae8468e605728329 Mon Sep 17 00:00:00 2001 From: scawful Date: Thu, 2 Oct 2025 18:00:58 -0400 Subject: [PATCH] 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. --- src/app/app.cmake | 7 + src/app/core/imgui_test_harness_service.cc | 596 +++++++++++++++++++- src/app/core/imgui_test_harness_service.h | 16 +- src/app/core/proto/imgui_test_harness.proto | 89 ++- src/app/core/test_recorder.cc | 253 +++++++++ src/app/core/test_recorder.h | 121 ++++ src/app/core/test_script_parser.cc | 251 +++++++++ src/app/core/test_script_parser.h | 53 ++ 8 files changed, 1351 insertions(+), 35 deletions(-) create mode 100644 src/app/core/test_recorder.cc create mode 100644 src/app/core/test_recorder.h create mode 100644 src/app/core/test_script_parser.cc create mode 100644 src/app/core/test_script_parser.h diff --git a/src/app/app.cmake b/src/app/app.cmake index 60e7d254..3afe2758 100644 --- a/src/app/app.cmake +++ b/src/app/app.cmake @@ -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 diff --git a/src/app/core/imgui_test_harness_service.cc b/src/app/core/imgui_test_harness_service.cc index b49ad641..f5d2c3dc 100644 --- a/src/app/core/imgui_test_harness_service.cc +++ b/src/app/core/imgui_test_harness_service.cc @@ -9,15 +9,19 @@ #include #include +#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& 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 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 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 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 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 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 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 // ============================================================================ diff --git a/src/app/core/imgui_test_harness_service.h b/src/app/core/imgui_test_harness_service.h index 6f5c2d83..95e3b929 100644 --- a/src/app/core/imgui_test_harness_service.h +++ b/src/app/core/imgui_test_harness_service.h @@ -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 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 diff --git a/src/app/core/proto/imgui_test_harness.proto b/src/app/core/proto/imgui_test_harness.proto index ebca3489..91333a04 100644 --- a/src/app/core/proto/imgui_test_harness.proto +++ b/src/app/core/proto/imgui_test_harness.proto @@ -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 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 +} diff --git a/src/app/core/test_recorder.cc b/src/app/core/test_recorder.cc new file mode 100644 index 00000000..3a71de41 --- /dev/null +++ b/src/app/core/test_recorder.cc @@ -0,0 +1,253 @@ +#include "app/core/test_recorder.h" + +#include + +#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 TestRecorder::Start( + const RecordingOptions& options) { + absl::MutexLock lock(&mu_); + return StartLocked(options); +} + +absl::StatusOr 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 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::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(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 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 diff --git a/src/app/core/test_recorder.h b/src/app/core/test_recorder.h new file mode 100644 index 00000000..51fb5f0e --- /dev/null +++ b/src/app/core/test_recorder.h @@ -0,0 +1,121 @@ +#ifndef YAZE_APP_CORE_TEST_RECORDER_H_ +#define YAZE_APP_CORE_TEST_RECORDER_H_ + +#include +#include +#include + +#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 assertion_failures; + std::string expected_value; + std::string actual_value; + HarnessTestStatus final_status = HarnessTestStatus::kUnspecified; + std::string final_error_message; + std::map 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 Start(const RecordingOptions& options); + absl::StatusOr 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 StartLocked(const RecordingOptions& options) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + absl::StatusOr 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 steps_ ABSL_GUARDED_BY(mu_); +}; + +} // namespace test +} // namespace yaze + +#endif // YAZE_APP_CORE_TEST_RECORDER_H_ diff --git a/src/app/core/test_script_parser.cc b/src/app/core/test_script_parser.cc new file mode 100644 index 00000000..739a5373 --- /dev/null +++ b/src/app/core/test_script_parser.cc @@ -0,0 +1,251 @@ +#include "app/core/test_script_parser.h" + +#include +#include +#include + +#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 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(); + 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 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()); + if (node.contains("target")) { + step.target = node.at("target").get(); + } + if (node.contains("click_type")) { + step.click_type = + absl::AsciiStrToLower(node.at("click_type").get()); + } + if (node.contains("text")) { + step.text = node.at("text").get(); + } + if (node.contains("clear_first")) { + step.clear_first = node.at("clear_first").get(); + } + if (node.contains("condition")) { + step.condition = node.at("condition").get(); + } + if (node.contains("timeout_ms")) { + step.timeout_ms = node.at("timeout_ms").get(); + } + if (node.contains("region")) { + step.region = node.at("region").get(); + } + if (node.contains("format")) { + step.format = node.at("format").get(); + } + + if (node.contains("expect")) { + const auto& expect = node.at("expect"); + if (expect.contains("success")) { + step.expect_success = expect.at("success").get(); + } + if (expect.contains("status")) { + step.expect_status = + absl::AsciiStrToLower(expect.at("status").get()); + } + if (expect.contains("message")) { + step.expect_message = expect.at("message").get(); + } + if (expect.contains("assertion_failures")) { + for (const auto& value : expect.at("assertion_failures")) { + step.expect_assertion_failures.push_back(value.get()); + } + } + 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(); + } + } + } + + 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 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() : 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(); + } + if (root.contains("name")) { + script.name = root["name"].get(); + } + if (root.contains("description")) { + script.description = root["description"].get(); + } + + ASSIGN_OR_RETURN(script.created_at, + ParseIsoTimestamp(root, "created_at")); + if (root.contains("duration_ms")) { + script.duration = absl::Milliseconds(root["duration_ms"].get()); + } + + 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 diff --git a/src/app/core/test_script_parser.h b/src/app/core/test_script_parser.h new file mode 100644 index 00000000..3a208e6c --- /dev/null +++ b/src/app/core/test_script_parser.h @@ -0,0 +1,53 @@ +#ifndef YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_ +#define YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_ + +#include +#include +#include + +#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 expect_assertion_failures; + std::map 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 steps; +}; + +class TestScriptParser { + public: + static absl::Status WriteToFile(const TestScript& script, + const std::string& path); + + static absl::StatusOr ParseFromFile(const std::string& path); +}; + +} // namespace test +} // namespace yaze + +#endif // YAZE_APP_CORE_TEST_SCRIPT_PARSER_H_