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