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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
253
src/app/core/test_recorder.cc
Normal file
253
src/app/core/test_recorder.cc
Normal 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
|
||||
121
src/app/core/test_recorder.h
Normal file
121
src/app/core/test_recorder.h
Normal 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_
|
||||
251
src/app/core/test_script_parser.cc
Normal file
251
src/app/core/test_script_parser.cc
Normal 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
|
||||
53
src/app/core/test_script_parser.h
Normal file
53
src/app/core/test_script_parser.h
Normal 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_
|
||||
Reference in New Issue
Block a user