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
23#endif
24
25namespace yaze {
26namespace cli {
27namespace agent {
28
29#ifdef YAZE_WITH_GRPC
30
31namespace {
32
33struct RecordingState {
34 std::string recording_id;
35 std::string host = "localhost";
36 int port = 50052;
37 std::string output_path;
38};
39
40std::filesystem::path RecordingStateFilePath() {
41 std::error_code ec;
42 std::filesystem::path base = std::filesystem::temp_directory_path(ec);
43 if (ec) {
44 base = std::filesystem::current_path();
45 }
46 return base / "yaze" / "agent" / "recording_state.json";
47}
48
49absl::Status SaveRecordingState(const RecordingState& state) {
50 auto path = RecordingStateFilePath();
51 std::error_code ec;
52 std::filesystem::create_directories(path.parent_path(), ec);
53 nlohmann::json json;
54 json["recording_id"] = state.recording_id;
55 json["host"] = state.host;
56 json["port"] = state.port;
57 json["output_path"] = state.output_path;
58
59 std::ofstream out(path, std::ios::out | std::ios::trunc);
60 if (!out.is_open()) {
61 return absl::InternalError(
62 absl::StrCat("Failed to write recording state to ", path.string()));
63 }
64 out << json.dump(2);
65 if (!out.good()) {
66 return absl::InternalError(
67 absl::StrCat("Failed to flush recording state to ", path.string()));
68 }
69 return absl::OkStatus();
70}
71
72absl::StatusOr<RecordingState> LoadRecordingState() {
73 auto path = RecordingStateFilePath();
74 std::ifstream in(path);
75 if (!in.is_open()) {
76 return absl::NotFoundError(
77 "No active recording session found. Run 'z3ed agent test record start' "
78 "first.");
79 }
80
81 nlohmann::json json;
82 try {
83 in >> json;
84 } catch (const nlohmann::json::parse_error& error) {
85 return absl::InternalError(
86 absl::StrCat("Failed to parse recording state at ", path.string(), ": ",
87 error.what()));
88 }
89
90 RecordingState state;
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", "");
95
96 if (state.recording_id.empty()) {
97 return absl::InvalidArgumentError(absl::StrCat(
98 "Recording state at ", path.string(), " is missing a recording_id"));
99 }
100
101 return state;
102}
103
104absl::Status ClearRecordingState() {
105 auto path = RecordingStateFilePath();
106 std::error_code ec;
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()));
111 }
112 return absl::OkStatus();
113}
114
115std::string DefaultRecordingOutputPath() {
116 absl::Time now = absl::Now();
117 return absl::StrCat(
118 "tests/gui/recording-",
119 absl::FormatTime("%Y%m%dT%H%M%S", now, absl::LocalTimeZone()), ".json");
120}
121
122} // namespace
123
124// Forward declarations for subcommand handlers
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);
131
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 "
136 "<port>]\n"
137 "Example: agent test run --prompt \"Open the overworld editor and "
138 "verify it loads\"");
139 }
140
141 std::string prompt = args.size() > 1 ? args[1] : "";
142 std::string host = "localhost";
143 int port = 50052;
144
145 // Parse additional arguments
146 for (size_t i = 2; i < args.size(); ++i) {
147 if (args[i] == "--host" && i + 1 < args.size()) {
148 host = args[++i];
149 } else if (args[i] == "--port" && i + 1 < args.size()) {
150 port = std::stoi(args[++i]);
151 }
152 }
153
154 std::cout << "\n=== GUI Automation Test ===\n";
155 std::cout << "Prompt: " << prompt << "\n";
156 std::cout << "Server: " << host << ":" << port << "\n\n";
157
158 // Generate workflow from natural language
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();
165 }
166
167 auto workflow = workflow_or.value();
168 std::cout << "Generated " << workflow.steps.size() << " test steps\n\n";
169
170 // Connect and execute
171 GuiAutomationClient client(absl::StrCat(host, ":", port));
172 auto status = client.Connect();
173 if (!status.ok()) {
174 std::cerr << "Failed to connect to test harness: " << status.message()
175 << std::endl;
176 return status;
177 }
178
179 // Execute each step
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() << "... ";
183
184 // Execute based on step type
185 absl::StatusOr<AutomationResult> result(
186 absl::InternalError("Unknown step type"));
187
188 switch (step.type) {
190 result = client.Click(step.target);
191 break;
193 result = client.Type(step.target, step.text, step.clear_first);
194 break;
196 result = client.Wait(step.condition, step.timeout_ms);
197 break;
199 result = client.Assert(step.condition);
200 break;
201 default:
202 std::cout << "✗ SKIPPED (unknown type)\n";
203 continue;
204 }
205
206 if (!result.ok()) {
207 std::cout << "✗ FAILED\n";
208 std::cerr << " Error: " << result.status().message() << "\n";
209 return result.status();
210 }
211
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);
216 }
217
218 std::cout << "✓\n";
219 }
220
221 std::cout << "\n✅ Test passed!\n";
222 return absl::OkStatus();
223}
224
225absl::Status HandleTestReplayCommand(const std::vector<std::string>& args) {
226 if (args.empty()) {
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 "
231 "target_room=0x12");
232 }
233
234 std::string script_path = args[0];
235 std::string host = "localhost";
236 int port = 50052;
237 bool ci_mode = false;
238 std::map<std::string, std::string> parameter_overrides;
239
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()) {
243 host = args[++i];
244 } else if (token == "--port" && i + 1 < args.size()) {
245 const std::string& port_value = args[++i];
246 int parsed_port = 0;
247 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
248 return absl::InvalidArgumentError(
249 absl::StrCat("Invalid --port value: ", port_value));
250 }
251 port = parsed_port;
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));
259 }
260 parameter_overrides[assignment.substr(0, separator)] =
261 assignment.substr(separator + 1);
262 } else if (token == "--ci") {
263 ci_mode = true;
264 } else {
265 return absl::InvalidArgumentError(
266 absl::StrCat("Unknown replay option: ", token));
267 }
268 }
269
270 std::cout << "\n=== Replay Test ===\n";
271 std::cout << "Script: " << script_path << "\n";
272 std::cout << "Server: " << host << ":" << port << "\n\n";
273 if (ci_mode) {
274 std::cout << "CI mode: enabled\n";
275 }
276 if (!parameter_overrides.empty()) {
277 std::cout << "Overrides:\n";
278 for (const auto& [key, value] : parameter_overrides) {
279 std::cout << " - " << key << "=" << value << "\n";
280 }
281 std::cout << "\n";
282 }
283
284 GuiAutomationClient client(absl::StrCat(host, ":", port));
285 auto status = client.Connect();
286 if (!status.ok()) {
287 std::cerr << "Failed to connect: " << status.message() << std::endl;
288 return status;
289 }
290
291 auto result = client.ReplayTest(script_path, ci_mode, parameter_overrides);
292 if (!result.ok()) {
293 std::cerr << "Replay failed: " << result.status().message() << std::endl;
294 return result.status();
295 }
296
297 if (result.value().success) {
298 std::cout << "✅ Replay succeeded\n";
299 std::cout << "Steps executed: " << result.value().steps_executed << "\n";
300 } else {
301 std::cout << "❌ Replay failed: " << result.value().message << "\n";
302 return absl::InternalError(result.value().message);
303 }
304
305 return absl::OkStatus();
306}
307
308absl::Status HandleTestStatusCommand(const std::vector<std::string>& args) {
309 std::string test_id;
310 std::string host = "localhost";
311 int port = 50052;
312
313 for (size_t i = 0; i < args.size(); ++i) {
314 if (args[i] == "--test-id" && i + 1 < args.size()) {
315 test_id = args[++i];
316 } else if (args[i] == "--host" && i + 1 < args.size()) {
317 host = args[++i];
318 } else if (args[i] == "--port" && i + 1 < args.size()) {
319 port = std::stoi(args[++i]);
320 }
321 }
322
323 if (test_id.empty()) {
324 return absl::InvalidArgumentError(
325 "Usage: agent test status --test-id <id> [--host <host>] [--port "
326 "<port>]");
327 }
328
329 GuiAutomationClient client(absl::StrCat(host, ":", port));
330 auto status = client.Connect();
331 if (!status.ok()) {
332 return status;
333 }
334
335 auto details = client.GetTestStatus(test_id);
336 if (!details.ok()) {
337 return details.status();
338 }
339
340 std::cout << "\n=== Test Status ===\n";
341 std::cout << "Test ID: " << test_id << "\n";
342 std::cout << "Status: " << TestRunStatusToString(details.value().status)
343 << "\n";
344 std::cout << "Started: " << FormatOptionalTime(details.value().started_at)
345 << "\n";
346 std::cout << "Completed: " << FormatOptionalTime(details.value().completed_at)
347 << "\n";
348
349 if (!details.value().error_message.empty()) {
350 std::cout << "Error: " << details.value().error_message << "\n";
351 }
352
353 return absl::OkStatus();
354}
355
356absl::Status HandleTestListCommand(const std::vector<std::string>& args) {
357 std::string host = "localhost";
358 int port = 50052;
359
360 for (size_t i = 0; i < args.size(); ++i) {
361 if (args[i] == "--host" && i + 1 < args.size()) {
362 host = args[++i];
363 } else if (args[i] == "--port" && i + 1 < args.size()) {
364 port = std::stoi(args[++i]);
365 }
366 }
367
368 GuiAutomationClient client(absl::StrCat(host, ":", port));
369 auto status = client.Connect();
370 if (!status.ok()) {
371 return status;
372 }
373
374 auto batch = client.ListTests("", 100, "");
375 if (!batch.ok()) {
376 return batch.status();
377 }
378
379 std::cout << "\n=== Available Tests ===\n";
380 std::cout << "Total: " << batch.value().total_count << "\n\n";
381
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";
388 }
389
390 return absl::OkStatus();
391}
392
393absl::Status HandleTestResultsCommand(const std::vector<std::string>& args) {
394 std::string test_id;
395 std::string host = "localhost";
396 int port = 50052;
397 bool include_logs = false;
398
399 for (size_t i = 0; i < args.size(); ++i) {
400 if (args[i] == "--test-id" && i + 1 < args.size()) {
401 test_id = args[++i];
402 } else if (args[i] == "--host" && i + 1 < args.size()) {
403 host = args[++i];
404 } else if (args[i] == "--port" && i + 1 < args.size()) {
405 port = std::stoi(args[++i]);
406 } else if (args[i] == "--include-logs") {
407 include_logs = true;
408 }
409 }
410
411 if (test_id.empty()) {
412 return absl::InvalidArgumentError(
413 "Usage: agent test results --test-id <id> [--include-logs] [--host "
414 "<host>] [--port <port>]");
415 }
416
417 GuiAutomationClient client(absl::StrCat(host, ":", port));
418 auto status = client.Connect();
419 if (!status.ok()) {
420 return status;
421 }
422
423 auto details = client.GetTestResults(test_id, include_logs);
424 if (!details.ok()) {
425 return details.status();
426 }
427
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";
433
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";
441 }
442 }
443 }
444
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";
449 }
450 }
451
452 return absl::OkStatus();
453}
454
455absl::Status HandleTestRecordCommand(const std::vector<std::string>& args) {
456 if (args.empty()) {
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>]");
462 }
463
464 std::string action = args[0];
465 if (action != "start" && action != "stop") {
466 return absl::InvalidArgumentError(
467 "Record action must be 'start' or 'stop'");
468 }
469
470 if (action == "start") {
471 std::string host = "localhost";
472 int port = 50052;
473 std::string description;
474 std::string session_name;
475 std::string output_path;
476
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()) {
482 description = args[++i];
483 } else if (token == "--session" && i + 1 < args.size()) {
484 session_name = args[++i];
485 } else if (token == "--host" && i + 1 < args.size()) {
486 host = args[++i];
487 } else if (token == "--port" && i + 1 < args.size()) {
488 std::string port_value = args[++i];
489 int parsed_port = 0;
490 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
491 return absl::InvalidArgumentError(
492 absl::StrCat("Invalid --port value: ", port_value));
493 }
494 port = parsed_port;
495 }
496 }
497
498 if (output_path.empty()) {
499 output_path = DefaultRecordingOutputPath();
500 }
501
502 std::filesystem::path absolute_output =
503 std::filesystem::absolute(output_path);
504 std::error_code ec;
505 std::filesystem::create_directories(absolute_output.parent_path(), ec);
506
507 GuiAutomationClient client(absl::StrCat(host, ":", port));
508 RETURN_IF_ERROR(client.Connect());
509
510 if (session_name.empty()) {
511 session_name = std::filesystem::path(output_path).stem().string();
512 }
513
514 ASSIGN_OR_RETURN(auto start_result,
515 client.StartRecording(absolute_output.string(),
516 session_name, description));
517 if (!start_result.success) {
518 return absl::InternalError(absl::StrCat(
519 "Harness rejected start-recording request: ", start_result.message));
520 }
521
522 RecordingState state;
523 state.recording_id = start_result.recording_id;
524 state.host = host;
525 state.port = port;
526 state.output_path = absolute_output.string();
527 RETURN_IF_ERROR(SaveRecordingState(state));
528
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";
533 if (!description.empty()) {
534 std::cout << "Description: " << description << "\n";
535 }
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())
541 << "\n";
542 }
543 std::cout << "\nPress Ctrl+C to abort the recording session.\n";
544
545 return absl::OkStatus();
546 }
547
548 // Stop
549 bool validate = false;
550 bool discard = false;
551 std::optional<std::string> host_override;
552 std::optional<int> port_override;
553
554 for (size_t i = 1; i < args.size(); ++i) {
555 const std::string& token = args[i];
556 if (token == "--validate") {
557 validate = true;
558 } else if (token == "--discard") {
559 discard = true;
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];
564 int parsed_port = 0;
565 if (!absl::SimpleAtoi(port_value, &parsed_port)) {
566 return absl::InvalidArgumentError(
567 absl::StrCat("Invalid --port value: ", port_value));
568 }
569 port_override = parsed_port;
570 }
571 }
572
573 if (discard && validate) {
574 return absl::InvalidArgumentError(
575 "Cannot use --validate and --discard together");
576 }
577
578 ASSIGN_OR_RETURN(auto state, LoadRecordingState());
579 if (host_override.has_value()) {
580 state.host = *host_override;
581 }
582 if (port_override.has_value()) {
583 state.port = *port_override;
584 }
585
586 GuiAutomationClient client(absl::StrCat(state.host, ":", state.port));
587 RETURN_IF_ERROR(client.Connect());
588
589 ASSIGN_OR_RETURN(auto stop_result,
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));
594 }
595 RETURN_IF_ERROR(ClearRecordingState());
596
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";
604 }
605 if (!discard && !stop_result.output_path.empty()) {
606 std::cout << "Output saved to: " << stop_result.output_path << "\n";
607 }
608
609 if (discard) {
610 std::cout << "Recording discarded; no script file was produced."
611 << std::endl;
612 return absl::OkStatus();
613 }
614
615 if (!validate || stop_result.output_path.empty()) {
616 std::cout << std::endl;
617 return absl::OkStatus();
618 }
619
620 std::cout << "\nReplaying recorded script to validate...\n";
621 ASSIGN_OR_RETURN(auto replay_result,
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));
626 }
627
628 std::cout << "Replay succeeded. Steps executed: "
629 << replay_result.steps_executed << "\n";
630 return absl::OkStatus();
631}
632
633#endif // YAZE_WITH_GRPC
634
635absl::Status HandleTestCommand(const std::vector<std::string>& args) {
636 if (args.empty()) {
637 return absl::InvalidArgumentError(
638 "Usage: agent test <subcommand>\n"
639 "Subcommands:\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.");
647 }
648
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");
654#else
655 std::string subcommand = args[0];
656 std::vector<std::string> tail(args.begin() + 1, args.end());
657
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);
670 } else {
671 return absl::InvalidArgumentError(
672 absl::StrCat("Unknown test subcommand: ", subcommand,
673 "\nRun 'z3ed agent test' for usage."));
674 }
675#endif
676}
677
678} // namespace agent
679} // namespace cli
680} // 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)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22