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>
2026-11-01 02:03:38 +01:00
|
|
|
// 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";
|
fix(cli): print lldb by its path, and report a held app as held
Tried on the example with the app's sandbox, two things were wrong:
- `debugger` printed `flatpak enter <id> lldb -p <pid>`, and flatpak
enter runs a command without the sandbox's PATH, which /app/bin is
on: "Failed to execute child process lldb". It now prints the path
it found, /app/bin/lldb, which attached, listed threads and detached.
- With the app held by that debugger, the handshake times out, and
the client said "cannot reach basic on port ..." and exited 3, no
instance, though its lock said it lived. It now says the app is
alive but not answering, stopped in a debugger or hung, prints how
to attach, check and end it, and exits 5.
Environment::haveLldb becomes lldb, the path or empty. ShouldServeOverQuic
covers the second with an instance whose lock is held and whose port
is silent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
|
|
|
environment.lldb = [] { return std::string(); };
|
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>
2026-11-01 02:03:38 +01:00
|
|
|
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");
|
fix(cli): take global flags after the command, and call --json again
Only --json was taken after the command, so `wait --paused --timeout
60s`, the order the guide's own synopsis and agents use, failed with
"unknown argument '--timeout'", and `-i` after the command did too.
And since that --json was taken as the output flag, `call X --json
'{...}'`, the documented way to pass arguments as JSON, never reached
call: its object then failed as "not key=value".
--timeout, -i and --json now go anywhere on the line; no command has
options of those names. After `call`, a --json followed by an object
is the arguments, and any other --json the output flag. A trailing
--timeout or -i without its value is a usage error that says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
|
|
|
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");
|
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>
2026-11-01 02:03:38 +01:00
|
|
|
|
|
|
|
|
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");
|
fix(cli): take global flags after the command, and call --json again
Only --json was taken after the command, so `wait --paused --timeout
60s`, the order the guide's own synopsis and agents use, failed with
"unknown argument '--timeout'", and `-i` after the command did too.
And since that --json was taken as the output flag, `call X --json
'{...}'`, the documented way to pass arguments as JSON, never reached
call: its object then failed as "not key=value".
--timeout, -i and --json now go anywhere on the line; no command has
options of those names. After `call`, a --json followed by an object
is the arguments, and any other --json the output flag. A trailing
--timeout or -i without its value is a usage error that says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 03:21:41 +01:00
|
|
|
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));
|
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>
2026-11-01 02:03:38 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|