Crafter.Debug/tests/ShouldRunClientCommands/main.cpp

264 lines
13 KiB
C++
Raw Permalink Normal View History

// 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 (this
// project's apps first, another project's only through -i), 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;
};
// `project` as the launcher would pass it; empty, as without one.
Ran Run(std::vector<std::string_view> args, const std::vector<Instance>& instances, const fs::path& project = {}) {
std::ostringstream out;
std::ostringstream err;
Cli::Environment environment;
environment.project = project;
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.lldb = [] { return std::string(); };
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({ "wait", "score < 0", "--timeout", "100ms" }, instances);
Check(r.code == 5 && Has(r.err, "was not true within"), std::format("global flags also go after the command's own ({})", r.err));
r = Run({ "wait", "--frames", "1", "--timeout" }, instances);
Check(r.code == 2 && Has(r.err, "--timeout needs a value"), "a global flag without its value is a usage error");
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({ "status", "--json" }, instances);
Check(r.code == 0 && r.out.starts_with("{"), "--json after the command too");
r = Run({ "call", "Player_SetAim", "--json", R"({"slot": 4, "yaw": 5})" }, instances);
Check(r.code == 0 && Has(r.out, R"({"slot":4,"yaw":5.0})"), std::format("call takes its arguments as JSON ({})", r.out + r.err));
r = Run({ "call", "Player_SetAim", "--json", R"({"slot": 4})", "yaw=7", "--json" }, instances);
Check(r.code == 0 && Has(r.out, R"({"slot":4,"yaw":7.0})"), std::format("JSON arguments with key=value on top, and --json after them ({})", r.out + r.err));
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");
// Apps of two projects at once. `debugger` prints the pid of the record
// it picked, which is how these tell which one that was.
const fs::path here = "/projects/game";
InstanceRecord mine = record;
mine.instanceId = "aaa111";
mine.pid = 11;
mine.exe = (here / "bin" / "variant" / "game").string();
InstanceRecord copy = mine;
copy.instanceId = "aaa222";
copy.pid = 12;
InstanceRecord theirs = record;
theirs.name = "tool";
theirs.instanceId = "bbb111";
theirs.pid = 21;
theirs.exe = "/projects/game-tools/bin/variant/tool";
r = Run({ "debugger" }, { { theirs, {} }, { mine, {} } }, here);
Check(r.code == 0 && Has(r.out, "-p 11"), std::format("with another project's app running too, this project's is picked ({})", r.out + r.err));
r = Run({ "debugger" }, { { theirs, {} } }, here);
Check(r.code == 3 && Has(r.err, "nothing from this project") && Has(r.err, "bbb111"),
std::format("another project's app alone is not picked, but listed for -i ({})", r.err));
r = Run({ "-i", "tool", "debugger" }, { { theirs, {} }, { mine, {} } }, here);
Check(r.code == 0 && Has(r.out, "-p 21"), "-i picks another project's app");
r = Run({ "debugger" }, { { mine, {} }, { copy, {} } }, here);
Check(r.code == 2 && Has(r.err, "several instances"), "two copies of this project's app still need -i");
r = Run({ "-i", "aaa2", "debugger" }, { { mine, {} }, { copy, {} } }, here);
Check(r.code == 0 && Has(r.out, "-p 12"), "a start of the id is enough");
r = Run({ "debugger" }, { { theirs, {} } });
Check(r.code == 0 && Has(r.out, "-p 21"), "without a launcher's project, the one app running is picked as before");
r = Run({ "ls" }, { { theirs, {} }, { mine, {} } }, here);
Check(r.code == 0 && r.out.starts_with("game id aaa111") && Has(r.out, "(this project)") && Has(r.out, "tool id bbb111"),
std::format("ls lists this project's apps first, marked, then the others ({})", r.out));
const auto waited = std::chrono::steady_clock::now();
r = Run({ "ls", "--wait", "300ms" }, { { theirs, {} } }, here);
Check(r.code == 3 && std::chrono::steady_clock::now() - waited >= 300ms && Has(r.out, "tool id bbb111") && Has(r.err, "none of these is from this project"),
std::format("ls --wait waits for this project's app, not any ({})", r.err));
r = Run({ "--json", "ls" }, { { theirs, {} }, { mine, {} } }, here);
Check(r.code == 0 && Has(r.out, R"("thisProject":true)") && Has(r.out, R"("thisProject":false)"), "ls --json says which apps are this project's");
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;
}