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 =
41 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(absl::StrCat(
"Failed to write recording state to ",
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(
"No active recording session found. Run 'z3ed agent test record start' first.");
81 }
catch (
const nlohmann::json::parse_error& error) {
82 return absl::InternalError(
83 absl::StrCat(
"Failed to parse recording state at ", path.string(),
88 state.recording_id = json.value(
"recording_id",
"");
89 state.host = json.value(
"host",
"localhost");
90 state.port = json.value(
"port", 50052);
91 state.output_path = json.value(
"output_path",
"");
93 if (state.recording_id.empty()) {
94 return absl::InvalidArgumentError(
95 absl::StrCat(
"Recording state at ", path.string(),
96 " 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(absl::StrCat(
"Failed to clear recording state: ",
110 return absl::OkStatus();
113std::string DefaultRecordingOutputPath() {
114 absl::Time now = absl::Now();
115 return absl::StrCat(
"tests/gui/recording-",
116 absl::FormatTime(
"%Y%m%dT%H%M%S", now,
117 absl::LocalTimeZone()),
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 <port>]\n"
135 "Example: agent test run --prompt \"Open the overworld editor and verify it loads\"");
138 std::string prompt = args.size() > 1 ? args[1] :
"";
139 std::string host =
"localhost";
143 for (
size_t i = 2; i < args.size(); ++i) {
144 if (args[i] ==
"--host" && i + 1 < args.size()) {
146 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
147 port = std::stoi(args[++i]);
151 std::cout <<
"\n=== GUI Automation Test ===\n";
152 std::cout <<
"Prompt: " << prompt <<
"\n";
153 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
156 TestWorkflowGenerator generator;
157 auto workflow_or = generator.GenerateWorkflow(prompt);
158 if (!workflow_or.ok()) {
159 std::cerr <<
"Failed to generate workflow: " << workflow_or.status().message() << std::endl;
160 return workflow_or.status();
163 auto workflow = workflow_or.value();
164 std::cout <<
"Generated " << workflow.steps.size() <<
" test steps\n\n";
167 GuiAutomationClient client(absl::StrCat(host,
":", port));
168 auto status = client.Connect();
170 std::cerr <<
"Failed to connect to test harness: " << status.message() << std::endl;
175 for (
size_t i = 0; i < workflow.steps.size(); ++i) {
176 const auto& step = workflow.steps[i];
177 std::cout <<
"Step " << (i + 1) <<
": " << step.ToString() <<
"... ";
180 absl::StatusOr<AutomationResult> result(absl::InternalError(
"Unknown step type"));
184 result = client.Click(step.target);
187 result = client.Type(step.target, step.text, step.clear_first);
190 result = client.Wait(step.condition, step.timeout_ms);
193 result = client.Assert(step.condition);
196 std::cout <<
"✗ SKIPPED (unknown type)\n";
201 std::cout <<
"✗ FAILED\n";
202 std::cerr <<
" Error: " << result.status().message() <<
"\n";
203 return result.status();
206 if (!result.value().success) {
207 std::cout <<
"✗ FAILED\n";
208 std::cerr <<
" Error: " << result.value().message <<
"\n";
209 return absl::InternalError(result.value().message);
215 std::cout <<
"\n✅ Test passed!\n";
216 return absl::OkStatus();
219absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args) {
221 return absl::InvalidArgumentError(
222 "Usage: agent test replay <script.json> [--host <host>] [--port <port>]\n"
223 "Example: agent test replay tests/overworld_load.json");
226 std::string script_path = args[0];
227 std::string host =
"localhost";
230 for (
size_t i = 1; i < args.size(); ++i) {
231 if (args[i] ==
"--host" && i + 1 < args.size()) {
233 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
234 port = std::stoi(args[++i]);
238 std::cout <<
"\n=== Replay Test ===\n";
239 std::cout <<
"Script: " << script_path <<
"\n";
240 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
242 GuiAutomationClient client(absl::StrCat(host,
":", port));
243 auto status = client.Connect();
245 std::cerr <<
"Failed to connect: " << status.message() << std::endl;
249 auto result = client.ReplayTest(script_path,
false, {});
251 std::cerr <<
"Replay failed: " << result.status().message() << std::endl;
252 return result.status();
255 if (result.value().success) {
256 std::cout <<
"✅ Replay succeeded\n";
257 std::cout <<
"Steps executed: " << result.value().steps_executed <<
"\n";
259 std::cout <<
"❌ Replay failed: " << result.value().message <<
"\n";
260 return absl::InternalError(result.value().message);
263 return absl::OkStatus();
266absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args) {
268 std::string host =
"localhost";
271 for (
size_t i = 0; i < args.size(); ++i) {
272 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
274 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
276 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
277 port = std::stoi(args[++i]);
281 if (test_id.empty()) {
282 return absl::InvalidArgumentError(
283 "Usage: agent test status --test-id <id> [--host <host>] [--port <port>]");
286 GuiAutomationClient client(absl::StrCat(host,
":", port));
287 auto status = client.Connect();
292 auto details = client.GetTestStatus(test_id);
294 return details.status();
297 std::cout <<
"\n=== Test Status ===\n";
298 std::cout <<
"Test ID: " << test_id <<
"\n";
301 std::cout <<
"Completed: " <<
FormatOptionalTime(details.value().completed_at) <<
"\n";
303 if (!details.value().error_message.empty()) {
304 std::cout <<
"Error: " << details.value().error_message <<
"\n";
307 return absl::OkStatus();
310absl::Status HandleTestListCommand(
const std::vector<std::string>& args) {
311 std::string host =
"localhost";
314 for (
size_t i = 0; i < args.size(); ++i) {
315 if (args[i] ==
"--host" && i + 1 < args.size()) {
317 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
318 port = std::stoi(args[++i]);
322 GuiAutomationClient client(absl::StrCat(host,
":", port));
323 auto status = client.Connect();
328 auto batch = client.ListTests(
"", 100,
"");
330 return batch.status();
333 std::cout <<
"\n=== Available Tests ===\n";
334 std::cout <<
"Total: " << batch.value().total_count <<
"\n\n";
336 for (
const auto& test : batch.value().tests) {
337 std::cout <<
"• " << test.name <<
"\n";
338 std::cout <<
" ID: " << test.test_id <<
"\n";
339 std::cout <<
" Category: " << test.category <<
"\n";
340 std::cout <<
" Runs: " << test.total_runs <<
" (" << test.pass_count
341 <<
" passed, " << test.fail_count <<
" failed)\n\n";
344 return absl::OkStatus();
347absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args) {
349 std::string host =
"localhost";
351 bool include_logs =
false;
353 for (
size_t i = 0; i < args.size(); ++i) {
354 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
356 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
358 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
359 port = std::stoi(args[++i]);
360 }
else if (args[i] ==
"--include-logs") {
365 if (test_id.empty()) {
366 return absl::InvalidArgumentError(
367 "Usage: agent test results --test-id <id> [--include-logs] [--host <host>] [--port <port>]");
370 GuiAutomationClient client(absl::StrCat(host,
":", port));
371 auto status = client.Connect();
376 auto details = client.GetTestResults(test_id, include_logs);
378 return details.status();
381 std::cout <<
"\n=== Test Results ===\n";
382 std::cout <<
"Test ID: " << details.value().test_id <<
"\n";
383 std::cout <<
"Name: " << details.value().test_name <<
"\n";
384 std::cout <<
"Success: " << (details.value().success ?
"✓" :
"✗") <<
"\n";
385 std::cout <<
"Duration: " << details.value().duration_ms <<
"ms\n\n";
387 if (!details.value().assertions.empty()) {
388 std::cout <<
"Assertions:\n";
389 for (
const auto& assertion : details.value().assertions) {
390 std::cout <<
" " << (assertion.passed ?
"✓" :
"✗") <<
" "
391 << assertion.description <<
"\n";
392 if (!assertion.error_message.empty()) {
393 std::cout <<
" Error: " << assertion.error_message <<
"\n";
398 if (include_logs && !details.value().logs.empty()) {
399 std::cout <<
"\nLogs:\n";
400 for (
const auto& log : details.value().logs) {
401 std::cout <<
" " << log <<
"\n";
405 return absl::OkStatus();
408absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args) {
410 return absl::InvalidArgumentError(
411 "Usage: agent test record <start|stop> [options]\n"
412 " start [--output <file>] [--description <text>] [--session <id>]\n"
413 " [--host <host>] [--port <port>]\n"
414 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
417 std::string action = args[0];
418 if (action !=
"start" && action !=
"stop") {
419 return absl::InvalidArgumentError(
"Record action must be 'start' or 'stop'");
422 if (action ==
"start") {
423 std::string host =
"localhost";
425 std::string description;
426 std::string session_name;
427 std::string output_path;
429 for (
size_t i = 1; i < args.size(); ++i) {
430 const std::string& token = args[i];
431 if (token ==
"--output" && i + 1 < args.size()) {
432 output_path = args[++i];
433 }
else if (token ==
"--description" && i + 1 < args.size()) {
434 description = args[++i];
435 }
else if (token ==
"--session" && i + 1 < args.size()) {
436 session_name = args[++i];
437 }
else if (token ==
"--host" && i + 1 < args.size()) {
439 }
else if (token ==
"--port" && i + 1 < args.size()) {
440 std::string port_value = args[++i];
442 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
443 return absl::InvalidArgumentError(
444 absl::StrCat(
"Invalid --port value: ", port_value));
450 if (output_path.empty()) {
451 output_path = DefaultRecordingOutputPath();
454 std::filesystem::path absolute_output =
455 std::filesystem::absolute(output_path);
457 std::filesystem::create_directories(absolute_output.parent_path(), ec);
459 GuiAutomationClient client(absl::StrCat(host,
":", port));
462 if (session_name.empty()) {
463 session_name = std::filesystem::path(output_path).stem().string();
467 client.StartRecording(absolute_output.string(),
468 session_name, description));
469 if (!start_result.success) {
470 return absl::InternalError(
471 absl::StrCat(
"Harness rejected start-recording request: ",
472 start_result.message));
475 RecordingState state;
476 state.recording_id = start_result.recording_id;
479 state.output_path = absolute_output.string();
482 std::cout <<
"\n=== Recording Session Started ===\n";
483 std::cout <<
"Recording ID: " << start_result.recording_id <<
"\n";
484 std::cout <<
"Server: " << host <<
":" << port <<
"\n";
485 std::cout <<
"Output: " << absolute_output <<
"\n";
486 if (!description.empty()) {
487 std::cout <<
"Description: " << description <<
"\n";
489 if (start_result.started_at.has_value()) {
490 std::cout <<
"Started: "
491 << absl::FormatTime(
"%Y-%m-%d %H:%M:%S",
492 *start_result.started_at,
493 absl::LocalTimeZone())
496 std::cout <<
"\nPress Ctrl+C to abort the recording session.\n";
498 return absl::OkStatus();
502 bool validate =
false;
503 bool discard =
false;
504 std::optional<std::string> host_override;
505 std::optional<int> port_override;
507 for (
size_t i = 1; i < args.size(); ++i) {
508 const std::string& token = args[i];
509 if (token ==
"--validate") {
511 }
else if (token ==
"--discard") {
513 }
else if (token ==
"--host" && i + 1 < args.size()) {
514 host_override = args[++i];
515 }
else if (token ==
"--port" && i + 1 < args.size()) {
516 std::string port_value = args[++i];
518 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
519 return absl::InvalidArgumentError(
520 absl::StrCat(
"Invalid --port value: ", port_value));
522 port_override = parsed_port;
526 if (discard && validate) {
527 return absl::InvalidArgumentError(
528 "Cannot use --validate and --discard together");
532 if (host_override.has_value()) {
533 state.host = *host_override;
535 if (port_override.has_value()) {
536 state.port = *port_override;
539 GuiAutomationClient client(absl::StrCat(state.host,
":", state.port));
543 client.StopRecording(state.recording_id, discard));
544 if (!stop_result.success) {
545 return absl::InternalError(
546 absl::StrCat(
"Stop recording failed: ", stop_result.message));
550 std::cout <<
"\n=== Recording Session Completed ===\n";
551 std::cout <<
"Recording ID: " << state.recording_id <<
"\n";
552 std::cout <<
"Server: " << state.host <<
":" << state.port <<
"\n";
553 std::cout <<
"Steps captured: " << stop_result.step_count <<
"\n";
554 std::cout <<
"Duration: " << stop_result.duration.count() <<
" ms\n";
555 if (!stop_result.message.empty()) {
556 std::cout <<
"Message: " << stop_result.message <<
"\n";
558 if (!discard && !stop_result.output_path.empty()) {
559 std::cout <<
"Output saved to: " << stop_result.output_path <<
"\n";
563 std::cout <<
"Recording discarded; no script file was produced." << std::endl;
564 return absl::OkStatus();
567 if (!validate || stop_result.output_path.empty()) {
568 std::cout << std::endl;
569 return absl::OkStatus();
572 std::cout <<
"\nReplaying recorded script to validate...\n";
574 client.ReplayTest(stop_result.output_path,
false, {}));
575 if (!replay_result.success) {
576 return absl::InternalError(
577 absl::StrCat(
"Replay failed: ", replay_result.message));
580 std::cout <<
"Replay succeeded. Steps executed: "
581 << replay_result.steps_executed <<
"\n";
582 return absl::OkStatus();
589 return absl::InvalidArgumentError(
590 "Usage: agent test <subcommand>\n"
592 " run --prompt <text> - Generate and run a GUI automation test\n"
593 " replay <script> - Replay a recorded test script\n"
594 " status --test-id <id> - Query test execution status\n"
595 " list - List available tests\n"
596 " results --test-id <id> - Get detailed test results\n"
597 " record start/stop - Record test interactions\n"
598 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
601#ifndef YAZE_WITH_GRPC
602 return absl::UnimplementedError(
603 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
604 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
605 "Then: cmake --build build-grpc-test --target z3ed");
607 std::string subcommand = args[0];
608 std::vector<std::string> tail(args.begin() + 1, args.end());
610 if (subcommand ==
"run") {
611 return HandleTestRunCommand(tail);
612 }
else if (subcommand ==
"replay") {
613 return HandleTestReplayCommand(tail);
614 }
else if (subcommand ==
"status") {
615 return HandleTestStatusCommand(tail);
616 }
else if (subcommand ==
"list") {
617 return HandleTestListCommand(tail);
618 }
else if (subcommand ==
"results") {
619 return HandleTestResultsCommand(tail);
620 }
else if (subcommand ==
"record") {
621 return HandleTestRecordCommand(tail);
623 return absl::InvalidArgumentError(
624 absl::StrCat(
"Unknown test subcommand: ", subcommand,
625 "\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.