Crafter.Debug/tests/ShouldRunClientCommands/main.cpp
Jorijn van der Graaf 2217037d72
feat(cli): pick the app of the project the launcher belongs to
Discovery is per user, so every project's launcher sees every debug app
running, and with two of them up every command refused to guess and
asked for -i, though the agent in one project meant its own app. Two
copies of the example, both named basic, could only be told apart by
id.

The launcher now exports CRAFTER_DEBUG_PROJECT, its project's directory
(flatpak-spawn gets it spelled out), and the client counts an app as
the project's when its executable lies under it, as `-r` runs it from
<project>/bin/<variant>/. With other projects' apps running, commands
pick this project's; two copies of it still need -i. Another project's
app is never picked by default: with this project's app not up yet, or
crashed, the agent would otherwise drive, or quit, someone else's. It
is listed instead, for -i.

`ls` lists this project's apps first, marked, `--json` says which are,
and `--wait` waits for one of them rather than any app; the exit code
says whether one runs. A client started without the launcher has no
project and picks the one app running, as before, and the command the
protocol check prints for an older app's own client now names it with
-i.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:33:59 +01:00

264 lines
13 KiB
C++

// 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;
}