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/clock.h"
14#include "absl/time/time.h"
16#include "nlohmann/json.hpp"
32struct RecordingState {
33 std::string recording_id;
34 std::string host =
"localhost";
36 std::string output_path;
39std::filesystem::path RecordingStateFilePath() {
41 std::filesystem::path base = std::filesystem::temp_directory_path(ec);
43 base = std::filesystem::current_path();
45 return base /
"yaze" /
"agent" /
"recording_state.json";
48absl::Status SaveRecordingState(
const RecordingState& state) {
49 auto path = RecordingStateFilePath();
51 std::filesystem::create_directories(path.parent_path(), ec);
53 json[
"recording_id"] = state.recording_id;
54 json[
"host"] = state.host;
55 json[
"port"] = state.port;
56 json[
"output_path"] = state.output_path;
58 std::ofstream out(path, std::ios::out | std::ios::trunc);
60 return absl::InternalError(
61 absl::StrCat(
"Failed to write recording state to ", path.string()));
65 return absl::InternalError(
66 absl::StrCat(
"Failed to flush recording state to ", path.string()));
68 return absl::OkStatus();
71absl::StatusOr<RecordingState> LoadRecordingState() {
72 auto path = RecordingStateFilePath();
73 std::ifstream in(path);
75 return absl::NotFoundError(
76 "No active recording session found. Run 'z3ed agent test record start' "
83 }
catch (
const nlohmann::json::parse_error& error) {
84 return absl::InternalError(
85 absl::StrCat(
"Failed to parse recording state at ", path.string(),
": ",
90 state.recording_id =
json.value(
"recording_id",
"");
91 state.host =
json.value(
"host",
"localhost");
92 state.port =
json.value(
"port", 50052);
93 state.output_path =
json.value(
"output_path",
"");
95 if (state.recording_id.empty()) {
96 return absl::InvalidArgumentError(absl::StrCat(
97 "Recording state at ", path.string(),
" is missing a recording_id"));
103absl::Status ClearRecordingState() {
104 auto path = RecordingStateFilePath();
106 std::filesystem::remove(path, ec);
107 if (ec && ec != std::errc::no_such_file_or_directory) {
108 return absl::InternalError(
109 absl::StrCat(
"Failed to clear recording state: ", ec.message()));
111 return absl::OkStatus();
114std::string DefaultRecordingOutputPath() {
115 absl::Time now = absl::Now();
117 "tests/gui/recording-",
118 absl::FormatTime(
"%Y%m%dT%H%M%S", now, absl::LocalTimeZone()),
".json");
124absl::Status HandleTestRunCommand(
const std::vector<std::string>& args);
125absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args);
126absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args);
127absl::Status HandleTestListCommand(
const std::vector<std::string>& args);
128absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args);
129absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args);
131absl::Status HandleTestRunCommand(
const std::vector<std::string>& args) {
132 if (args.empty() || args[0] !=
"--prompt") {
133 return absl::InvalidArgumentError(
134 "Usage: agent test run --prompt <description> [--host <host>] [--port "
136 "Example: agent test run --prompt \"Open the overworld editor and "
137 "verify it loads\"");
140 std::string prompt = args.size() > 1 ? args[1] :
"";
141 std::string host =
"localhost";
145 for (
size_t i = 2; i < args.size(); ++i) {
146 if (args[i] ==
"--host" && i + 1 < args.size()) {
148 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
149 port = std::stoi(args[++i]);
153 std::cout <<
"\n=== GUI Automation Test ===\n";
154 std::cout <<
"Prompt: " << prompt <<
"\n";
155 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
158 TestWorkflowGenerator generator;
159 auto workflow_or = generator.GenerateWorkflow(prompt);
160 if (!workflow_or.ok()) {
161 std::cerr <<
"Failed to generate workflow: "
162 << workflow_or.status().message() << std::endl;
163 return workflow_or.status();
166 auto workflow = workflow_or.value();
167 std::cout <<
"Generated " << workflow.steps.size() <<
" test steps\n\n";
170 GuiAutomationClient client(absl::StrCat(host,
":", port));
171 auto status = client.Connect();
173 std::cerr <<
"Failed to connect to test harness: " << status.message()
179 for (
size_t i = 0; i < workflow.steps.size(); ++i) {
180 const auto& step = workflow.steps[i];
181 std::cout <<
"Step " << (i + 1) <<
": " << step.ToString() <<
"... ";
184 absl::StatusOr<AutomationResult> result(
185 absl::InternalError(
"Unknown step type"));
189 result = client.Click(step.target);
192 result = client.Type(step.target, step.text, step.clear_first);
195 result = client.Wait(step.condition, step.timeout_ms);
198 result = client.Assert(step.condition);
201 std::cout <<
"✗ SKIPPED (unknown type)\n";
206 std::cout <<
"✗ FAILED\n";
207 std::cerr <<
" Error: " << result.status().message() <<
"\n";
208 return result.status();
211 if (!result.value().success) {
212 std::cout <<
"✗ FAILED\n";
213 std::cerr <<
" Error: " << result.value().message <<
"\n";
214 return absl::InternalError(result.value().message);
220 std::cout <<
"\n✅ Test passed!\n";
221 return absl::OkStatus();
224absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args) {
226 return absl::InvalidArgumentError(
227 "Usage: agent test replay <script.json> [--host <host>] [--port "
229 "Example: agent test replay tests/overworld_load.json");
232 std::string script_path = args[0];
233 std::string host =
"localhost";
236 for (
size_t i = 1; i < args.size(); ++i) {
237 if (args[i] ==
"--host" && i + 1 < args.size()) {
239 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
240 port = std::stoi(args[++i]);
244 std::cout <<
"\n=== Replay Test ===\n";
245 std::cout <<
"Script: " << script_path <<
"\n";
246 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
248 GuiAutomationClient client(absl::StrCat(host,
":", port));
249 auto status = client.Connect();
251 std::cerr <<
"Failed to connect: " << status.message() << std::endl;
255 auto result = client.ReplayTest(script_path,
false, {});
257 std::cerr <<
"Replay failed: " << result.status().message() << std::endl;
258 return result.status();
261 if (result.value().success) {
262 std::cout <<
"✅ Replay succeeded\n";
263 std::cout <<
"Steps executed: " << result.value().steps_executed <<
"\n";
265 std::cout <<
"❌ Replay failed: " << result.value().message <<
"\n";
266 return absl::InternalError(result.value().message);
269 return absl::OkStatus();
272absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args) {
274 std::string host =
"localhost";
277 for (
size_t i = 0; i < args.size(); ++i) {
278 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
280 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
282 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
283 port = std::stoi(args[++i]);
287 if (test_id.empty()) {
288 return absl::InvalidArgumentError(
289 "Usage: agent test status --test-id <id> [--host <host>] [--port "
293 GuiAutomationClient client(absl::StrCat(host,
":", port));
294 auto status = client.Connect();
299 auto details = client.GetTestStatus(test_id);
301 return details.status();
304 std::cout <<
"\n=== Test Status ===\n";
305 std::cout <<
"Test ID: " << test_id <<
"\n";
306 std::cout <<
"Status: " << TestRunStatusToString(details.value().status)
313 if (!details.value().error_message.empty()) {
314 std::cout <<
"Error: " << details.value().error_message <<
"\n";
317 return absl::OkStatus();
320absl::Status HandleTestListCommand(
const std::vector<std::string>& args) {
321 std::string host =
"localhost";
324 for (
size_t i = 0; i < args.size(); ++i) {
325 if (args[i] ==
"--host" && i + 1 < args.size()) {
327 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
328 port = std::stoi(args[++i]);
332 GuiAutomationClient client(absl::StrCat(host,
":", port));
333 auto status = client.Connect();
338 auto batch = client.ListTests(
"", 100,
"");
340 return batch.status();
343 std::cout <<
"\n=== Available Tests ===\n";
344 std::cout <<
"Total: " << batch.value().total_count <<
"\n\n";
346 for (
const auto& test : batch.value().tests) {
347 std::cout <<
"• " << test.name <<
"\n";
348 std::cout <<
" ID: " << test.test_id <<
"\n";
349 std::cout <<
" Category: " << test.category <<
"\n";
350 std::cout <<
" Runs: " << test.total_runs <<
" (" << test.pass_count
351 <<
" passed, " << test.fail_count <<
" failed)\n\n";
354 return absl::OkStatus();
357absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args) {
359 std::string host =
"localhost";
361 bool include_logs =
false;
363 for (
size_t i = 0; i < args.size(); ++i) {
364 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
366 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
368 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
369 port = std::stoi(args[++i]);
370 }
else if (args[i] ==
"--include-logs") {
375 if (test_id.empty()) {
376 return absl::InvalidArgumentError(
377 "Usage: agent test results --test-id <id> [--include-logs] [--host "
378 "<host>] [--port <port>]");
381 GuiAutomationClient client(absl::StrCat(host,
":", port));
382 auto status = client.Connect();
387 auto details = client.GetTestResults(test_id, include_logs);
389 return details.status();
392 std::cout <<
"\n=== Test Results ===\n";
393 std::cout <<
"Test ID: " << details.value().test_id <<
"\n";
394 std::cout <<
"Name: " << details.value().test_name <<
"\n";
395 std::cout <<
"Success: " << (details.value().success ?
"✓" :
"✗") <<
"\n";
396 std::cout <<
"Duration: " << details.value().duration_ms <<
"ms\n\n";
398 if (!details.value().assertions.empty()) {
399 std::cout <<
"Assertions:\n";
400 for (
const auto& assertion : details.value().assertions) {
401 std::cout <<
" " << (assertion.passed ?
"✓" :
"✗") <<
" "
402 << assertion.description <<
"\n";
403 if (!assertion.error_message.empty()) {
404 std::cout <<
" Error: " << assertion.error_message <<
"\n";
409 if (include_logs && !details.value().logs.empty()) {
410 std::cout <<
"\nLogs:\n";
411 for (
const auto& log : details.value().logs) {
412 std::cout <<
" " << log <<
"\n";
416 return absl::OkStatus();
419absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args) {
421 return absl::InvalidArgumentError(
422 "Usage: agent test record <start|stop> [options]\n"
423 " start [--output <file>] [--description <text>] [--session <id>]\n"
424 " [--host <host>] [--port <port>]\n"
425 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
428 std::string action = args[0];
429 if (action !=
"start" && action !=
"stop") {
430 return absl::InvalidArgumentError(
431 "Record action must be 'start' or 'stop'");
434 if (action ==
"start") {
435 std::string host =
"localhost";
438 std::string session_name;
439 std::string output_path;
441 for (
size_t i = 1; i < args.size(); ++i) {
442 const std::string& token = args[i];
443 if (token ==
"--output" && i + 1 < args.size()) {
444 output_path = args[++i];
445 }
else if (token ==
"--description" && i + 1 < args.size()) {
447 }
else if (token ==
"--session" && i + 1 < args.size()) {
448 session_name = args[++i];
449 }
else if (token ==
"--host" && i + 1 < args.size()) {
451 }
else if (token ==
"--port" && i + 1 < args.size()) {
452 std::string port_value = args[++i];
454 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
455 return absl::InvalidArgumentError(
456 absl::StrCat(
"Invalid --port value: ", port_value));
462 if (output_path.empty()) {
463 output_path = DefaultRecordingOutputPath();
466 std::filesystem::path absolute_output =
467 std::filesystem::absolute(output_path);
469 std::filesystem::create_directories(absolute_output.parent_path(), ec);
471 GuiAutomationClient client(absl::StrCat(host,
":", port));
474 if (session_name.empty()) {
475 session_name = std::filesystem::path(output_path).stem().string();
479 client.StartRecording(absolute_output.string(),
481 if (!start_result.success) {
482 return absl::InternalError(absl::StrCat(
483 "Harness rejected start-recording request: ", start_result.message));
486 RecordingState state;
487 state.recording_id = start_result.recording_id;
490 state.output_path = absolute_output.string();
493 std::cout <<
"\n=== Recording Session Started ===\n";
494 std::cout <<
"Recording ID: " << start_result.recording_id <<
"\n";
495 std::cout <<
"Server: " << host <<
":" << port <<
"\n";
496 std::cout <<
"Output: " << absolute_output <<
"\n";
498 std::cout <<
"Description: " <<
description <<
"\n";
500 if (start_result.started_at.has_value()) {
501 std::cout <<
"Started: "
502 << absl::FormatTime(
"%Y-%m-%d %H:%M:%S",
503 *start_result.started_at,
504 absl::LocalTimeZone())
507 std::cout <<
"\nPress Ctrl+C to abort the recording session.\n";
509 return absl::OkStatus();
513 bool validate =
false;
514 bool discard =
false;
515 std::optional<std::string> host_override;
516 std::optional<int> port_override;
518 for (
size_t i = 1; i < args.size(); ++i) {
519 const std::string& token = args[i];
520 if (token ==
"--validate") {
522 }
else if (token ==
"--discard") {
524 }
else if (token ==
"--host" && i + 1 < args.size()) {
525 host_override = args[++i];
526 }
else if (token ==
"--port" && i + 1 < args.size()) {
527 std::string port_value = args[++i];
529 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
530 return absl::InvalidArgumentError(
531 absl::StrCat(
"Invalid --port value: ", port_value));
533 port_override = parsed_port;
537 if (discard && validate) {
538 return absl::InvalidArgumentError(
539 "Cannot use --validate and --discard together");
543 if (host_override.has_value()) {
544 state.host = *host_override;
546 if (port_override.has_value()) {
547 state.port = *port_override;
550 GuiAutomationClient client(absl::StrCat(state.host,
":", state.port));
554 client.StopRecording(state.recording_id, discard));
555 if (!stop_result.success) {
556 return absl::InternalError(
557 absl::StrCat(
"Stop recording failed: ", stop_result.message));
561 std::cout <<
"\n=== Recording Session Completed ===\n";
562 std::cout <<
"Recording ID: " << state.recording_id <<
"\n";
563 std::cout <<
"Server: " << state.host <<
":" << state.port <<
"\n";
564 std::cout <<
"Steps captured: " << stop_result.step_count <<
"\n";
565 std::cout <<
"Duration: " << stop_result.duration.count() <<
" ms\n";
566 if (!stop_result.message.empty()) {
567 std::cout <<
"Message: " << stop_result.message <<
"\n";
569 if (!discard && !stop_result.output_path.empty()) {
570 std::cout <<
"Output saved to: " << stop_result.output_path <<
"\n";
574 std::cout <<
"Recording discarded; no script file was produced."
576 return absl::OkStatus();
579 if (!validate || stop_result.output_path.empty()) {
580 std::cout << std::endl;
581 return absl::OkStatus();
584 std::cout <<
"\nReplaying recorded script to validate...\n";
586 client.ReplayTest(stop_result.output_path,
false, {}));
587 if (!replay_result.success) {
588 return absl::InternalError(
589 absl::StrCat(
"Replay failed: ", replay_result.message));
592 std::cout <<
"Replay succeeded. Steps executed: "
593 << replay_result.steps_executed <<
"\n";
594 return absl::OkStatus();
601 return absl::InvalidArgumentError(
602 "Usage: agent test <subcommand>\n"
604 " run --prompt <text> - Generate and run a GUI automation test\n"
605 " replay <script> - Replay a recorded test script\n"
606 " status --test-id <id> - Query test execution status\n"
607 " list - List available tests\n"
608 " results --test-id <id> - Get detailed test results\n"
609 " record start/stop - Record test interactions\n"
610 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
613#ifndef YAZE_WITH_GRPC
614 return absl::UnimplementedError(
615 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
616 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
617 "Then: cmake --build build-grpc-test --target z3ed");
619 std::string subcommand = args[0];
620 std::vector<std::string> tail(args.begin() + 1, args.end());
622 if (subcommand ==
"run") {
623 return HandleTestRunCommand(tail);
624 }
else if (subcommand ==
"replay") {
625 return HandleTestReplayCommand(tail);
626 }
else if (subcommand ==
"status") {
627 return HandleTestStatusCommand(tail);
628 }
else if (subcommand ==
"list") {
629 return HandleTestListCommand(tail);
630 }
else if (subcommand ==
"results") {
631 return HandleTestResultsCommand(tail);
632 }
else if (subcommand ==
"record") {
633 return HandleTestRecordCommand(tail);
635 return absl::InvalidArgumentError(
636 absl::StrCat(
"Unknown test subcommand: ", subcommand,
637 "\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)