8#include "absl/status/status.h"
9#include "absl/status/statusor.h"
10#include "absl/strings/numbers.h"
11#include "absl/strings/str_cat.h"
12#include "absl/strings/str_format.h"
13#include "absl/time/time.h"
15#include "nlohmann/json.hpp"
31struct RecordingState {
32 std::string recording_id;
33 std::string host =
"localhost";
35 std::string output_path;
38std::filesystem::path RecordingStateFilePath() {
40 std::filesystem::path base = std::filesystem::temp_directory_path(ec);
42 base = std::filesystem::current_path();
44 return base /
"yaze" /
"agent" /
"recording_state.json";
47absl::Status SaveRecordingState(
const RecordingState& state) {
48 auto path = RecordingStateFilePath();
50 std::filesystem::create_directories(path.parent_path(), ec);
52 json[
"recording_id"] = state.recording_id;
53 json[
"host"] = state.host;
54 json[
"port"] = state.port;
55 json[
"output_path"] = state.output_path;
57 std::ofstream out(path, std::ios::out | std::ios::trunc);
59 return absl::InternalError(
60 absl::StrCat(
"Failed to write recording state to ", path.string()));
64 return absl::InternalError(
65 absl::StrCat(
"Failed to flush recording state to ", path.string()));
67 return absl::OkStatus();
70absl::StatusOr<RecordingState> LoadRecordingState() {
71 auto path = RecordingStateFilePath();
72 std::ifstream in(path);
74 return absl::NotFoundError(
75 "No active recording session found. Run 'z3ed agent test record start' "
82 }
catch (
const nlohmann::json::parse_error& error) {
83 return absl::InternalError(
84 absl::StrCat(
"Failed to parse recording state at ", path.string(),
": ",
89 state.recording_id =
json.value(
"recording_id",
"");
90 state.host =
json.value(
"host",
"localhost");
91 state.port =
json.value(
"port", 50052);
92 state.output_path =
json.value(
"output_path",
"");
94 if (state.recording_id.empty()) {
95 return absl::InvalidArgumentError(absl::StrCat(
96 "Recording state at ", path.string(),
" is missing a recording_id"));
102absl::Status ClearRecordingState() {
103 auto path = RecordingStateFilePath();
105 std::filesystem::remove(path, ec);
106 if (ec && ec != std::errc::no_such_file_or_directory) {
107 return absl::InternalError(
108 absl::StrCat(
"Failed to clear recording state: ", ec.message()));
110 return absl::OkStatus();
113std::string DefaultRecordingOutputPath() {
114 absl::Time now = absl::Now();
116 "tests/gui/recording-",
117 absl::FormatTime(
"%Y%m%dT%H%M%S", now, absl::LocalTimeZone()),
".json");
123absl::Status HandleTestRunCommand(
const std::vector<std::string>& args);
124absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args);
125absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args);
126absl::Status HandleTestListCommand(
const std::vector<std::string>& args);
127absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args);
128absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args);
130absl::Status HandleTestRunCommand(
const std::vector<std::string>& args) {
131 if (args.empty() || args[0] !=
"--prompt") {
132 return absl::InvalidArgumentError(
133 "Usage: agent test run --prompt <description> [--host <host>] [--port "
135 "Example: agent test run --prompt \"Open the overworld editor and "
136 "verify it loads\"");
139 std::string prompt = args.size() > 1 ? args[1] :
"";
140 std::string host =
"localhost";
144 for (
size_t i = 2; i < args.size(); ++i) {
145 if (args[i] ==
"--host" && i + 1 < args.size()) {
147 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
148 port = std::stoi(args[++i]);
152 std::cout <<
"\n=== GUI Automation Test ===\n";
153 std::cout <<
"Prompt: " << prompt <<
"\n";
154 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
157 TestWorkflowGenerator generator;
158 auto workflow_or = generator.GenerateWorkflow(prompt);
159 if (!workflow_or.ok()) {
160 std::cerr <<
"Failed to generate workflow: "
161 << workflow_or.status().message() << std::endl;
162 return workflow_or.status();
165 auto workflow = workflow_or.value();
166 std::cout <<
"Generated " << workflow.steps.size() <<
" test steps\n\n";
169 GuiAutomationClient client(absl::StrCat(host,
":", port));
170 auto status = client.Connect();
172 std::cerr <<
"Failed to connect to test harness: " << status.message()
178 for (
size_t i = 0; i < workflow.steps.size(); ++i) {
179 const auto& step = workflow.steps[i];
180 std::cout <<
"Step " << (i + 1) <<
": " << step.ToString() <<
"... ";
183 absl::StatusOr<AutomationResult> result(
184 absl::InternalError(
"Unknown step type"));
188 result = client.Click(step.target);
191 result = client.Type(step.target, step.text, step.clear_first);
194 result = client.Wait(step.condition, step.timeout_ms);
197 result = client.Assert(step.condition);
200 std::cout <<
"✗ SKIPPED (unknown type)\n";
205 std::cout <<
"✗ FAILED\n";
206 std::cerr <<
" Error: " << result.status().message() <<
"\n";
207 return result.status();
210 if (!result.value().success) {
211 std::cout <<
"✗ FAILED\n";
212 std::cerr <<
" Error: " << result.value().message <<
"\n";
213 return absl::InternalError(result.value().message);
219 std::cout <<
"\n✅ Test passed!\n";
220 return absl::OkStatus();
223absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args) {
225 return absl::InvalidArgumentError(
226 "Usage: agent test replay <script.json> [--host <host>] [--port "
228 "Example: agent test replay tests/overworld_load.json");
231 std::string script_path = args[0];
232 std::string host =
"localhost";
235 for (
size_t i = 1; i < args.size(); ++i) {
236 if (args[i] ==
"--host" && i + 1 < args.size()) {
238 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
239 port = std::stoi(args[++i]);
243 std::cout <<
"\n=== Replay Test ===\n";
244 std::cout <<
"Script: " << script_path <<
"\n";
245 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
247 GuiAutomationClient client(absl::StrCat(host,
":", port));
248 auto status = client.Connect();
250 std::cerr <<
"Failed to connect: " << status.message() << std::endl;
254 auto result = client.ReplayTest(script_path,
false, {});
256 std::cerr <<
"Replay failed: " << result.status().message() << std::endl;
257 return result.status();
260 if (result.value().success) {
261 std::cout <<
"✅ Replay succeeded\n";
262 std::cout <<
"Steps executed: " << result.value().steps_executed <<
"\n";
264 std::cout <<
"❌ Replay failed: " << result.value().message <<
"\n";
265 return absl::InternalError(result.value().message);
268 return absl::OkStatus();
271absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args) {
273 std::string host =
"localhost";
276 for (
size_t i = 0; i < args.size(); ++i) {
277 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
279 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
281 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
282 port = std::stoi(args[++i]);
286 if (test_id.empty()) {
287 return absl::InvalidArgumentError(
288 "Usage: agent test status --test-id <id> [--host <host>] [--port "
292 GuiAutomationClient client(absl::StrCat(host,
":", port));
293 auto status = client.Connect();
298 auto details = client.GetTestStatus(test_id);
300 return details.status();
303 std::cout <<
"\n=== Test Status ===\n";
304 std::cout <<
"Test ID: " << test_id <<
"\n";
305 std::cout <<
"Status: " << TestRunStatusToString(details.value().status)
312 if (!details.value().error_message.empty()) {
313 std::cout <<
"Error: " << details.value().error_message <<
"\n";
316 return absl::OkStatus();
319absl::Status HandleTestListCommand(
const std::vector<std::string>& args) {
320 std::string host =
"localhost";
323 for (
size_t i = 0; i < args.size(); ++i) {
324 if (args[i] ==
"--host" && i + 1 < args.size()) {
326 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
327 port = std::stoi(args[++i]);
331 GuiAutomationClient client(absl::StrCat(host,
":", port));
332 auto status = client.Connect();
337 auto batch = client.ListTests(
"", 100,
"");
339 return batch.status();
342 std::cout <<
"\n=== Available Tests ===\n";
343 std::cout <<
"Total: " << batch.value().total_count <<
"\n\n";
345 for (
const auto& test : batch.value().tests) {
346 std::cout <<
"• " << test.name <<
"\n";
347 std::cout <<
" ID: " << test.test_id <<
"\n";
348 std::cout <<
" Category: " << test.category <<
"\n";
349 std::cout <<
" Runs: " << test.total_runs <<
" (" << test.pass_count
350 <<
" passed, " << test.fail_count <<
" failed)\n\n";
353 return absl::OkStatus();
356absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args) {
358 std::string host =
"localhost";
360 bool include_logs =
false;
362 for (
size_t i = 0; i < args.size(); ++i) {
363 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
365 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
367 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
368 port = std::stoi(args[++i]);
369 }
else if (args[i] ==
"--include-logs") {
374 if (test_id.empty()) {
375 return absl::InvalidArgumentError(
376 "Usage: agent test results --test-id <id> [--include-logs] [--host "
377 "<host>] [--port <port>]");
380 GuiAutomationClient client(absl::StrCat(host,
":", port));
381 auto status = client.Connect();
386 auto details = client.GetTestResults(test_id, include_logs);
388 return details.status();
391 std::cout <<
"\n=== Test Results ===\n";
392 std::cout <<
"Test ID: " << details.value().test_id <<
"\n";
393 std::cout <<
"Name: " << details.value().test_name <<
"\n";
394 std::cout <<
"Success: " << (details.value().success ?
"✓" :
"✗") <<
"\n";
395 std::cout <<
"Duration: " << details.value().duration_ms <<
"ms\n\n";
397 if (!details.value().assertions.empty()) {
398 std::cout <<
"Assertions:\n";
399 for (
const auto& assertion : details.value().assertions) {
400 std::cout <<
" " << (assertion.passed ?
"✓" :
"✗") <<
" "
401 << assertion.description <<
"\n";
402 if (!assertion.error_message.empty()) {
403 std::cout <<
" Error: " << assertion.error_message <<
"\n";
408 if (include_logs && !details.value().logs.empty()) {
409 std::cout <<
"\nLogs:\n";
410 for (
const auto& log : details.value().logs) {
411 std::cout <<
" " << log <<
"\n";
415 return absl::OkStatus();
418absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args) {
420 return absl::InvalidArgumentError(
421 "Usage: agent test record <start|stop> [options]\n"
422 " start [--output <file>] [--description <text>] [--session <id>]\n"
423 " [--host <host>] [--port <port>]\n"
424 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
427 std::string action = args[0];
428 if (action !=
"start" && action !=
"stop") {
429 return absl::InvalidArgumentError(
430 "Record action must be 'start' or 'stop'");
433 if (action ==
"start") {
434 std::string host =
"localhost";
437 std::string session_name;
438 std::string output_path;
440 for (
size_t i = 1; i < args.size(); ++i) {
441 const std::string& token = args[i];
442 if (token ==
"--output" && i + 1 < args.size()) {
443 output_path = args[++i];
444 }
else if (token ==
"--description" && i + 1 < args.size()) {
446 }
else if (token ==
"--session" && i + 1 < args.size()) {
447 session_name = args[++i];
448 }
else if (token ==
"--host" && i + 1 < args.size()) {
450 }
else if (token ==
"--port" && i + 1 < args.size()) {
451 std::string port_value = args[++i];
453 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
454 return absl::InvalidArgumentError(
455 absl::StrCat(
"Invalid --port value: ", port_value));
461 if (output_path.empty()) {
462 output_path = DefaultRecordingOutputPath();
465 std::filesystem::path absolute_output =
466 std::filesystem::absolute(output_path);
468 std::filesystem::create_directories(absolute_output.parent_path(), ec);
470 GuiAutomationClient client(absl::StrCat(host,
":", port));
473 if (session_name.empty()) {
474 session_name = std::filesystem::path(output_path).stem().string();
478 client.StartRecording(absolute_output.string(),
480 if (!start_result.success) {
481 return absl::InternalError(absl::StrCat(
482 "Harness rejected start-recording request: ", start_result.message));
485 RecordingState state;
486 state.recording_id = start_result.recording_id;
489 state.output_path = absolute_output.string();
492 std::cout <<
"\n=== Recording Session Started ===\n";
493 std::cout <<
"Recording ID: " << start_result.recording_id <<
"\n";
494 std::cout <<
"Server: " << host <<
":" << port <<
"\n";
495 std::cout <<
"Output: " << absolute_output <<
"\n";
497 std::cout <<
"Description: " <<
description <<
"\n";
499 if (start_result.started_at.has_value()) {
500 std::cout <<
"Started: "
501 << absl::FormatTime(
"%Y-%m-%d %H:%M:%S",
502 *start_result.started_at,
503 absl::LocalTimeZone())
506 std::cout <<
"\nPress Ctrl+C to abort the recording session.\n";
508 return absl::OkStatus();
512 bool validate =
false;
513 bool discard =
false;
514 std::optional<std::string> host_override;
515 std::optional<int> port_override;
517 for (
size_t i = 1; i < args.size(); ++i) {
518 const std::string& token = args[i];
519 if (token ==
"--validate") {
521 }
else if (token ==
"--discard") {
523 }
else if (token ==
"--host" && i + 1 < args.size()) {
524 host_override = args[++i];
525 }
else if (token ==
"--port" && i + 1 < args.size()) {
526 std::string port_value = args[++i];
528 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
529 return absl::InvalidArgumentError(
530 absl::StrCat(
"Invalid --port value: ", port_value));
532 port_override = parsed_port;
536 if (discard && validate) {
537 return absl::InvalidArgumentError(
538 "Cannot use --validate and --discard together");
542 if (host_override.has_value()) {
543 state.host = *host_override;
545 if (port_override.has_value()) {
546 state.port = *port_override;
549 GuiAutomationClient client(absl::StrCat(state.host,
":", state.port));
553 client.StopRecording(state.recording_id, discard));
554 if (!stop_result.success) {
555 return absl::InternalError(
556 absl::StrCat(
"Stop recording failed: ", stop_result.message));
560 std::cout <<
"\n=== Recording Session Completed ===\n";
561 std::cout <<
"Recording ID: " << state.recording_id <<
"\n";
562 std::cout <<
"Server: " << state.host <<
":" << state.port <<
"\n";
563 std::cout <<
"Steps captured: " << stop_result.step_count <<
"\n";
564 std::cout <<
"Duration: " << stop_result.duration.count() <<
" ms\n";
565 if (!stop_result.message.empty()) {
566 std::cout <<
"Message: " << stop_result.message <<
"\n";
568 if (!discard && !stop_result.output_path.empty()) {
569 std::cout <<
"Output saved to: " << stop_result.output_path <<
"\n";
573 std::cout <<
"Recording discarded; no script file was produced."
575 return absl::OkStatus();
578 if (!validate || stop_result.output_path.empty()) {
579 std::cout << std::endl;
580 return absl::OkStatus();
583 std::cout <<
"\nReplaying recorded script to validate...\n";
585 client.ReplayTest(stop_result.output_path,
false, {}));
586 if (!replay_result.success) {
587 return absl::InternalError(
588 absl::StrCat(
"Replay failed: ", replay_result.message));
591 std::cout <<
"Replay succeeded. Steps executed: "
592 << replay_result.steps_executed <<
"\n";
593 return absl::OkStatus();
600 return absl::InvalidArgumentError(
601 "Usage: agent test <subcommand>\n"
603 " run --prompt <text> - Generate and run a GUI automation test\n"
604 " replay <script> - Replay a recorded test script\n"
605 " status --test-id <id> - Query test execution status\n"
606 " list - List available tests\n"
607 " results --test-id <id> - Get detailed test results\n"
608 " record start/stop - Record test interactions\n"
609 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
612#ifndef YAZE_WITH_GRPC
613 return absl::UnimplementedError(
614 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
615 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
616 "Then: cmake --build build-grpc-test --target z3ed");
618 std::string subcommand = args[0];
619 std::vector<std::string> tail(args.begin() + 1, args.end());
621 if (subcommand ==
"run") {
622 return HandleTestRunCommand(tail);
623 }
else if (subcommand ==
"replay") {
624 return HandleTestReplayCommand(tail);
625 }
else if (subcommand ==
"status") {
626 return HandleTestStatusCommand(tail);
627 }
else if (subcommand ==
"list") {
628 return HandleTestListCommand(tail);
629 }
else if (subcommand ==
"results") {
630 return HandleTestResultsCommand(tail);
631 }
else if (subcommand ==
"record") {
632 return HandleTestRecordCommand(tail);
634 return absl::InvalidArgumentError(
635 absl::StrCat(
"Unknown test subcommand: ", subcommand,
636 "\nRun 'z3ed agent test' for usage."));
#define ASSIGN_OR_RETURN(type_variable_name, expression)
std::string FormatOptionalTime(const std::optional< absl::Time > &time)
absl::Status HandleTestCommand(const std::vector< std::string > &args)
#define RETURN_IF_ERROR(expr)