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