feat(cli): crafter-debug, the client agents drive an app with
Cli::Run is the whole client but its transport, so tests run it in process against a real Session; main will hand it the QUIC connection. - Commands: ls, status, cmds, help, call, get, set, pause, resume, step, wait, trace, log (with --follow), screenshot, quit, debugger, guide and version, with -i, --json and --timeout. - call types key=value from the command's argument schema (slot=1 is a number, name=1 a string, at.x=3 nests), so agents need no JSON for simple calls; a value that cannot fit its field is a usage error before anything is sent. - The instance comes from discovery; one running instance needs no -i. The record's protocol is checked before any request: a mismatch exits 4, says which side is newer and prints the app's own client. Other sources only warn. - A request with no reply is diagnosed: the app has exited, stopped in a debugger (with the attach command), or no longer calls Pump (with what to change). Exit codes: 2 usage, 3 no instance, 4 version, 5 timeout, 6 the app's error. - Output is short by default, arrays cut at 20 with a note; --json prints everything. `guide` is the full reference for agents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
37e0d9b9ae
commit
34c60dcd34
7 changed files with 1359 additions and 10 deletions
213
tests/ShouldRunClientCommands/main.cpp
Normal file
213
tests/ShouldRunClientCommands/main.cpp
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The crafter-debug client end to end against a real Session in the same
|
||||
// process, through a Connection that hands each request to Session::Handle
|
||||
// while a thread plays the app's frame loop (Advance, Pump). Covers what an
|
||||
// agent sees: typed key=value calls and the usage error for a bad value,
|
||||
// suggestions for typos, get/set, pause/step/resume, waits, the log, traces,
|
||||
// screenshots written where asked, --json, instance selection, the protocol
|
||||
// and source checks against the discovery record, a server-side timeout,
|
||||
// and a client-side one that diagnoses an app no longer calling Pump.
|
||||
#include <stdio.h>
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
import Crafter.Debug.Cli;
|
||||
using namespace Crafter::Debug;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
struct AimArgs {
|
||||
Required<std::int32_t> slot;
|
||||
float yaw = 10;
|
||||
};
|
||||
|
||||
struct Aimed {
|
||||
std::int32_t slot = 0;
|
||||
float yaw = 0;
|
||||
};
|
||||
|
||||
Session* session = nullptr;
|
||||
|
||||
class Collect : public Detail::Exchange {
|
||||
public:
|
||||
explicit Collect(std::function<bool(const Value&)> onLine) : onLine(std::move(onLine)) {}
|
||||
void Reply(const Value& reply) override {
|
||||
std::lock_guard lock(mutex);
|
||||
if (final) return;
|
||||
final = reply;
|
||||
ready.notify_all();
|
||||
}
|
||||
bool Stream(const Value& line) override { return onLine ? onLine(line) : true; }
|
||||
std::optional<Value> Wait(std::chrono::milliseconds timeout) {
|
||||
std::unique_lock lock(mutex);
|
||||
ready.wait_for(lock, timeout, [&] { return final.has_value(); });
|
||||
return final;
|
||||
}
|
||||
|
||||
private:
|
||||
std::function<bool(const Value&)> onLine;
|
||||
std::mutex mutex;
|
||||
std::condition_variable ready;
|
||||
std::optional<Value> final;
|
||||
};
|
||||
|
||||
class InProcess : public Cli::Connection {
|
||||
public:
|
||||
std::expected<Value, Cli::CallError> Call(const Value& request, std::chrono::milliseconds timeout, const std::function<bool(const Value&)>& onLine) override {
|
||||
auto exchange = std::make_shared<Collect>(onLine);
|
||||
session->Handle(request, exchange);
|
||||
if (auto reply = exchange->Wait(timeout)) return *reply;
|
||||
return std::unexpected(Cli::CallError{ Cli::CallError::Kind::TimedOut, "no reply" });
|
||||
}
|
||||
};
|
||||
|
||||
struct Ran {
|
||||
std::int32_t code = 0;
|
||||
std::string out;
|
||||
std::string err;
|
||||
};
|
||||
|
||||
Ran Run(std::vector<std::string_view> args, const std::vector<Instance>& instances) {
|
||||
std::ostringstream out;
|
||||
std::ostringstream err;
|
||||
Cli::Environment environment;
|
||||
environment.connect = [](const InstanceRecord&) -> std::unique_ptr<Cli::Connection> { return std::make_unique<InProcess>(); };
|
||||
environment.list = [instances] { return instances; };
|
||||
environment.out = &out;
|
||||
environment.err = &err;
|
||||
environment.cwd = fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands";
|
||||
environment.haveLldb = [] { return false; };
|
||||
const std::int32_t code = Cli::Run(args, environment);
|
||||
return { code, out.str(), err.str() };
|
||||
}
|
||||
|
||||
bool Has(const std::string& text, std::string_view part) {
|
||||
return text.find(part) != std::string::npos;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::thread([] {
|
||||
std::this_thread::sleep_for(50s);
|
||||
std::println("FAIL the test hung");
|
||||
std::fflush(stdout);
|
||||
std::_Exit(1);
|
||||
}).detach();
|
||||
|
||||
Command("Player_SetAim", "Aim the turret.", [](const AimArgs& args) { return Aimed{ *args.slot, args.yaw }; });
|
||||
std::int32_t hp = 100;
|
||||
Watch("hp", hp);
|
||||
std::int32_t score = 0;
|
||||
Watch("score", score);
|
||||
SetCapture([] {
|
||||
return Async<std::vector<std::byte>>([]() -> std::optional<std::vector<std::byte>> {
|
||||
return std::vector<std::byte>{ std::byte{ 0x89 }, std::byte{ 'P' }, std::byte{ 'N' }, std::byte{ 'G' } };
|
||||
});
|
||||
});
|
||||
|
||||
Session owned({ .name = "game", .listen = false });
|
||||
session = &owned;
|
||||
const InstanceRecord record{ .name = "game", .instanceId = "abc123", .pid = 2, .token = owned.Token(), .protocol = protocolVersion, .sourceId = std::string(Detail::SourceId()) };
|
||||
const std::vector<Instance> instances = { { record, {} } };
|
||||
|
||||
std::atomic<bool> pumping = true;
|
||||
std::thread app([&] {
|
||||
while (pumping) {
|
||||
if (Advance()) ++score;
|
||||
Pump();
|
||||
std::this_thread::sleep_for(2ms);
|
||||
}
|
||||
});
|
||||
|
||||
Ran r = Run({ "status" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "game frame"), "status");
|
||||
r = Run({ "cmds" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "Player_SetAim(slot: integer (required), yaw: number = 10.0) -> Aimed -- Aim the turret."), std::format("cmds shows arguments and defaults: {}", r.out));
|
||||
r = Run({ "help", "Player_SetAim" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "required") && Has(r.out, "default 10.0"), "help shows each argument");
|
||||
|
||||
r = Run({ "call", "Player_SetAim", "slot=2", "yaw=45" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, R"({"slot":2,"yaw":45.0})"), std::format("call types key=value from the arguments ({})", r.out + r.err));
|
||||
r = Run({ "call", "Player_SetAim", "slot=two" }, instances);
|
||||
Check(r.code == 2 && Has(r.err, "slot: 'two' is not a number"), "a value that does not fit its field is a usage error");
|
||||
r = Run({ "call", "Player_SetAm" }, instances);
|
||||
Check(r.code == 6 && Has(r.err, "did you mean 'Player_SetAim'"), "a mistyped command gets a suggestion");
|
||||
|
||||
r = Run({ "get", "hp" }, instances);
|
||||
Check(r.code == 0 && r.out == "hp = 100\n", "get");
|
||||
r = Run({ "set", "hp", "5" }, instances);
|
||||
Check(r.code == 0 && r.out == "hp = 5\n", "set");
|
||||
r = Run({ "get", "nope" }, instances);
|
||||
Check(r.code == 6 && Has(r.err, "no watch or sample 'nope'"), "get of an unknown path");
|
||||
|
||||
r = Run({ "pause" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "paused at frame"), "pause");
|
||||
r = Run({ "step", "2" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "stepped 2 to frame"), "step");
|
||||
r = Run({ "resume" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "running from frame"), "resume");
|
||||
r = Run({ "wait", "--frames", "3" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "frame "), "wait --frames");
|
||||
r = Run({ "wait", "score >= 1" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "'score >= 1' held at frame"), "wait for a condition");
|
||||
r = Run({ "--timeout", "100ms", "wait", "score < 0" }, instances);
|
||||
Check(r.code == 5 && Has(r.err, "was not true within"), "a wait that times out exits 5 and says what the paths were");
|
||||
|
||||
r = Run({ "log", "--last", "5" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "AGENT"), "log shows the agent's actions");
|
||||
r = Run({ "trace", "hp", "--frames", "3" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "changes 0"), "trace");
|
||||
|
||||
r = Run({ "screenshot", "-o", "shot.png" }, instances);
|
||||
const fs::path shot = fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands" / "shot.png";
|
||||
Check(r.code == 0 && fs::exists(shot) && fs::file_size(shot) == 4 && Has(r.out, "(4 bytes)"), "screenshot writes the PNG where asked");
|
||||
|
||||
r = Run({ "--json", "status" }, instances);
|
||||
bool json = false;
|
||||
try {
|
||||
json = Value::Parse(r.out).Find("frame") != nullptr;
|
||||
} catch (const ParseError&) {
|
||||
}
|
||||
Check(r.code == 0 && json, "--json prints JSON");
|
||||
|
||||
r = Run({ "stauts" }, instances);
|
||||
Check(r.code == 2 && Has(r.err, "did you mean 'status'"), "a mistyped command name gets a suggestion");
|
||||
r = Run({ "-i", "other", "status" }, instances);
|
||||
Check(r.code == 3 && Has(r.err, "no running instance named 'other'"), "-i with no match exits 3");
|
||||
r = Run({ "status" }, {});
|
||||
Check(r.code == 3 && Has(r.err, "ls --wait"), "no instance exits 3 and says how to start one");
|
||||
|
||||
InstanceRecord newer = record;
|
||||
newer.protocol = protocolVersion + 1;
|
||||
r = Run({ "status" }, { { newer, {} } });
|
||||
Check(r.code == 4 && Has(r.err, "the app is newer"), "a newer app's protocol exits 4");
|
||||
InstanceRecord otherSources = record;
|
||||
otherSources.sourceId = "0000000000000000";
|
||||
r = Run({ "status" }, { { otherSources, {} } });
|
||||
Check(r.code == 0 && Has(r.err, "built from other Crafter.Debug sources"), "other sources only warn");
|
||||
|
||||
r = Run({ "ls" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "game id abc123"), "ls");
|
||||
r = Run({ "debugger" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "gdb -p 2"), "debugger prints the attach command");
|
||||
r = Run({ "guide" }, instances);
|
||||
Check(r.code == 0 && Has(r.out, "# crafter-debug"), "guide");
|
||||
|
||||
pumping = false;
|
||||
app.join();
|
||||
r = Run({ "--timeout", "100ms", "get", "hp" }, instances);
|
||||
Check(r.code == 5 && Has(r.err, "has not called Debug::Pump()"), std::format("a timeout explains an app that stopped pumping ({})", r.err));
|
||||
|
||||
std::error_code ignored;
|
||||
fs::remove_all(fs::temp_directory_path() / "crafter-debug-ShouldRunClientCommands", ignored);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue