1#include "cli/handlers/commands.h"
10#include "absl/status/status.h"
11#include "absl/status/statusor.h"
12#include "absl/strings/numbers.h"
13#include "absl/strings/str_cat.h"
14#include "absl/strings/str_format.h"
15#include "absl/time/time.h"
17#include "nlohmann/json.hpp"
33struct RecordingState {
34 std::string recording_id;
35 std::string host =
"localhost";
37 std::string output_path;
40std::filesystem::path RecordingStateFilePath() {
42 std::filesystem::path base =
43 std::filesystem::temp_directory_path(ec);
45 base = std::filesystem::current_path();
47 return base /
"yaze" /
"agent" /
"recording_state.json";
50absl::Status SaveRecordingState(
const RecordingState& state) {
51 auto path = RecordingStateFilePath();
53 std::filesystem::create_directories(path.parent_path(), ec);
55 json[
"recording_id"] = state.recording_id;
56 json[
"host"] = state.host;
57 json[
"port"] = state.port;
58 json[
"output_path"] = state.output_path;
60 std::ofstream out(path, std::ios::out | std::ios::trunc);
62 return absl::InternalError(absl::StrCat(
"Failed to write recording state to ",
67 return absl::InternalError(
68 absl::StrCat(
"Failed to flush recording state to ", path.string()));
70 return absl::OkStatus();
73absl::StatusOr<RecordingState> LoadRecordingState() {
74 auto path = RecordingStateFilePath();
75 std::ifstream in(path);
77 return absl::NotFoundError(
"No active recording session found. Run 'z3ed agent test record start' first.");
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(
97 absl::StrCat(
"Recording state at ", path.string(),
98 " is missing a recording_id"));
104absl::Status ClearRecordingState() {
105 auto path = RecordingStateFilePath();
107 std::filesystem::remove(path, ec);
108 if (ec && ec != std::errc::no_such_file_or_directory) {
109 return absl::InternalError(absl::StrCat(
"Failed to clear recording state: ",
112 return absl::OkStatus();
115std::string DefaultRecordingOutputPath() {
116 absl::Time now = absl::Now();
117 return absl::StrCat(
"tests/gui/recording-",
118 absl::FormatTime(
"%Y%m%dT%H%M%S", now,
119 absl::LocalTimeZone()),
126absl::Status HandleTestRunCommand(
const std::vector<std::string>& args);
127absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args);
128absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args);
129absl::Status HandleTestListCommand(
const std::vector<std::string>& args);
130absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args);
131absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args);
133absl::Status HandleTestRunCommand(
const std::vector<std::string>& args) {
134 if (args.empty() || args[0] !=
"--prompt") {
135 return absl::InvalidArgumentError(
136 "Usage: agent test run --prompt <description> [--host <host>] [--port <port>]\n"
137 "Example: agent test run --prompt \"Open the overworld editor and 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: " << 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() << std::endl;
177 for (
size_t i = 0; i < workflow.steps.size(); ++i) {
178 const auto& step = workflow.steps[i];
179 std::cout <<
"Step " << (i + 1) <<
": " << step.ToString() <<
"... ";
182 absl::StatusOr<AutomationResult> result(absl::InternalError(
"Unknown step type"));
186 result = client.Click(step.target);
189 result = client.Type(step.target, step.text, step.clear_first);
192 result = client.Wait(step.condition, step.timeout_ms);
195 result = client.Assert(step.condition);
198 std::cout <<
"✗ SKIPPED (unknown type)\n";
203 std::cout <<
"✗ FAILED\n";
204 std::cerr <<
" Error: " << result.status().message() <<
"\n";
205 return result.status();
208 if (!result.value().success) {
209 std::cout <<
"✗ FAILED\n";
210 std::cerr <<
" Error: " << result.value().message <<
"\n";
211 return absl::InternalError(result.value().message);
217 std::cout <<
"\n✅ Test passed!\n";
218 return absl::OkStatus();
221absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args) {
223 return absl::InvalidArgumentError(
224 "Usage: agent test replay <script.json> [--host <host>] [--port <port>]\n"
225 "Example: agent test replay tests/overworld_load.json");
228 std::string script_path = args[0];
229 std::string host =
"localhost";
232 for (
size_t i = 1; i < args.size(); ++i) {
233 if (args[i] ==
"--host" && i + 1 < args.size()) {
235 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
236 port = std::stoi(args[++i]);
240 std::cout <<
"\n=== Replay Test ===\n";
241 std::cout <<
"Script: " << script_path <<
"\n";
242 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
244 GuiAutomationClient client(absl::StrCat(host,
":", port));
245 auto status = client.Connect();
247 std::cerr <<
"Failed to connect: " << status.message() << std::endl;
251 auto result = client.ReplayTest(script_path,
false, {});
253 std::cerr <<
"Replay failed: " << result.status().message() << std::endl;
254 return result.status();
257 if (result.value().success) {
258 std::cout <<
"✅ Replay succeeded\n";
259 std::cout <<
"Steps executed: " << result.value().steps_executed <<
"\n";
261 std::cout <<
"❌ Replay failed: " << result.value().message <<
"\n";
262 return absl::InternalError(result.value().message);
265 return absl::OkStatus();
268absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args) {
270 std::string host =
"localhost";
273 for (
size_t i = 0; i < args.size(); ++i) {
274 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
276 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
278 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
279 port = std::stoi(args[++i]);
283 if (test_id.empty()) {
284 return absl::InvalidArgumentError(
285 "Usage: agent test status --test-id <id> [--host <host>] [--port <port>]");
288 GuiAutomationClient client(absl::StrCat(host,
":", port));
289 auto status = client.Connect();
294 auto details = client.GetTestStatus(test_id);
296 return details.status();
299 std::cout <<
"\n=== Test Status ===\n";
300 std::cout <<
"Test ID: " << test_id <<
"\n";
303 std::cout <<
"Completed: " <<
FormatOptionalTime(details.value().completed_at) <<
"\n";
305 if (!details.value().error_message.empty()) {
306 std::cout <<
"Error: " << details.value().error_message <<
"\n";
309 return absl::OkStatus();
312absl::Status HandleTestListCommand(
const std::vector<std::string>& args) {
313 std::string host =
"localhost";
316 for (
size_t i = 0; i < args.size(); ++i) {
317 if (args[i] ==
"--host" && i + 1 < args.size()) {
319 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
320 port = std::stoi(args[++i]);
324 GuiAutomationClient client(absl::StrCat(host,
":", port));
325 auto status = client.Connect();
330 auto batch = client.ListTests(
"", 100,
"");
332 return batch.status();
335 std::cout <<
"\n=== Available Tests ===\n";
336 std::cout <<
"Total: " << batch.value().total_count <<
"\n\n";
338 for (
const auto& test : batch.value().tests) {
339 std::cout <<
"• " << test.name <<
"\n";
340 std::cout <<
" ID: " << test.test_id <<
"\n";
341 std::cout <<
" Category: " << test.category <<
"\n";
342 std::cout <<
" Runs: " << test.total_runs <<
" (" << test.pass_count
343 <<
" passed, " << test.fail_count <<
" failed)\n\n";
346 return absl::OkStatus();
349absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args) {
351 std::string host =
"localhost";
353 bool include_logs =
false;
355 for (
size_t i = 0; i < args.size(); ++i) {
356 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
358 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
360 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
361 port = std::stoi(args[++i]);
362 }
else if (args[i] ==
"--include-logs") {
367 if (test_id.empty()) {
368 return absl::InvalidArgumentError(
369 "Usage: agent test results --test-id <id> [--include-logs] [--host <host>] [--port <port>]");
372 GuiAutomationClient client(absl::StrCat(host,
":", port));
373 auto status = client.Connect();
378 auto details = client.GetTestResults(test_id, include_logs);
380 return details.status();
383 std::cout <<
"\n=== Test Results ===\n";
384 std::cout <<
"Test ID: " << details.value().test_id <<
"\n";
385 std::cout <<
"Name: " << details.value().test_name <<
"\n";
386 std::cout <<
"Success: " << (details.value().success ?
"✓" :
"✗") <<
"\n";
387 std::cout <<
"Duration: " << details.value().duration_ms <<
"ms\n\n";
389 if (!details.value().assertions.empty()) {
390 std::cout <<
"Assertions:\n";
391 for (
const auto& assertion : details.value().assertions) {
392 std::cout <<
" " << (assertion.passed ?
"✓" :
"✗") <<
" "
393 << assertion.description <<
"\n";
394 if (!assertion.error_message.empty()) {
395 std::cout <<
" Error: " << assertion.error_message <<
"\n";
400 if (include_logs && !details.value().logs.empty()) {
401 std::cout <<
"\nLogs:\n";
402 for (
const auto& log : details.value().logs) {
403 std::cout <<
" " << log <<
"\n";
407 return absl::OkStatus();
410absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args) {
412 return absl::InvalidArgumentError(
413 "Usage: agent test record <start|stop> [options]\n"
414 " start [--output <file>] [--description <text>] [--session <id>]\n"
415 " [--host <host>] [--port <port>]\n"
416 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
419 std::string action = args[0];
420 if (action !=
"start" && action !=
"stop") {
421 return absl::InvalidArgumentError(
"Record action must be 'start' or 'stop'");
424 if (action ==
"start") {
425 std::string host =
"localhost";
427 std::string description;
428 std::string session_name;
429 std::string output_path;
431 for (
size_t i = 1; i < args.size(); ++i) {
432 const std::string& token = args[i];
433 if (token ==
"--output" && i + 1 < args.size()) {
434 output_path = args[++i];
435 }
else if (token ==
"--description" && i + 1 < args.size()) {
436 description = args[++i];
437 }
else if (token ==
"--session" && i + 1 < args.size()) {
438 session_name = args[++i];
439 }
else if (token ==
"--host" && i + 1 < args.size()) {
441 }
else if (token ==
"--port" && i + 1 < args.size()) {
442 std::string port_value = args[++i];
444 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
445 return absl::InvalidArgumentError(
446 absl::StrCat(
"Invalid --port value: ", port_value));
452 if (output_path.empty()) {
453 output_path = DefaultRecordingOutputPath();
456 std::filesystem::path absolute_output =
457 std::filesystem::absolute(output_path);
459 std::filesystem::create_directories(absolute_output.parent_path(), ec);
461 GuiAutomationClient client(absl::StrCat(host,
":", port));
464 if (session_name.empty()) {
465 session_name = std::filesystem::path(output_path).stem().string();
469 client.StartRecording(absolute_output.string(),
470 session_name, description));
471 if (!start_result.success) {
472 return absl::InternalError(
473 absl::StrCat(
"Harness rejected start-recording request: ",
474 start_result.message));
477 RecordingState state;
478 state.recording_id = start_result.recording_id;
481 state.output_path = absolute_output.string();
484 std::cout <<
"\n=== Recording Session Started ===\n";
485 std::cout <<
"Recording ID: " << start_result.recording_id <<
"\n";
486 std::cout <<
"Server: " << host <<
":" << port <<
"\n";
487 std::cout <<
"Output: " << absolute_output <<
"\n";
488 if (!description.empty()) {
489 std::cout <<
"Description: " << description <<
"\n";
491 if (start_result.started_at.has_value()) {
492 std::cout <<
"Started: "
493 << absl::FormatTime(
"%Y-%m-%d %H:%M:%S",
494 *start_result.started_at,
495 absl::LocalTimeZone())
498 std::cout <<
"\nPress Ctrl+C to abort the recording session.\n";
500 return absl::OkStatus();
504 bool validate =
false;
505 bool discard =
false;
506 std::optional<std::string> host_override;
507 std::optional<int> port_override;
509 for (
size_t i = 1; i < args.size(); ++i) {
510 const std::string& token = args[i];
511 if (token ==
"--validate") {
513 }
else if (token ==
"--discard") {
515 }
else if (token ==
"--host" && i + 1 < args.size()) {
516 host_override = args[++i];
517 }
else if (token ==
"--port" && i + 1 < args.size()) {
518 std::string port_value = args[++i];
520 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
521 return absl::InvalidArgumentError(
522 absl::StrCat(
"Invalid --port value: ", port_value));
524 port_override = parsed_port;
528 if (discard && validate) {
529 return absl::InvalidArgumentError(
530 "Cannot use --validate and --discard together");
534 if (host_override.has_value()) {
535 state.host = *host_override;
537 if (port_override.has_value()) {
538 state.port = *port_override;
541 GuiAutomationClient client(absl::StrCat(state.host,
":", state.port));
545 client.StopRecording(state.recording_id, discard));
546 if (!stop_result.success) {
547 return absl::InternalError(
548 absl::StrCat(
"Stop recording failed: ", stop_result.message));
552 std::cout <<
"\n=== Recording Session Completed ===\n";
553 std::cout <<
"Recording ID: " << state.recording_id <<
"\n";
554 std::cout <<
"Server: " << state.host <<
":" << state.port <<
"\n";
555 std::cout <<
"Steps captured: " << stop_result.step_count <<
"\n";
556 std::cout <<
"Duration: " << stop_result.duration.count() <<
" ms\n";
557 if (!stop_result.message.empty()) {
558 std::cout <<
"Message: " << stop_result.message <<
"\n";
560 if (!discard && !stop_result.output_path.empty()) {
561 std::cout <<
"Output saved to: " << stop_result.output_path <<
"\n";
565 std::cout <<
"Recording discarded; no script file was produced." << std::endl;
566 return absl::OkStatus();
569 if (!validate || stop_result.output_path.empty()) {
570 std::cout << std::endl;
571 return absl::OkStatus();
574 std::cout <<
"\nReplaying recorded script to validate...\n";
576 client.ReplayTest(stop_result.output_path,
false, {}));
577 if (!replay_result.success) {
578 return absl::InternalError(
579 absl::StrCat(
"Replay failed: ", replay_result.message));
582 std::cout <<
"Replay succeeded. Steps executed: "
583 << replay_result.steps_executed <<
"\n";
584 return absl::OkStatus();
591 return absl::InvalidArgumentError(
592 "Usage: agent test <subcommand>\n"
594 " run --prompt <text> - Generate and run a GUI automation test\n"
595 " replay <script> - Replay a recorded test script\n"
596 " status --test-id <id> - Query test execution status\n"
597 " list - List available tests\n"
598 " results --test-id <id> - Get detailed test results\n"
599 " record start/stop - Record test interactions\n"
600 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
603#ifndef YAZE_WITH_GRPC
604 return absl::UnimplementedError(
605 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
606 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
607 "Then: cmake --build build-grpc-test --target z3ed");
609 std::string subcommand = args[0];
610 std::vector<std::string> tail(args.begin() + 1, args.end());
612 if (subcommand ==
"run") {
613 return HandleTestRunCommand(tail);
614 }
else if (subcommand ==
"replay") {
615 return HandleTestReplayCommand(tail);
616 }
else if (subcommand ==
"status") {
617 return HandleTestStatusCommand(tail);
618 }
else if (subcommand ==
"list") {
619 return HandleTestListCommand(tail);
620 }
else if (subcommand ==
"results") {
621 return HandleTestResultsCommand(tail);
622 }
else if (subcommand ==
"record") {
623 return HandleTestRecordCommand(tail);
625 return absl::InvalidArgumentError(
626 absl::StrCat(
"Unknown test subcommand: ", subcommand,
627 "\nRun 'z3ed agent test' for usage."));
#define RETURN_IF_ERROR(expression)
#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)
const char * TestRunStatusToString(TestRunStatus status)
Main namespace for the application.