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