yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
test_commands.cc
Go to the documentation of this file.
1#include <filesystem>
2#include <fstream>
3#include <iostream>
4#include <map>
5#include <optional>
6#include <string>
7#include <vector>
8
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"
18#include "util/macro.h"
19
20#ifdef YAZE_WITH_GRPC
24#endif
25
26namespace yaze {
27namespace cli {
28namespace agent {
29
30#ifdef YAZE_WITH_GRPC
31
32namespace {
33
34struct RecordingState {
35 std::string recording_id;
36 std::string host = "localhost";
37 int port = 50052;
38 std::string output_path;
39};
40
41std::filesystem::path RecordingStateFilePath() {
42 std::error_code ec;
43 std::filesystem::path base = std::filesystem::temp_directory_path(ec);
44 if (ec) {
45 base = std::filesystem::current_path();
46 }
47 return base / "yaze" / "agent" / "recording_state.json";
48}
49
50absl::Status SaveRecordingState(const RecordingState& state) {
51 auto path = RecordingStateFilePath();
52 std::error_code ec;
53 std::filesystem::create_directories(path.parent_path(), ec);
54 nlohmann::json json;
55 json["recording_id"] = state.recording_id;
56 json["host"] = state.host;
57 json["port"] = state.port;
58 json["output_path"] = state.output_path;
59
60 std::ofstream out(path, std::ios::out | std::ios::trunc);
61 if (!out.is_open()) {
62 return absl::InternalError(
63 absl::StrCat("Failed to write recording state to ", path.string()));
64 }
65 out << json.dump(2);
66 if (!out.good()) {
67 return absl::InternalError(
68 absl::StrCat("Failed to flush recording state to ", path.string()));
69 }
70 return absl::OkStatus();
71}
72
73absl::StatusOr<RecordingState> LoadRecordingState() {
74 auto path = RecordingStateFilePath();
75 std::ifstream in(path);
76 if (!in.is_open()) {
77 return absl::NotFoundError(
78 "No active recording session found. Run 'z3ed agent test record start' "
79 "first.");
80 }
81
82 nlohmann::json json;
83 try {
84 in >> json;
85 } catch (const nlohmann::json::parse_error& error) {
86 return absl::InternalError(
87 absl::StrCat("Failed to parse recording state at ", path.string(), ": ",
88 error.what()));
89 }
90
91 RecordingState state;
92 state.recording_id = json.value("recording_id", "");
93 state.host = json.value("host", "localhost");
94 state.port = json.value("port", 50052);
95 state.output_path = json.value("output_path", "");
96
97 if (state.recording_id.empty()) {
98 return absl::InvalidArgumentError(absl::StrCat(
99 "Recording state at ", path.string(), " is missing a recording_id"));
100 }
101
102 return state;
103}
104
105absl::Status ClearRecordingState() {
106 auto path = RecordingStateFilePath();
107 std::error_code ec;
108 std::filesystem::remove(path, ec);
109 if (ec && ec != std::errc::no_such_file_or_directory) {
110 return absl::InternalError(
111 absl::StrCat("Failed to clear recording state: ", ec.message()));
112 }
113 return absl::OkStatus();
114}
115
116std::string DefaultRecordingOutputPath() {
117 absl::Time now = absl::Now();
118 return absl::StrCat(
119 "tests/gui/recording-",
120 absl::FormatTime("%Y%m%dT%H%M%S", now, absl::LocalTimeZone()), ".json");
121}
122
123} // namespace
124
125// Forward declarations for subcommand handlers
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);
132
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 "
137 "<port>]\n"
138 "Example: agent test run --prompt \"Open the overworld editor and "
139 "verify it loads\"");
140 }
141
142 std::string prompt = args.size() > 1 ? args[1] : "";
143 std::string host = "localhost";
144 int port = 50052;
145
146 // Parse additional arguments
147 for (size_t i = 2; i < args.size(); ++i) {
148 if (args[i] == "--host" && i + 1 < args.size()) {
149 host = args[++i];
150 } else if (args[i] == "--port" && i + 1 < args.size()) {
151 port = std::stoi(args[++i]);
152 }
153 }
154
155 std::cout << "\n=== GUI Automation Test ===\n";
156 std::cout << "Prompt: " << prompt << "\n";
157 std::cout << "Server: " << host << ":" << port << "\n\n";
158
159 // Generate workflow from natural language
160 TestWorkflowGenerator generator;
161 auto workflow_or = generator.GenerateWorkflow(prompt);
162 if (!workflow_or.ok()) {
163 std::cerr << "Failed to generate workflow: "
164 << workflow_or.status().message() << std::endl;
165 return workflow_or.status();
166 }
167
168 auto workflow = workflow_or.value();
169 std::cout << "Generated " << workflow.steps.size() << " test steps\n\n";
170
171 // Connect and execute
172 GuiAutomationClient client(absl::StrCat(host, ":", port));
173 auto status = client.Connect();
174 if (!status.ok()) {
175 std::cerr << "Failed to connect to test harness: " << status.message()
176 << std::endl;
177 return status;
178 }
179
180 // Execute each step
181 for (size_t i = 0; i < workflow.steps.size(); ++i) {
182 const auto& step = workflow.steps[i];
183 std::cout << "Step " << (i + 1) << ": " << step.ToString() << "... ";
184
185 // Execute based on step type
186 absl::StatusOr<AutomationResult> result(
187 absl::InternalError("Unknown step type"));
188
189 switch (step.type) {
191 result = client.Click(step.target);
192 break;
194 result = client.Type(step.target, step.text, step.clear_first);
195 break;
197 result = client.Wait(step.condition, step.timeout_ms);
198 break;
200 result = client.Assert(step.condition);
201 break;
202 default:
203 std::cout << "✗ SKIPPED (unknown type)\n";
204 continue;
205 }
206
207 if (!result.ok()) {
208 std::cout << "✗ FAILED\n";
209 std::cerr << " Error: " << result.status().message() << "\n";
210 return result.status();
211 }
212
213 if (!result.value().success) {
214 std::cout << "✗ FAILED\n";
215 std::cerr << " Error: " << result.value().message << "\n";
216 return absl::InternalError(result.value().message);
217 }
218
219 std::cout << "✓\n";
220 }
221
222 std::cout << "\n✅ Test passed!\n";
223 return absl::OkStatus();
224}
225
226absl::Status HandleTestReplayCommand(const std::vector<std::string>& args) {
227 if (args.empty()) {
228 return absl::InvalidArgumentError(
229 "Usage: agent test replay <script.json> [--host <host>] [--port "
230 "<port>] [--ci] [--set key=value ...]\n"
231 "Example: agent test replay tests/overworld_load.json --set "
232 "target_room=0x12");
233 }
234
235 std::string script_path = args[0];
236 std::string host = "localhost";
237 int port = 50052;
238 bool ci_mode = false;
239 std::map<std::string, std::string> parameter_overrides;
240
241 for (size_t i = 1; i < args.size(); ++i) {
242 const std::string& token = args[i];
243 if (token == "--host" && i + 1 < args.size()) {
244 host = args[++i];
245 } else if (token == "--port" && i + 1 < args.size()) {
246 const std::string& port_value = args[++i];
247 int parsed_port = 0;
248 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
249 return absl::InvalidArgumentError(
250 absl::StrCat("Invalid --port value: ", port_value));
251 }
252 port = parsed_port;
253 } else if (token == "--set" && i + 1 < args.size()) {
254 const std::string& assignment = args[++i];
255 size_t separator = assignment.find('=');
256 if (separator == std::string::npos || separator == 0 ||
257 separator == assignment.size() - 1) {
258 return absl::InvalidArgumentError(absl::StrCat(
259 "Invalid --set value (expected key=value): ", assignment));
260 }
261 parameter_overrides[assignment.substr(0, separator)] =
262 assignment.substr(separator + 1);
263 } else if (token == "--ci") {
264 ci_mode = true;
265 } else {
266 return absl::InvalidArgumentError(
267 absl::StrCat("Unknown replay option: ", token));
268 }
269 }
270
271 std::cout << "\n=== Replay Test ===\n";
272 std::cout << "Script: " << script_path << "\n";
273 std::cout << "Server: " << host << ":" << port << "\n\n";
274 if (ci_mode) {
275 std::cout << "CI mode: enabled\n";
276 }
277 if (!parameter_overrides.empty()) {
278 std::cout << "Overrides:\n";
279 for (const auto& [key, value] : parameter_overrides) {
280 std::cout << " - " << key << "=" << value << "\n";
281 }
282 std::cout << "\n";
283 }
284
285 GuiAutomationClient client(absl::StrCat(host, ":", port));
286 auto status = client.Connect();
287 if (!status.ok()) {
288 std::cerr << "Failed to connect: " << status.message() << std::endl;
289 return status;
290 }
291
292 auto result = client.ReplayTest(script_path, ci_mode, parameter_overrides);
293 if (!result.ok()) {
294 std::cerr << "Replay failed: " << result.status().message() << std::endl;
295 return result.status();
296 }
297
298 PrintReplayResult(*result, std::cout);
299 if (!result->success) {
300 return absl::InternalError(result.value().message);
301 }
302
303 return absl::OkStatus();
304}
305
306absl::Status HandleTestStatusCommand(const std::vector<std::string>& args) {
307 std::string test_id;
308 std::string host = "localhost";
309 int port = 50052;
310
311 for (size_t i = 0; i < args.size(); ++i) {
312 if (args[i] == "--test-id" && i + 1 < args.size()) {
313 test_id = args[++i];
314 } else if (args[i] == "--host" && i + 1 < args.size()) {
315 host = args[++i];
316 } else if (args[i] == "--port" && i + 1 < args.size()) {
317 port = std::stoi(args[++i]);
318 }
319 }
320
321 if (test_id.empty()) {
322 return absl::InvalidArgumentError(
323 "Usage: agent test status --test-id <id> [--host <host>] [--port "
324 "<port>]");
325 }
326
327 GuiAutomationClient client(absl::StrCat(host, ":", port));
328 auto status = client.Connect();
329 if (!status.ok()) {
330 return status;
331 }
332
333 auto details = client.GetTestStatus(test_id);
334 if (!details.ok()) {
335 return details.status();
336 }
337
338 std::cout << "\n=== Test Status ===\n";
339 std::cout << "Test ID: " << test_id << "\n";
340 std::cout << "Status: " << TestRunStatusToString(details.value().status)
341 << "\n";
342 std::cout << "Started: " << FormatOptionalTime(details.value().started_at)
343 << "\n";
344 std::cout << "Completed: " << FormatOptionalTime(details.value().completed_at)
345 << "\n";
346
347 if (!details.value().error_message.empty()) {
348 std::cout << "Error: " << details.value().error_message << "\n";
349 }
350
351 return absl::OkStatus();
352}
353
354absl::Status HandleTestListCommand(const std::vector<std::string>& args) {
355 std::string host = "localhost";
356 int port = 50052;
357
358 for (size_t i = 0; i < args.size(); ++i) {
359 if (args[i] == "--host" && i + 1 < args.size()) {
360 host = args[++i];
361 } else if (args[i] == "--port" && i + 1 < args.size()) {
362 port = std::stoi(args[++i]);
363 }
364 }
365
366 GuiAutomationClient client(absl::StrCat(host, ":", port));
367 auto status = client.Connect();
368 if (!status.ok()) {
369 return status;
370 }
371
372 auto batch = client.ListTests("", 100, "");
373 if (!batch.ok()) {
374 return batch.status();
375 }
376
377 std::cout << "\n=== Available Tests ===\n";
378 std::cout << "Total: " << batch.value().total_count << "\n\n";
379
380 for (const auto& test : batch.value().tests) {
381 std::cout << "• " << test.name << "\n";
382 std::cout << " ID: " << test.test_id << "\n";
383 std::cout << " Category: " << test.category << "\n";
384 std::cout << " Runs: " << test.total_runs << " (" << test.pass_count
385 << " passed, " << test.fail_count << " failed)\n\n";
386 }
387
388 return absl::OkStatus();
389}
390
391absl::Status HandleTestResultsCommand(const std::vector<std::string>& args) {
392 std::string test_id;
393 std::string host = "localhost";
394 int port = 50052;
395 bool include_logs = false;
396
397 for (size_t i = 0; i < args.size(); ++i) {
398 if (args[i] == "--test-id" && i + 1 < args.size()) {
399 test_id = args[++i];
400 } else if (args[i] == "--host" && i + 1 < args.size()) {
401 host = args[++i];
402 } else if (args[i] == "--port" && i + 1 < args.size()) {
403 port = std::stoi(args[++i]);
404 } else if (args[i] == "--include-logs") {
405 include_logs = true;
406 }
407 }
408
409 if (test_id.empty()) {
410 return absl::InvalidArgumentError(
411 "Usage: agent test results --test-id <id> [--include-logs] [--host "
412 "<host>] [--port <port>]");
413 }
414
415 GuiAutomationClient client(absl::StrCat(host, ":", port));
416 auto status = client.Connect();
417 if (!status.ok()) {
418 return status;
419 }
420
421 auto details = client.GetTestResults(test_id, include_logs);
422 if (!details.ok()) {
423 return details.status();
424 }
425
426 std::cout << "\n=== Test Results ===\n";
427 std::cout << "Test ID: " << details.value().test_id << "\n";
428 std::cout << "Name: " << details.value().test_name << "\n";
429 std::cout << "Success: " << (details.value().success ? "✓" : "✗") << "\n";
430 std::cout << "Duration: " << details.value().duration_ms << "ms\n\n";
431
432 if (!details.value().assertions.empty()) {
433 std::cout << "Assertions:\n";
434 for (const auto& assertion : details.value().assertions) {
435 std::cout << " " << (assertion.passed ? "✓" : "✗") << " "
436 << assertion.description << "\n";
437 if (!assertion.error_message.empty()) {
438 std::cout << " Error: " << assertion.error_message << "\n";
439 }
440 }
441 }
442
443 if (include_logs && !details.value().logs.empty()) {
444 std::cout << "\nLogs:\n";
445 for (const auto& log : details.value().logs) {
446 std::cout << " " << log << "\n";
447 }
448 }
449
450 return absl::OkStatus();
451}
452
453absl::Status HandleTestRecordCommand(const std::vector<std::string>& args) {
454 if (args.empty()) {
455 return absl::InvalidArgumentError(
456 "Usage: agent test record <start|stop> [options]\n"
457 " start [--output <file>] [--description <text>] [--session <id>]\n"
458 " [--host <host>] [--port <port>]\n"
459 " stop [--validate] [--discard] [--host <host>] [--port <port>]");
460 }
461
462 std::string action = args[0];
463 if (action != "start" && action != "stop") {
464 return absl::InvalidArgumentError(
465 "Record action must be 'start' or 'stop'");
466 }
467
468 if (action == "start") {
469 std::string host = "localhost";
470 int port = 50052;
471 std::string description;
472 std::string session_name;
473 std::string output_path;
474
475 for (size_t i = 1; i < args.size(); ++i) {
476 const std::string& token = args[i];
477 if (token == "--output" && i + 1 < args.size()) {
478 output_path = args[++i];
479 } else if (token == "--description" && i + 1 < args.size()) {
480 description = args[++i];
481 } else if (token == "--session" && i + 1 < args.size()) {
482 session_name = args[++i];
483 } else if (token == "--host" && i + 1 < args.size()) {
484 host = args[++i];
485 } else if (token == "--port" && i + 1 < args.size()) {
486 std::string port_value = args[++i];
487 int parsed_port = 0;
488 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
489 return absl::InvalidArgumentError(
490 absl::StrCat("Invalid --port value: ", port_value));
491 }
492 port = parsed_port;
493 }
494 }
495
496 if (output_path.empty()) {
497 output_path = DefaultRecordingOutputPath();
498 }
499
500 std::filesystem::path absolute_output =
501 std::filesystem::absolute(output_path);
502 std::error_code ec;
503 std::filesystem::create_directories(absolute_output.parent_path(), ec);
504
505 GuiAutomationClient client(absl::StrCat(host, ":", port));
506 RETURN_IF_ERROR(client.Connect());
507
508 if (session_name.empty()) {
509 session_name = std::filesystem::path(output_path).stem().string();
510 }
511
512 ASSIGN_OR_RETURN(auto start_result,
513 client.StartRecording(absolute_output.string(),
514 session_name, description));
515 if (!start_result.success) {
516 return absl::InternalError(absl::StrCat(
517 "Harness rejected start-recording request: ", start_result.message));
518 }
519
520 RecordingState state;
521 state.recording_id = start_result.recording_id;
522 state.host = host;
523 state.port = port;
524 state.output_path = absolute_output.string();
525 RETURN_IF_ERROR(SaveRecordingState(state));
526
527 std::cout << "\n=== Recording Session Started ===\n";
528 std::cout << "Recording ID: " << start_result.recording_id << "\n";
529 std::cout << "Server: " << host << ":" << port << "\n";
530 std::cout << "Output: " << absolute_output << "\n";
531 if (!description.empty()) {
532 std::cout << "Description: " << description << "\n";
533 }
534 if (start_result.started_at.has_value()) {
535 std::cout << "Started: "
536 << absl::FormatTime("%Y-%m-%d %H:%M:%S",
537 *start_result.started_at,
538 absl::LocalTimeZone())
539 << "\n";
540 }
541 std::cout << "\nPress Ctrl+C to abort the recording session.\n";
542
543 return absl::OkStatus();
544 }
545
546 // Stop
547 bool validate = false;
548 bool discard = false;
549 std::optional<std::string> host_override;
550 std::optional<int> port_override;
551
552 for (size_t i = 1; i < args.size(); ++i) {
553 const std::string& token = args[i];
554 if (token == "--validate") {
555 validate = true;
556 } else if (token == "--discard") {
557 discard = true;
558 } else if (token == "--host" && i + 1 < args.size()) {
559 host_override = args[++i];
560 } else if (token == "--port" && i + 1 < args.size()) {
561 std::string port_value = args[++i];
562 int parsed_port = 0;
563 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
564 return absl::InvalidArgumentError(
565 absl::StrCat("Invalid --port value: ", port_value));
566 }
567 port_override = parsed_port;
568 }
569 }
570
571 if (discard && validate) {
572 return absl::InvalidArgumentError(
573 "Cannot use --validate and --discard together");
574 }
575
576 ASSIGN_OR_RETURN(auto state, LoadRecordingState());
577 if (host_override.has_value()) {
578 state.host = *host_override;
579 }
580 if (port_override.has_value()) {
581 state.port = *port_override;
582 }
583
584 GuiAutomationClient client(absl::StrCat(state.host, ":", state.port));
585 RETURN_IF_ERROR(client.Connect());
586
587 ASSIGN_OR_RETURN(auto stop_result,
588 client.StopRecording(state.recording_id, discard));
589 if (!stop_result.success) {
590 return absl::InternalError(
591 absl::StrCat("Stop recording failed: ", stop_result.message));
592 }
593 RETURN_IF_ERROR(ClearRecordingState());
594
595 std::cout << "\n=== Recording Session Completed ===\n";
596 std::cout << "Recording ID: " << state.recording_id << "\n";
597 std::cout << "Server: " << state.host << ":" << state.port << "\n";
598 std::cout << "Steps captured: " << stop_result.step_count << "\n";
599 std::cout << "Duration: " << stop_result.duration.count() << " ms\n";
600 if (!stop_result.message.empty()) {
601 std::cout << "Message: " << stop_result.message << "\n";
602 }
603 if (!discard && !stop_result.output_path.empty()) {
604 std::cout << "Output saved to: " << stop_result.output_path << "\n";
605 }
606
607 if (discard) {
608 std::cout << "Recording discarded; no script file was produced."
609 << std::endl;
610 return absl::OkStatus();
611 }
612
613 if (!validate || stop_result.output_path.empty()) {
614 std::cout << std::endl;
615 return absl::OkStatus();
616 }
617
618 std::cout << "\nReplaying recorded script to validate...\n";
619 ASSIGN_OR_RETURN(auto replay_result,
620 client.ReplayTest(stop_result.output_path, false, {}));
621 if (!replay_result.success) {
622 return absl::InternalError(
623 absl::StrCat("Replay failed: ", replay_result.message));
624 }
625
626 std::cout << "Replay succeeded. Steps executed: "
627 << replay_result.steps_executed << "\n";
628 return absl::OkStatus();
629}
630
631#endif // YAZE_WITH_GRPC
632
633absl::Status HandleTestCommand(const std::vector<std::string>& args) {
634 if (args.empty()) {
635 return absl::InvalidArgumentError(
636 "Usage: agent test <subcommand>\n"
637 "Subcommands:\n"
638 " run --prompt <text> - Generate and run a GUI automation test\n"
639 " replay <script> - Replay a script [--set key=value] [--ci]\n"
640 " status --test-id <id> - Query test execution status\n"
641 " list - List available tests\n"
642 " results --test-id <id> - Get detailed test results\n"
643 " record start/stop - Record test interactions\n"
644 "\nNote: Test commands require YAZE_WITH_GRPC=ON at build time.");
645 }
646
647#ifndef YAZE_WITH_GRPC
648 return absl::UnimplementedError(
649 "GUI automation test commands require YAZE_WITH_GRPC=ON at build time.\n"
650 "Rebuild with: cmake -B build-grpc-test -DYAZE_WITH_GRPC=ON\n"
651 "Then: cmake --build build-grpc-test --target z3ed");
652#else
653 std::string subcommand = args[0];
654 std::vector<std::string> tail(args.begin() + 1, args.end());
655
656 if (subcommand == "run") {
657 return HandleTestRunCommand(tail);
658 } else if (subcommand == "replay") {
659 return HandleTestReplayCommand(tail);
660 } else if (subcommand == "status") {
661 return HandleTestStatusCommand(tail);
662 } else if (subcommand == "list") {
663 return HandleTestListCommand(tail);
664 } else if (subcommand == "results") {
665 return HandleTestResultsCommand(tail);
666 } else if (subcommand == "record") {
667 return HandleTestRecordCommand(tail);
668 } else {
669 return absl::InvalidArgumentError(
670 absl::StrCat("Unknown test subcommand: ", subcommand,
671 "\nRun 'z3ed agent test' for usage."));
672 }
673#endif
674}
675
676} // namespace agent
677} // namespace cli
678} // namespace yaze
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
std::string FormatOptionalTime(const std::optional< absl::Time > &time)
Definition common.cc:60
absl::Status HandleTestCommand(const std::vector< std::string > &args)
void PrintReplayResult(const ReplayTestResult &result, std::ostream &output)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22