9#include "absl/status/status.h"
10#include "absl/status/statusor.h"
11#include "absl/strings/numbers.h"
12#include "absl/strings/str_cat.h"
13#include "absl/strings/str_format.h"
14#include "absl/time/clock.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 = std::filesystem::temp_directory_path(ec);
44 base = std::filesystem::current_path();
46 return base /
"yaze" /
"agent" /
"recording_state.json";
49absl::Status SaveRecordingState(
const RecordingState& state) {
50 auto path = RecordingStateFilePath();
52 std::filesystem::create_directories(path.parent_path(), ec);
54 json[
"recording_id"] = state.recording_id;
55 json[
"host"] = state.host;
56 json[
"port"] = state.port;
57 json[
"output_path"] = state.output_path;
59 std::ofstream out(path, std::ios::out | std::ios::trunc);
61 return absl::InternalError(
62 absl::StrCat(
"Failed to write recording state to ", path.string()));
66 return absl::InternalError(
67 absl::StrCat(
"Failed to flush recording state to ", path.string()));
69 return absl::OkStatus();
72absl::StatusOr<RecordingState> LoadRecordingState() {
73 auto path = RecordingStateFilePath();
74 std::ifstream in(path);
76 return absl::NotFoundError(
77 "No active recording session found. Run 'z3ed agent test record start' "
84 }
catch (
const nlohmann::json::parse_error& error) {
85 return absl::InternalError(
86 absl::StrCat(
"Failed to parse recording state at ", path.string(),
": ",
91 state.recording_id =
json.value(
"recording_id",
"");
92 state.host =
json.value(
"host",
"localhost");
93 state.port =
json.value(
"port", 50052);
94 state.output_path =
json.value(
"output_path",
"");
96 if (state.recording_id.empty()) {
97 return absl::InvalidArgumentError(absl::StrCat(
98 "Recording state at ", path.string(),
" 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(
110 absl::StrCat(
"Failed to clear recording state: ", ec.message()));
112 return absl::OkStatus();
115std::string DefaultRecordingOutputPath() {
116 absl::Time now = absl::Now();
118 "tests/gui/recording-",
119 absl::FormatTime(
"%Y%m%dT%H%M%S", now, absl::LocalTimeZone()),
".json");
125absl::Status HandleTestRunCommand(
const std::vector<std::string>& args);
126absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args);
127absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args);
128absl::Status HandleTestListCommand(
const std::vector<std::string>& args);
129absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args);
130absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args);
132absl::Status HandleTestRunCommand(
const std::vector<std::string>& args) {
133 if (args.empty() || args[0] !=
"--prompt") {
134 return absl::InvalidArgumentError(
135 "Usage: agent test run --prompt <description> [--host <host>] [--port "
137 "Example: agent test run --prompt \"Open the overworld editor and "
138 "verify it loads\"");
141 std::string prompt = args.size() > 1 ? args[1] :
"";
142 std::string host =
"localhost";
146 for (
size_t i = 2; i < args.size(); ++i) {
147 if (args[i] ==
"--host" && i + 1 < args.size()) {
149 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
150 port = std::stoi(args[++i]);
154 std::cout <<
"\n=== GUI Automation Test ===\n";
155 std::cout <<
"Prompt: " << prompt <<
"\n";
156 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
159 TestWorkflowGenerator generator;
160 auto workflow_or = generator.GenerateWorkflow(prompt);
161 if (!workflow_or.ok()) {
162 std::cerr <<
"Failed to generate workflow: "
163 << workflow_or.status().message() << std::endl;
164 return workflow_or.status();
167 auto workflow = workflow_or.value();
168 std::cout <<
"Generated " << workflow.steps.size() <<
" test steps\n\n";
171 GuiAutomationClient client(absl::StrCat(host,
":", port));
172 auto status = client.Connect();
174 std::cerr <<
"Failed to connect to test harness: " << status.message()
180 for (
size_t i = 0; i < workflow.steps.size(); ++i) {
181 const auto& step = workflow.steps[i];
182 std::cout <<
"Step " << (i + 1) <<
": " << step.ToString() <<
"... ";
185 absl::StatusOr<AutomationResult> result(
186 absl::InternalError(
"Unknown step type"));
190 result = client.Click(step.target);
193 result = client.Type(step.target, step.text, step.clear_first);
196 result = client.Wait(step.condition, step.timeout_ms);
199 result = client.Assert(step.condition);
202 std::cout <<
"✗ SKIPPED (unknown type)\n";
207 std::cout <<
"✗ FAILED\n";
208 std::cerr <<
" Error: " << result.status().message() <<
"\n";
209 return result.status();
212 if (!result.value().success) {
213 std::cout <<
"✗ FAILED\n";
214 std::cerr <<
" Error: " << result.value().message <<
"\n";
215 return absl::InternalError(result.value().message);
221 std::cout <<
"\n✅ Test passed!\n";
222 return absl::OkStatus();
225absl::Status HandleTestReplayCommand(
const std::vector<std::string>& args) {
227 return absl::InvalidArgumentError(
228 "Usage: agent test replay <script.json> [--host <host>] [--port "
229 "<port>] [--ci] [--set key=value ...]\n"
230 "Example: agent test replay tests/overworld_load.json --set "
234 std::string script_path = args[0];
235 std::string host =
"localhost";
237 bool ci_mode =
false;
238 std::map<std::string, std::string> parameter_overrides;
240 for (
size_t i = 1; i < args.size(); ++i) {
241 const std::string& token = args[i];
242 if (token ==
"--host" && i + 1 < args.size()) {
244 }
else if (token ==
"--port" && i + 1 < args.size()) {
245 const std::string& port_value = args[++i];
247 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
248 return absl::InvalidArgumentError(
249 absl::StrCat(
"Invalid --port value: ", port_value));
252 }
else if (token ==
"--set" && i + 1 < args.size()) {
253 const std::string& assignment = args[++i];
254 size_t separator = assignment.find(
'=');
255 if (separator == std::string::npos || separator == 0 ||
256 separator == assignment.size() - 1) {
257 return absl::InvalidArgumentError(absl::StrCat(
258 "Invalid --set value (expected key=value): ", assignment));
260 parameter_overrides[assignment.substr(0, separator)] =
261 assignment.substr(separator + 1);
262 }
else if (token ==
"--ci") {
265 return absl::InvalidArgumentError(
266 absl::StrCat(
"Unknown replay option: ", token));
270 std::cout <<
"\n=== Replay Test ===\n";
271 std::cout <<
"Script: " << script_path <<
"\n";
272 std::cout <<
"Server: " << host <<
":" << port <<
"\n\n";
274 std::cout <<
"CI mode: enabled\n";
276 if (!parameter_overrides.empty()) {
277 std::cout <<
"Overrides:\n";
278 for (
const auto& [key, value] : parameter_overrides) {
279 std::cout <<
" - " <<
key <<
"=" << value <<
"\n";
284 GuiAutomationClient client(absl::StrCat(host,
":", port));
285 auto status = client.Connect();
287 std::cerr <<
"Failed to connect: " << status.message() << std::endl;
291 auto result = client.ReplayTest(script_path, ci_mode, parameter_overrides);
293 std::cerr <<
"Replay failed: " << result.status().message() << std::endl;
294 return result.status();
297 if (result.value().success) {
298 std::cout <<
"✅ Replay succeeded\n";
299 std::cout <<
"Steps executed: " << result.value().steps_executed <<
"\n";
301 std::cout <<
"❌ Replay failed: " << result.value().message <<
"\n";
302 return absl::InternalError(result.value().message);
305 return absl::OkStatus();
308absl::Status HandleTestStatusCommand(
const std::vector<std::string>& args) {
310 std::string host =
"localhost";
313 for (
size_t i = 0; i < args.size(); ++i) {
314 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
316 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
318 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
319 port = std::stoi(args[++i]);
323 if (test_id.empty()) {
324 return absl::InvalidArgumentError(
325 "Usage: agent test status --test-id <id> [--host <host>] [--port "
329 GuiAutomationClient client(absl::StrCat(host,
":", port));
330 auto status = client.Connect();
335 auto details = client.GetTestStatus(test_id);
337 return details.status();
340 std::cout <<
"\n=== Test Status ===\n";
341 std::cout <<
"Test ID: " << test_id <<
"\n";
342 std::cout <<
"Status: " << TestRunStatusToString(details.value().status)
349 if (!details.value().error_message.empty()) {
350 std::cout <<
"Error: " << details.value().error_message <<
"\n";
353 return absl::OkStatus();
356absl::Status HandleTestListCommand(
const std::vector<std::string>& args) {
357 std::string host =
"localhost";
360 for (
size_t i = 0; i < args.size(); ++i) {
361 if (args[i] ==
"--host" && i + 1 < args.size()) {
363 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
364 port = std::stoi(args[++i]);
368 GuiAutomationClient client(absl::StrCat(host,
":", port));
369 auto status = client.Connect();
374 auto batch = client.ListTests(
"", 100,
"");
376 return batch.status();
379 std::cout <<
"\n=== Available Tests ===\n";
380 std::cout <<
"Total: " << batch.value().total_count <<
"\n\n";
382 for (
const auto& test : batch.value().tests) {
383 std::cout <<
"• " << test.name <<
"\n";
384 std::cout <<
" ID: " << test.test_id <<
"\n";
385 std::cout <<
" Category: " << test.category <<
"\n";
386 std::cout <<
" Runs: " << test.total_runs <<
" (" << test.pass_count
387 <<
" passed, " << test.fail_count <<
" failed)\n\n";
390 return absl::OkStatus();
393absl::Status HandleTestResultsCommand(
const std::vector<std::string>& args) {
395 std::string host =
"localhost";
397 bool include_logs =
false;
399 for (
size_t i = 0; i < args.size(); ++i) {
400 if (args[i] ==
"--test-id" && i + 1 < args.size()) {
402 }
else if (args[i] ==
"--host" && i + 1 < args.size()) {
404 }
else if (args[i] ==
"--port" && i + 1 < args.size()) {
405 port = std::stoi(args[++i]);
406 }
else if (args[i] ==
"--include-logs") {
411 if (test_id.empty()) {
412 return absl::InvalidArgumentError(
413 "Usage: agent test results --test-id <id> [--include-logs] [--host "
414 "<host>] [--port <port>]");
417 GuiAutomationClient client(absl::StrCat(host,
":", port));
418 auto status = client.Connect();
423 auto details = client.GetTestResults(test_id, include_logs);
425 return details.status();
428 std::cout <<
"\n=== Test Results ===\n";
429 std::cout <<
"Test ID: " << details.value().test_id <<
"\n";
430 std::cout <<
"Name: " << details.value().test_name <<
"\n";
431 std::cout <<
"Success: " << (details.value().success ?
"✓" :
"✗") <<
"\n";
432 std::cout <<
"Duration: " << details.value().duration_ms <<
"ms\n\n";
434 if (!details.value().assertions.empty()) {
435 std::cout <<
"Assertions:\n";
436 for (
const auto& assertion : details.value().assertions) {
437 std::cout <<
" " << (assertion.passed ?
"✓" :
"✗") <<
" "
438 << assertion.description <<
"\n";
439 if (!assertion.error_message.empty()) {
440 std::cout <<
" Error: " << assertion.error_message <<
"\n";
445 if (include_logs && !details.value().logs.empty()) {
446 std::cout <<
"\nLogs:\n";
447 for (
const auto& log : details.value().logs) {
448 std::cout <<
" " << log <<
"\n";
452 return absl::OkStatus();
455absl::Status HandleTestRecordCommand(
const std::vector<std::string>& args) {
457 return absl::InvalidArgumentError(
458 "Usage: agent test record <start|stop> [options]\n"
459 " start [--output <file>] [--description <text>] [--session <id>]\n"
460 " [--host <host>] [--port <port>]\n"
461 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
464 std::string action = args[0];
465 if (action !=
"start" && action !=
"stop") {
466 return absl::InvalidArgumentError(
467 "Record action must be 'start' or 'stop'");
470 if (action ==
"start") {
471 std::string host =
"localhost";
474 std::string session_name;
475 std::string output_path;
477 for (
size_t i = 1; i < args.size(); ++i) {
478 const std::string& token = args[i];
479 if (token ==
"--output" && i + 1 < args.size()) {
480 output_path = args[++i];
481 }
else if (token ==
"--description" && i + 1 < args.size()) {
483 }
else if (token ==
"--session" && i + 1 < args.size()) {
484 session_name = args[++i];
485 }
else if (token ==
"--host" && i + 1 < args.size()) {
487 }
else if (token ==
"--port" && i + 1 < args.size()) {
488 std::string port_value = args[++i];
490 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
491 return absl::InvalidArgumentError(
492 absl::StrCat(
"Invalid --port value: ", port_value));
498 if (output_path.empty()) {
499 output_path = DefaultRecordingOutputPath();
502 std::filesystem::path absolute_output =
503 std::filesystem::absolute(output_path);
505 std::filesystem::create_directories(absolute_output.parent_path(), ec);
507 GuiAutomationClient client(absl::StrCat(host,
":", port));
510 if (session_name.empty()) {
511 session_name = std::filesystem::path(output_path).stem().string();
515 client.StartRecording(absolute_output.string(),
517 if (!start_result.success) {
518 return absl::InternalError(absl::StrCat(
519 "Harness rejected start-recording request: ", start_result.message));
522 RecordingState state;
523 state.recording_id = start_result.recording_id;
526 state.output_path = absolute_output.string();
529 std::cout <<
"\n=== Recording Session Started ===\n";
530 std::cout <<
"Recording ID: " << start_result.recording_id <<
"\n";
531 std::cout <<
"Server: " << host <<
":" << port <<
"\n";
532 std::cout <<
"Output: " << absolute_output <<
"\n";
534 std::cout <<
"Description: " <<
description <<
"\n";
536 if (start_result.started_at.has_value()) {
537 std::cout <<
"Started: "
538 << absl::FormatTime(
"%Y-%m-%d %H:%M:%S",
539 *start_result.started_at,
540 absl::LocalTimeZone())
543 std::cout <<
"\nPress Ctrl+C to abort the recording session.\n";
545 return absl::OkStatus();
549 bool validate =
false;
550 bool discard =
false;
551 std::optional<std::string> host_override;
552 std::optional<int> port_override;
554 for (
size_t i = 1; i < args.size(); ++i) {
555 const std::string& token = args[i];
556 if (token ==
"--validate") {
558 }
else if (token ==
"--discard") {
560 }
else if (token ==
"--host" && i + 1 < args.size()) {
561 host_override = args[++i];
562 }
else if (token ==
"--port" && i + 1 < args.size()) {
563 std::string port_value = args[++i];
565 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
566 return absl::InvalidArgumentError(
567 absl::StrCat(
"Invalid --port value: ", port_value));
569 port_override = parsed_port;
573 if (discard && validate) {
574 return absl::InvalidArgumentError(
575 "Cannot use --validate and --discard together");
579 if (host_override.has_value()) {
580 state.host = *host_override;
582 if (port_override.has_value()) {
583 state.port = *port_override;
586 GuiAutomationClient client(absl::StrCat(state.host,
":", state.port));
590 client.StopRecording(state.recording_id, discard));
591 if (!stop_result.success) {
592 return absl::InternalError(
593 absl::StrCat(
"Stop recording failed: ", stop_result.message));
597 std::cout <<
"\n=== Recording Session Completed ===\n";
598 std::cout <<
"Recording ID: " << state.recording_id <<
"\n";
599 std::cout <<
"Server: " << state.host <<
":" << state.port <<
"\n";
600 std::cout <<
"Steps captured: " << stop_result.step_count <<
"\n";
601 std::cout <<
"Duration: " << stop_result.duration.count() <<
" ms\n";
602 if (!stop_result.message.empty()) {
603 std::cout <<
"Message: " << stop_result.message <<
"\n";
605 if (!discard && !stop_result.output_path.empty()) {
606 std::cout <<
"Output saved to: " << stop_result.output_path <<
"\n";
610 std::cout <<
"Recording discarded; no script file was produced."
612 return absl::OkStatus();
615 if (!validate || stop_result.output_path.empty()) {
616 std::cout << std::endl;
617 return absl::OkStatus();
620 std::cout <<
"\nReplaying recorded script to validate...\n";
622 client.ReplayTest(stop_result.output_path,
false, {}));
623 if (!replay_result.success) {
624 return absl::InternalError(
625 absl::StrCat(
"Replay failed: ", replay_result.message));
628 std::cout <<
"Replay succeeded. Steps executed: "
629 << replay_result.steps_executed <<
"\n";
630 return absl::OkStatus();
637 return absl::InvalidArgumentError(
638 "Usage: agent test <subcommand>\n"
640 " run --prompt <text> - Generate and run a GUI automation test\n"
641 " replay <script> - Replay a script [--set key=value] [--ci]\n"
642 " status --test-id <id> - Query test execution status\n"
643 " list - List available tests\n"
644 " results --test-id <id> - Get detailed test results\n"
645 " record start/stop - Record test interactions\n"
646 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
649#ifndef YAZE_WITH_GRPC
650 return absl::UnimplementedError(
651 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
652 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
653 "Then: cmake --build build-grpc-test --target z3ed");
655 std::string subcommand = args[0];
656 std::vector<std::string> tail(args.begin() + 1, args.end());
658 if (subcommand ==
"run") {
659 return HandleTestRunCommand(tail);
660 }
else if (subcommand ==
"replay") {
661 return HandleTestReplayCommand(tail);
662 }
else if (subcommand ==
"status") {
663 return HandleTestStatusCommand(tail);
664 }
else if (subcommand ==
"list") {
665 return HandleTestListCommand(tail);
666 }
else if (subcommand ==
"results") {
667 return HandleTestResultsCommand(tail);
668 }
else if (subcommand ==
"record") {
669 return HandleTestRecordCommand(tail);
671 return absl::InvalidArgumentError(
672 absl::StrCat(
"Unknown test subcommand: ", subcommand,
673 "\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)