// 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 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 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 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 Wait(std::chrono::milliseconds timeout) { std::unique_lock lock(mutex); ready.wait_for(lock, timeout, [&] { return final.has_value(); }); return final; } private: std::function onLine; std::mutex mutex; std::condition_variable ready; std::optional final; }; class InProcess : public Cli::Connection { public: std::expected Call(const Value& request, std::chrono::milliseconds timeout, const std::function& onLine) override { auto exchange = std::make_shared(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 args, const std::vector& instances) { std::ostringstream out; std::ostringstream err; Cli::Environment environment; environment.connect = [](const InstanceRecord&) -> std::unique_ptr { return std::make_unique(); }; 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::optional> { return std::vector{ 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 instances = { { record, {} } }; std::atomic 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"); 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; }