diff --git a/implementations/Crafter.Debug.Cli-Client.cpp b/implementations/Crafter.Debug.Cli-Client.cpp new file mode 100644 index 0000000..af2100f --- /dev/null +++ b/implementations/Crafter.Debug.Cli-Client.cpp @@ -0,0 +1,1023 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Crafter.Debug.Cli:Client_impl; +import std; +import Crafter.Debug.Core; +import :Client; + +using namespace Crafter::Debug; +using namespace Crafter::Debug::Cli; +namespace fs = std::filesystem; +using namespace std::chrono_literals; + +namespace { + constexpr std::string_view usage = R"(usage: crafter-debug [-i ] [--json] [--timeout <30s>] ... + + ls [--wait 60s] running instances + status frame, fps, pause state, failing checks + cmds [glob] the app's commands + help arguments, types and defaults + call [k=v ...] call a command (or --json '{...}') + get ... read watches and samples + set write a watch, or one member of it + pause [--when ] [--on-check] [--trap] [--clear] + resume + step [n] + wait | --frames | --event | --paused + trace ... [--frames 60] [--history] + log [--last 50] [--level warn] [--cat ] [--grep ] [--since-frame ] [--follow [--for 30m]] + screenshot [-o ] + quit [--force] + debugger how to attach lldb or gdb + guide the full reference +)"; + + constexpr std::array commandNames = { + "ls", "status", "cmds", "help", "call", "get", "set", "pause", "resume", + "step", "wait", "trace", "log", "screenshot", "quit", "debugger", "guide", "version", + }; + + struct Options { + std::string instance; + bool json = false; + std::chrono::milliseconds timeout = 30s; + std::string command; + std::vector rest; + }; + + Value Object(std::initializer_list> members) { + Value object = Value::MakeObject(); + for (const auto& [key, value] : members) object.Set(key, value); + return object; + } + + std::string Short(std::string_view name) { + const std::size_t colon = name.rfind("::"); + return std::string(colon == std::string_view::npos ? name : name.substr(colon + 2)); + } + + std::string FileName(std::string_view path) { + const std::size_t slash = path.rfind('/'); + return std::string(slash == std::string_view::npos ? path : path.substr(slash + 1)); + } + + // A value as an agent reads it in a terminal: arrays cut to 20 items + // with a note saying how many more, deeply; --json shows everything. + Value Truncated(const Value& value) { + if (value.IsArray()) { + Value cut = Value::MakeArray(); + const auto items = value.Items(); + for (std::size_t i = 0; i < items.size() && i < 20; ++i) cut.Push(Truncated(items[i])); + if (items.size() > 20) cut.Push(Value(std::format("... {} more (--json for all)", items.size() - 20))); + return cut; + } + if (value.IsObject()) { + Value cut = Value::MakeObject(); + for (const auto& [key, member] : value.Members()) cut.Set(key, Truncated(member)); + return cut; + } + return value; + } + + std::string Display(const Value& value) { + if (value.IsString()) return value.AsString(); + return Truncated(value).Dump(); + } + + std::string TypeText(const Value& schema) { + const Value* type = schema.Find("type"); + if (type == nullptr || !type->IsString()) return "?"; + const std::string& kind = type->AsString(); + std::string text; + if (kind == "integer") { + text = "integer"; + const Value* low = schema.Find("minimum"); + const Value* high = schema.Find("maximum"); + if (low != nullptr && high != nullptr) text += std::format(" {}..{}", low->Dump(), high->Dump()); + } else if (kind == "enum") { + for (const Value& name : schema.Find("values")->Items()) text += text.empty() ? name.AsString() : "|" + name.AsString(); + } else if (kind == "array") { + text = std::format("array of {}", schema.Find("items") ? TypeText(*schema.Find("items")) : "?"); + if (const Value* length = schema.Find("length")) text += std::format(" [{}]", length->Dump()); + } else if (kind == "object") { + if (const Value* name = schema.Find("name")) { + text = Short(name->AsString()); + } else if (const Value* values = schema.Find("values")) { + text = std::format("map of {}", TypeText(*values)); + } else { + text = "object"; + } + } else if (kind == "any") { + text = "json"; + } else { + text = kind; + } + if (schema.Find("nullable") != nullptr) text += "?"; + return text; + } + + // "slot: integer 0..255 (required), yaw: number = 10" + std::string ArgsText(const Value& args) { + const Value* fields = args.Find("fields"); + if (fields == nullptr) return args.IsNull() ? std::string() : TypeText(args); + std::string text; + for (const Value& field : fields->Items()) { + if (!text.empty()) text += ", "; + text += std::format("{}: {}", field.Find("name")->AsString(), TypeText(*field.Find("type"))); + if (field.Find("required") != nullptr) { + text += " (required)"; + } else if (const Value* fallback = field.Find("default")) { + text += std::format(" = {}", fallback->Dump()); + } + } + return text; + } + + // A k=v value typed by the field's schema: numbers and booleans parsed, + // strings and enum names taken as written, anything else read as JSON. + std::expected Coerce(std::string_view raw, const Value* schema) { + const auto json = [&]() -> std::optional { + try { + return Value::Parse(raw); + } catch (const ParseError&) { + return std::nullopt; + } + }; + const Value* type = schema != nullptr ? schema->Find("type") : nullptr; + const std::string kind = type != nullptr && type->IsString() ? type->AsString() : std::string(); + if (kind == "string" || kind == "enum") return Value(raw); + if (kind == "boolean") { + if (raw == "true" || raw == "1" || raw == "yes") return Value(true); + if (raw == "false" || raw == "0" || raw == "no") return Value(false); + return std::unexpected(std::format("'{}' is not a boolean (true or false)", raw)); + } + if (kind == "integer" || kind == "number") { + const auto parsed = json(); + if (parsed && parsed->IsNumber()) return *parsed; + return std::unexpected(std::format("'{}' is not a number", raw)); + } + if (raw == "null" && schema != nullptr && schema->Find("nullable") != nullptr) return Value(); + if (const auto parsed = json()) return *parsed; + if (kind == "array" || kind == "object") return std::unexpected(std::format("'{}' is not JSON; an {} is written as JSON", raw, kind)); + return Value(raw); + } + + const Value* FieldSchema(const Value* objectSchema, std::string_view name) { + if (objectSchema == nullptr) return nullptr; + const Value* fields = objectSchema->Find("fields"); + if (fields == nullptr) return nullptr; + for (const Value& field : fields->Items()) { + if (field.Find("name")->AsString() == name) return field.Find("type"); + } + return nullptr; + } + + // key=value pairs into an object; dotted keys nest ("at.x=1"). + std::expected BuildParams(std::span pairs, const Value* schema) { + Value params = Value::MakeObject(); + for (const std::string& pair : pairs) { + const std::size_t equals = pair.find('='); + if (equals == std::string::npos || equals == 0) return std::unexpected(std::format("'{}' is not key=value; values are passed as key=value, or all at once with --json '{{...}}'", pair)); + const std::string_view key(pair.data(), equals); + const std::string_view raw = std::string_view(pair).substr(equals + 1); + Value* target = ¶ms; + const Value* targetSchema = schema; + std::string_view rest = key; + while (true) { + const std::size_t dot = rest.find('.'); + const std::string_view name = rest.substr(0, dot); + const Value* fieldSchema = FieldSchema(targetSchema, name); + if (dot == std::string_view::npos) { + auto value = Coerce(raw, fieldSchema); + if (!value) return std::unexpected(std::format("{}: {}", key, value.error())); + target->Set(name, std::move(*value)); + break; + } + Value* nested = target->Find(name); + if (nested == nullptr) nested = &target->Set(name, Value::MakeObject()); + target = nested; + targetSchema = fieldSchema; + rest = rest.substr(dot + 1); + } + } + return params; + } + + // A value given on the command line: JSON when it parses, else a string. + Value Literal(std::string_view raw) { + try { + return Value::Parse(raw); + } catch (const ParseError&) { + return Value(raw); + } + } + + std::string Age(std::int64_t startedAt) { + const std::int64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + const std::int64_t seconds = std::max(0, now - startedAt); + if (seconds < 120) return std::format("{}s", seconds); + if (seconds < 7200) return std::format("{}m", seconds / 60); + return std::format("{}h", seconds / 3600); + } + + std::optional Number(std::string_view text) { + std::uint64_t number = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), number); + if (error != std::errc() || end != text.data() + text.size()) return std::nullopt; + return number; + } + + class Client { + public: + Client(Environment& environment, Options options) : env(environment), opts(std::move(options)) {} + + std::int32_t Execute() { + const std::string& c = opts.command; + if (c == "guide") { + Out() << Guide(); + return exitOk; + } + if (c == "version") { + Out() << std::format("crafter-debug {}, protocol {}, sources {}\n", libraryVersion, protocolVersion, Detail::SourceId()); + return exitOk; + } + if (c == "ls") return Ls(); + if (const std::int32_t code = Select(); code != exitOk) return code; + if (c == "debugger") return Debugger(); + if (c == "status") return Status(); + if (c == "cmds") return Cmds(); + if (c == "help") return Help(); + if (c == "call") return Call(); + if (c == "get") return Get(); + if (c == "set") return Set(); + if (c == "pause") return Pause(); + if (c == "resume") return Resume(); + if (c == "step") return Step(); + if (c == "wait") return Wait(); + if (c == "trace") return Trace(); + if (c == "log") return Log(); + if (c == "screenshot") return Screenshot(); + if (c == "quit") return Quit(); + return exitUsage; + } + + private: + Environment& env; + Options opts; + std::optional instance; + std::unique_ptr connection; + + std::ostream& Out() { return *env.out; } + std::ostream& Err() { return *env.err; } + + std::int32_t Usage(std::string_view problem) { + Err() << std::format("crafter-debug {}: {}\n", opts.command, problem); + return exitUsage; + } + + // ---- choosing the instance ---- + + std::int32_t Select() { + std::vector live = env.list(); + if (!opts.instance.empty()) { + std::erase_if(live, [&](const Instance& candidate) { + return candidate.record.name != opts.instance && !candidate.record.instanceId.starts_with(opts.instance); + }); + } + if (live.empty()) { + if (!opts.instance.empty()) { + std::string running; + for (const Instance& other : env.list()) running += std::format("{}{} ({})", running.empty() ? "" : ", ", other.record.name, other.record.instanceId); + Err() << std::format("crafter-debug: no running instance named '{}'{}\n", opts.instance, running.empty() ? std::string() : std::format("; running: {}", running)); + } else { + Err() << "crafter-debug: no running instance. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r), then: crafter-debug ls --wait 60s\n"; + } + return exitNoInstance; + } + if (live.size() > 1) { + Err() << "crafter-debug: several instances run; pick one with -i :\n"; + for (const Instance& candidate : live) Err() << std::format(" {} id {} up {}\n", candidate.record.name, candidate.record.instanceId, Age(candidate.record.startedAt)); + return exitUsage; + } + instance = live.front(); + const InstanceRecord& record = instance->record; + if (record.protocol != protocolVersion) { + const bool appNewer = record.protocol > protocolVersion; + Err() << std::format("crafter-debug: {} speaks protocol {} and this client {}: the {} is newer.\n", record.name, record.protocol, protocolVersion, appNewer ? "app" : "client"); + if (appNewer) { + Err() << " This launcher points at an older build. Rebuild with the project's debug flag, or run the app's own client:\n"; + } else { + Err() << " Restart the app from the current build (crafter-build -r), or talk to it with its own client:\n"; + } + Err() << std::format(" flatpak run --die-with-parent --command={} net.catcrafts.CrafterBuild {}\n", record.client, opts.command); + return exitVersion; + } + if (!record.sourceId.empty() && record.sourceId != Detail::SourceId()) { + Err() << std::format("note: {} was built from other Crafter.Debug sources ({} here, {} there); restart it from the current build if something misbehaves\n", + record.name, Detail::SourceId(), record.sourceId); + } + connection = env.connect(record); + return exitOk; + } + + // ---- talking to it ---- + + // The result, or the exit code once the problem has been reported. + std::expected Request(std::string_view method, Value params, std::chrono::milliseconds timeout, + const std::function& onLine = {}) { + Value request = Object({ + { "token", Value(instance->record.token) }, + { "method", Value(method) }, + { "params", std::move(params) }, + { "timeoutMs", Value(static_cast(timeout.count())) }, + }); + if (!connection) return std::unexpected(Unreachable("no connection")); + auto reply = connection->Call(request, timeout + 2s, onLine); + if (!reply) { + if (reply.error().kind == CallError::Kind::TimedOut) return std::unexpected(TimedOut(method, timeout)); + if (reply.error().kind == CallError::Kind::Broken) { + Err() << std::format("crafter-debug: a malformed reply: {}\n", reply.error().message); + return std::unexpected(exitError); + } + return std::unexpected(Unreachable(reply.error().message)); + } + if (const Value* error = reply->Find("error")) { + const std::string code = error->Find("code") ? error->Find("code")->AsString() : std::string("Error"); + const std::string message = error->Find("message") ? error->Find("message")->AsString() : std::string(); + if (opts.json) { + Err() << reply->Dump() << '\n'; + } else { + Err() << std::format("error {}: {}\n", code, message); + } + return std::unexpected(code == Codes::timeout ? exitTimeout : exitError); + } + const Value* result = reply->Find("result"); + return result != nullptr ? *result : Value(); + } + + std::int32_t Unreachable(std::string_view why) { + const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; }); + if (!alive) { + Err() << std::format("crafter-debug: {} has exited\n", instance->record.name); + } else { + Err() << std::format("crafter-debug: cannot reach {} on port {}: {}\n", instance->record.name, instance->record.port, why); + } + return exitNoInstance; + } + + // A request that got no reply. The app may be gone, stopped in a + // debugger, or no longer calling Pump; a short status call tells + // which, and what to do next. + std::int32_t TimedOut(std::string_view method, std::chrono::milliseconds timeout) { + const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; }); + if (!alive) { + Err() << std::format("crafter-debug: {} exited while {} waited\n", instance->record.name, method); + return exitNoInstance; + } + const auto probe = env.connect(instance->record); + const Value status = Object({ { "token", Value(instance->record.token) }, { "method", Value("debug.status") }, { "timeoutMs", Value(1000) } }); + const auto reply = probe ? probe->Call(status, 2s, {}) : std::unexpected(CallError{}); + Err() << std::format("crafter-debug: {} got no reply within {} ms. ", method, timeout.count()); + if (!reply || reply->Find("result") == nullptr) { + Err() << std::format("{} is alive but not answering at all: stopped in a debugger, or hung.\n", instance->record.name); + PrintAttach(true); + return exitTimeout; + } + const Value* last = reply->Find("result")->Find("lastPumpAgoMs"); + if (last != nullptr && last->IsInteger() && last->AsInteger() > 1000) { + Err() << std::format("{} answers, but has not called Debug::Pump() for {} ms: its main loop is stalled, or its window hidden " + "(set Window::updateInterval to keep it pumping).\n", + instance->record.name, last->AsInteger()); + } else { + Err() << "The app is pumping; the request took longer than its timeout (raise it with --timeout).\n"; + } + return exitTimeout; + } + + void PrintAttach(bool toErr) { + std::ostream& stream = toErr ? Err() : Out(); + const InstanceRecord& record = instance->record; + const std::string debugger = env.haveLldb && env.haveLldb() ? "lldb" : "gdb"; + if (!record.flatpakInstance.empty()) { + stream << std::format(" attach (on the host): flatpak enter {} {} -p {}\n", record.flatpakInstance, debugger, record.pid); + stream << std::format(" check whether a debugger holds it: flatpak enter {} grep State /proc/{}/status (t = stopped by a tracer)\n", record.flatpakInstance, record.pid); + if (record.hostInitPid > 0) stream << std::format(" end it, sandbox and all (on the host): kill -KILL {}\n", record.hostInitPid); + } else { + stream << std::format(" attach: {} -p {}\n", debugger, record.pid); + stream << std::format(" check whether a debugger holds it: grep State /proc/{}/status (t = stopped by a tracer)\n", record.pid); + } + } + + std::int32_t Print(const Value& result) { + if (opts.json) { + Out() << result.Dump() << '\n'; + return exitOk; + } + Out() << (result.IsNull() ? std::string("ok") : Display(result)) << '\n'; + return exitOk; + } + + // ---- commands ---- + + std::int32_t Ls() { + std::optional wait; + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + if (opts.rest[i] == "--wait" && i + 1 < opts.rest.size()) { + wait = ParseDuration(opts.rest[++i]); + if (!wait) return Usage(std::format("'{}' is not a duration (30s, 500ms, 2m)", opts.rest[i])); + } else { + return Usage(std::format("unknown argument '{}'", opts.rest[i])); + } + } + const auto deadline = std::chrono::steady_clock::now() + wait.value_or(0ms); + std::vector live = env.list(); + while (live.empty() && wait && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(200ms); + live = env.list(); + } + if (opts.json) { + Value list = Value::MakeArray(); + for (const Instance& entry : live) { + Value record = Encode(entry.record); + record.Erase("token"); + list.Push(std::move(record)); + } + Out() << list.Dump() << '\n'; + return live.empty() ? exitNoInstance : exitOk; + } + if (live.empty()) { + Err() << "no running instances. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r)" + << (wait ? ", and wait longer if it is still building\n" : ", then: crafter-debug ls --wait 60s\n"); + return exitNoInstance; + } + for (const Instance& entry : live) { + const InstanceRecord& r = entry.record; + Out() << std::format("{} id {} pid {}{} up {} port {}{}\n", r.name, r.instanceId, r.pid, + r.hostInitPid > 0 ? std::format(" (sandbox {})", r.hostInitPid) : std::string(), Age(r.startedAt), r.port, + r.protocol != protocolVersion ? std::format(" protocol {} (this client {})", r.protocol, protocolVersion) : std::string()); + } + return exitOk; + } + + std::int32_t Debugger() { + Out() << std::format("{}: the app stops while a debugger holds it, and crafter-debug calls then report it as not answering.\n", instance->record.name); + PrintAttach(false); + return exitOk; + } + + std::int32_t Status() { + if (!opts.rest.empty()) return Usage("takes no arguments"); + const auto result = Request("debug.status", Value(), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + const Value& s = *result; + const bool paused = s.Find("paused")->AsBool(); + const std::string reason = s.Find("pauseReason") ? s.Find("pauseReason")->AsString() : std::string(); + Out() << std::format("{} frame {} {:.1f} fps {}\n", s.Find("name")->AsString(), s.Find("frame")->Dump(), s.Find("fps")->AsNumber(), + paused ? std::format("PAUSED ({})", reason) : std::string("running")); + std::string armed; + if (const Value* when = s.Find("armed")->Find("when")) armed += std::format(" pause armed: when '{}'", when->AsString()); + if (s.Find("armed")->Find("onCheck") != nullptr) armed += " pause armed: on the next failing check"; + Out() << std::format("pause: {}{}\n", s.Find("pauseSupported")->AsBool() ? "supported" : "unsupported (the app never calls Debug::Advance or Debug::Paused)", armed); + const std::int64_t failures = s.Find("checkFailures")->AsInteger(); + if (failures > 0) { + std::string sites; + for (const Value& site : s.Find("checkSites")->Items()) { + sites += std::format("{}{}:{} x{} ({})", sites.empty() ? "" : ", ", site.Find("file")->AsString(), site.Find("line")->Dump(), site.Find("failures")->Dump(), site.Find("category")->AsString()); + } + Out() << std::format("checks: {} failures: {}\n", failures, sites); + } + Out() << std::format("{} commands, {} watches; requests: {} queued, {} waiting, {} following\n", s.Find("commands")->Dump(), s.Find("watches")->Dump(), + s.Find("queued")->Dump(), s.Find("waiting")->Dump(), s.Find("following")->Dump()); + const Value* last = s.Find("lastPumpAgoMs"); + Out() << (last == nullptr || last->IsNull() ? std::string("Pump: never called: the app does not call Debug::Pump(), so calls, gets and waits will not run\n") + : std::format("last Pump {} ms ago\n", last->Dump())); + return exitOk; + } + + std::int32_t Cmds() { + if (opts.rest.size() > 1) return Usage("takes at most one glob"); + const std::string glob = opts.rest.empty() ? "*" : opts.rest[0]; + const auto result = Request("debug.list", Object({ { "glob", Value(glob) } }), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result->Find("commands")); + const auto commands = result->Find("commands")->Items(); + if (commands.empty()) { + Out() << std::format("no commands match '{}'\n", glob); + return exitOk; + } + for (const Value& command : commands) { + const std::string returns = command.Find("returns")->IsNull() ? std::string() : std::format(" -> {}", TypeText(*command.Find("returns"))); + const std::string description = command.Find("description")->AsString(); + Out() << std::format("{}({}){}{}\n", command.Find("name")->AsString(), ArgsText(*command.Find("args")), returns, + description.empty() ? std::string() : std::format(" -- {}", description)); + } + return exitOk; + } + + std::int32_t Help() { + if (opts.rest.size() != 1) return Usage("names one command or watch: crafter-debug help "); + const std::string& name = opts.rest[0]; + const auto result = Request("debug.list", Object({ { "glob", Value("*") } }), opts.timeout); + if (!result) return result.error(); + for (const Value& command : result->Find("commands")->Items()) { + if (command.Find("name")->AsString() != name) continue; + if (opts.json) return Print(command); + Out() << std::format("{} -- {}\n", name, command.Find("description")->AsString()); + const Value& args = *command.Find("args"); + if (const Value* fields = args.Find("fields")) { + for (const Value& field : fields->Items()) { + const std::string detail = field.Find("required") != nullptr ? "required" : std::format("default {}", field.Find("default")->Dump()); + Out() << std::format(" {:<16} {:<24} {}\n", field.Find("name")->AsString(), TypeText(*field.Find("type")), detail); + } + } else if (!args.IsNull()) { + Out() << std::format(" takes {}\n", TypeText(args)); + } else { + Out() << " takes no arguments\n"; + } + if (!command.Find("returns")->IsNull()) Out() << std::format("returns {}\n", TypeText(*command.Find("returns"))); + Out() << std::format("call: crafter-debug call {}{}\n", name, args.Find("fields") ? " key=value ..." : ""); + return exitOk; + } + for (const Value& watch : result->Find("watches")->Items()) { + if (watch.Find("path")->AsString() != name) continue; + if (opts.json) return Print(watch); + Out() << std::format("{} {}{} {}\n", name, TypeText(*watch.Find("type")), watch.Find("readOnly")->AsBool() ? " read-only" : "", watch.Find("help")->AsString()); + return exitOk; + } + std::vector names; + for (const Value& command : result->Find("commands")->Items()) names.push_back(command.Find("name")->AsString()); + for (const Value& watch : result->Find("watches")->Items()) names.push_back(watch.Find("path")->AsString()); + std::vector views(names.begin(), names.end()); + const auto suggestion = Suggest(name, views); + Err() << std::format("crafter-debug: no command or watch '{}'{}\n", name, suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string("; crafter-debug cmds lists them")); + return exitError; + } + + std::int32_t Call() { + if (opts.rest.empty()) return Usage("names a command: crafter-debug call [key=value ...]"); + const std::string command = opts.rest[0]; + std::vector pairs; + std::optional json; + for (std::size_t i = 1; i < opts.rest.size(); ++i) { + if (opts.rest[i] == "--json" && i + 1 < opts.rest.size()) { + try { + json = Value::Parse(opts.rest[++i]); + } catch (const ParseError& error) { + return Usage(std::format("--json: {}", error.what())); + } + } else { + pairs.push_back(opts.rest[i]); + } + } + Value params = json.value_or(Value()); + if (!pairs.empty()) { + // The argument schema types each value: slot=1 is a number, + // name=1 a string, if that is what the fields are. + const auto listed = Request("debug.list", Object({ { "glob", Value(command) } }), opts.timeout); + if (!listed) return listed.error(); + const Value* schema = nullptr; + for (const Value& entry : listed->Find("commands")->Items()) { + if (entry.Find("name")->AsString() == command) schema = entry.Find("args"); + } + auto built = BuildParams(pairs, schema); + if (!built) return Usage(built.error()); + if (json && json->IsObject()) { + for (const auto& [key, value] : built->Members()) json->Set(key, value); + params = *json; + } else { + params = std::move(*built); + } + } + const auto result = Request(command, std::move(params), opts.timeout); + if (!result) return result.error(); + return Print(*result); + } + + std::int32_t Get() { + if (opts.rest.empty()) return Usage("names one or more paths or globs: crafter-debug get 'fort1.*'"); + Value paths = Value::MakeArray(); + for (const std::string& path : opts.rest) paths.Push(Value(path)); + const auto result = Request("debug.get", Object({ { "paths", std::move(paths) } }), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + for (const auto& [path, value] : result->Find("values")->Members()) Out() << std::format("{} = {}\n", path, Display(value)); + if (const Value* errors = result->Find("errors")) { + for (const auto& [path, error] : errors->Members()) Err() << std::format("{}: {}\n", path, error.AsString()); + } + return exitOk; + } + + std::int32_t Set() { + if (opts.rest.size() != 2) return Usage("takes a path and a value: crafter-debug set physics.gravity -9.81"); + const auto result = Request("debug.set", Object({ { "path", Value(opts.rest[0]) }, { "value", Literal(opts.rest[1]) } }), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + Out() << std::format("{} = {}\n", result->Find("path")->AsString(), Display(*result->Find("value"))); + return exitOk; + } + + std::int32_t Pause() { + Value params = Value::MakeObject(); + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + const std::string& arg = opts.rest[i]; + if (arg == "--when" && i + 1 < opts.rest.size()) { + params.Set("when", Value(opts.rest[++i])); + } else if (arg == "--on-check") { + params.Set("onCheck", Value(true)); + } else if (arg == "--trap") { + params.Set("trap", Value(true)); + } else if (arg == "--clear") { + params.Set("clear", Value(true)); + } else { + return Usage(std::format("unknown argument '{}'", arg)); + } + } + const auto result = Request("debug.pause", std::move(params), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + if (const Value* armed = result->Find("armed")) { + if (armed->IsBool()) { + Out() << "disarmed\n"; + } else if (armed->AsString() == "when") { + Out() << std::format("armed: pause when '{}'; then: crafter-debug wait --paused\n", result->Find("condition")->AsString()); + } else { + Out() << "armed: pause on the next failing Debug::Check; then: crafter-debug wait --paused\n"; + } + return exitOk; + } + Out() << std::format("paused at frame {}\n", result->Find("frame")->Dump()); + return exitOk; + } + + std::int32_t Resume() { + if (!opts.rest.empty()) return Usage("takes no arguments"); + const auto result = Request("debug.resume", Value(), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + Out() << std::format("running from frame {}\n", result->Find("frame")->Dump()); + return exitOk; + } + + std::int32_t Step() { + std::uint64_t count = 1; + if (opts.rest.size() > 1) return Usage("takes at most a count: crafter-debug step 3"); + if (opts.rest.size() == 1) { + const auto parsed = Number(opts.rest[0]); + if (!parsed || *parsed == 0) return Usage(std::format("'{}' is not a positive count", opts.rest[0])); + count = *parsed; + } + const auto result = Request("debug.step", Object({ { "count", Value(count) } }), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + Out() << std::format("stepped {} to frame {}, paused\n", count, result->Find("frame")->Dump()); + return exitOk; + } + + std::int32_t Wait() { + Value params = Value::MakeObject(); + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + const std::string& arg = opts.rest[i]; + if (arg == "--frames" && i + 1 < opts.rest.size()) { + const auto frames = Number(opts.rest[++i]); + if (!frames) return Usage(std::format("'{}' is not a number of frames", opts.rest[i])); + params.Set("frames", Value(*frames)); + } else if (arg == "--event" && i + 1 < opts.rest.size()) { + params.Set("event", Value(opts.rest[++i])); + } else if (arg == "--paused") { + params.Set("paused", Value(true)); + } else if (!arg.starts_with("--") && params.Find("condition") == nullptr) { + params.Set("condition", Value(arg)); + } else { + return Usage(std::format("unknown argument '{}'", arg)); + } + } + if (params.Size() == 0) return Usage("waits for something: a condition, --frames , --event or --paused"); + const auto result = Request("debug.wait", std::move(params), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + const Value& r = *result; + if (const Value* condition = r.Find("condition")) { + Out() << std::format("'{}' held at frame {}\n", condition->AsString(), r.Find("frame")->Dump()); + } else if (const Value* event = r.Find("event")) { + Out() << std::format("{} at frame {}: {}\n", event->AsString(), r.Find("frame")->Dump(), Display(*r.Find("payload"))); + } else if (const Value* reason = r.Find("reason")) { + Out() << std::format("paused at frame {}: {}\n", r.Find("frame")->Dump(), reason->AsString()); + } else { + Out() << std::format("frame {}\n", r.Find("frame")->Dump()); + } + return exitOk; + } + + std::int32_t Trace() { + Value paths = Value::MakeArray(); + Value params = Value::MakeObject(); + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + const std::string& arg = opts.rest[i]; + if (arg == "--frames" && i + 1 < opts.rest.size()) { + const auto frames = Number(opts.rest[++i]); + if (!frames || *frames == 0) return Usage(std::format("'{}' is not a positive number of frames", opts.rest[i])); + params.Set("frames", Value(*frames)); + } else if (arg == "--history") { + params.Set("history", Value(true)); + } else if (arg.starts_with("--")) { + return Usage(std::format("unknown argument '{}'", arg)); + } else { + paths.Push(Value(arg)); + } + } + if (paths.Size() == 0) return Usage("names one or more paths: crafter-debug trace score --frames 120"); + params.Set("paths", std::move(paths)); + const auto result = Request("debug.trace", std::move(params), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + const Value* frames = result->Find("frames"); + for (const auto& [path, series] : result->Find("series")->Members()) { + const Value& framesOf = series.Find("frames") ? *series.Find("frames") : *frames; + const auto values = series.Find("values")->Items(); + Out() << std::format("{} {} values, frames {}..{}\n", path, values.size(), framesOf.Items().front().Dump(), framesOf.Items().back().Dump()); + Out() << std::format(" {}\n", Display(*series.Find("values"))); + const Value& stats = *series.Find("stats"); + std::string line; + for (const std::string_view key : { "min", "max", "mean" }) { + if (const Value* number = stats.Find(key)) line += std::format("{} {} ", key, number->Dump()); + } + line += std::format("changes {}", stats.Find("changes")->Dump()); + if (const Value* first = stats.Find("firstChangeFrame")) line += std::format(" (first at frame {})", first->Dump()); + Out() << std::format(" {}\n", line); + } + if (result->Find("truncated") != nullptr) Out() << "(cut short by the timeout; raise --timeout for more frames)\n"; + return exitOk; + } + + void PrintEntry(const Value& entry) { + if (opts.json) { + Out() << entry.Dump() << '\n'; + return; + } + std::string kind = entry.Find("kind")->AsString(); + std::ranges::transform(kind, kind.begin(), [](char c) { return static_cast(c >= 'a' && c <= 'z' ? c - 32 : c); }); + std::string text = entry.Find("message")->AsString(); + if (const Value* payload = entry.Find("payload")) text = Display(*payload); + if (const Value* count = entry.Find("count"); count != nullptr && count->AsInteger() > 1) text += std::format(" (x{})", count->Dump()); + const std::string where = entry.Find("kind")->AsString() == "agent" ? std::string() : std::format(" {}:{}", FileName(entry.Find("file")->AsString()), entry.Find("line")->Dump()); + Out() << std::format("{:>7} {:<6} {:<10} {}{}\n", entry.Find("frame")->Dump(), kind, entry.Find("category")->AsString(), text, where) << std::flush; + } + + std::int32_t Log() { + Value params = Value::MakeObject(); + std::chrono::milliseconds timeout = opts.timeout; + bool follow = false; + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + const std::string& arg = opts.rest[i]; + const bool hasValue = i + 1 < opts.rest.size(); + if (arg == "--last" && hasValue) { + const auto last = Number(opts.rest[++i]); + if (!last) return Usage(std::format("'{}' is not a number", opts.rest[i])); + params.Set("last", Value(*last)); + } else if (arg == "--level" && hasValue) { + params.Set("level", Value(opts.rest[++i])); + } else if (arg == "--cat" && hasValue) { + params.Set("category", Value(opts.rest[++i])); + } else if (arg == "--grep" && hasValue) { + params.Set("grep", Value(opts.rest[++i])); + } else if (arg == "--since-frame" && hasValue) { + const auto frame = Number(opts.rest[++i]); + if (!frame) return Usage(std::format("'{}' is not a frame number", opts.rest[i])); + params.Set("sinceFrame", Value(*frame)); + } else if (arg == "--kinds" && hasValue) { + Value kinds = Value::MakeArray(); + for (const auto part : std::views::split(std::string_view(opts.rest[++i]), ',')) kinds.Push(Value(std::string_view(std::ranges::data(part), std::ranges::size(part)))); + params.Set("kinds", std::move(kinds)); + } else if (arg == "--follow") { + follow = true; + params.Set("follow", Value(true)); + } else if (arg == "--for" && hasValue) { + const auto duration = ParseDuration(opts.rest[++i]); + if (!duration) return Usage(std::format("'{}' is not a duration", opts.rest[i])); + params.Set("forMs", Value(static_cast(duration->count()))); + } else { + return Usage(std::format("unknown argument '{}'", arg)); + } + } + if (follow) { + const Value* forMs = params.Find("forMs"); + timeout = std::chrono::milliseconds(forMs != nullptr ? forMs->AsInteger() : 30 * 60 * 1000) + 5s; + const auto result = Request("debug.log", std::move(params), timeout, [&](const Value& line) { + if (const Value* entry = line.Find("entry")) PrintEntry(*entry); + return true; + }); + if (!result) return result.error(); + if (!opts.json) Err() << "(follow ended)\n"; + return exitOk; + } + const auto result = Request("debug.log", std::move(params), timeout); + if (!result) return result.error(); + for (const Value& entry : result->Find("entries")->Items()) PrintEntry(entry); + return exitOk; + } + + std::int32_t Screenshot() { + fs::path file; + for (std::size_t i = 0; i < opts.rest.size(); ++i) { + if ((opts.rest[i] == "-o" || opts.rest[i] == "--output") && i + 1 < opts.rest.size()) { + file = opts.rest[++i]; + } else { + return Usage(std::format("unknown argument '{}'", opts.rest[i])); + } + } + const auto result = Request("debug.screenshot", Value(), opts.timeout); + if (!result) return result.error(); + const auto png = Base64Decode(result->Find("png")->AsString()); + if (!png) { + Err() << "crafter-debug: the screenshot was not valid base64\n"; + return exitError; + } + if (file.empty()) { + const std::int64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + file = fs::path("build") / "crafter-debug-shots" / std::format("{}-{}.png", instance->record.name, now); + } + if (file.is_relative()) file = env.cwd / file; + std::error_code ec; + fs::create_directories(file.parent_path(), ec); + std::ofstream out(file, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(png->data()), static_cast(png->size())); + if (!out) { + Err() << std::format("crafter-debug: could not write {}\n", file.string()); + return exitError; + } + if (opts.json) return Print(Object({ { "file", Value(file.string()) }, { "bytes", Value(png->size()) } })); + Out() << std::format("{} ({} bytes)\n", file.string(), png->size()); + return exitOk; + } + + std::int32_t Quit() { + bool force = false; + for (const std::string& arg : opts.rest) { + if (arg == "--force") { + force = true; + } else { + return Usage(std::format("unknown argument '{}'", arg)); + } + } + const auto result = Request("debug.quit", Object({ { "force", Value(force) } }), opts.timeout); + if (!result) return result.error(); + if (opts.json) return Print(*result); + if (force) { + Out() << std::format("ended {}\n", instance->record.name); + } else { + Out() << std::format("asked {} to quit\n", instance->record.name); + } + return exitOk; + } + }; + + std::variant Parse(std::span args, Environment& env) { + Options options; + for (std::size_t i = 0; i < args.size(); ++i) { + const std::string_view arg = args[i]; + if (!options.command.empty()) { + // Global flags may follow the command too, before its own. + if (arg == "--json") { + options.json = true; + continue; + } + options.rest.emplace_back(arg); + continue; + } + if ((arg == "-i" || arg == "--instance") && i + 1 < args.size()) { + options.instance = args[++i]; + } else if (arg == "--json") { + options.json = true; + } else if (arg == "--timeout" && i + 1 < args.size()) { + const auto timeout = ParseDuration(args[++i]); + if (!timeout) { + *env.err << std::format("crafter-debug: '{}' is not a duration (30s, 500ms, 2m)\n", args[i]); + return exitUsage; + } + options.timeout = *timeout; + } else if (arg == "-h" || arg == "--help") { + *env.out << usage; + return exitOk; + } else if (arg.starts_with("-")) { + *env.err << std::format("crafter-debug: unknown option '{}'\n{}", arg, usage); + return exitUsage; + } else { + options.command = std::string(arg); + } + } + if (options.command.empty()) { + *env.err << usage; + return exitUsage; + } + if (std::ranges::find(commandNames, options.command) == commandNames.end()) { + const auto suggestion = Suggest(options.command, commandNames); + *env.err << std::format("crafter-debug: no command '{}'{}\n{}", options.command, + suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string(), usage); + return exitUsage; + } + return options; + } +} + +std::optional Crafter::Debug::Cli::ParseDuration(std::string_view text) { + std::size_t digits = 0; + while (digits < text.size() && text[digits] >= '0' && text[digits] <= '9') ++digits; + if (digits == 0) return std::nullopt; + std::int64_t number = 0; + std::from_chars(text.data(), text.data() + digits, number); + const std::string_view unit = text.substr(digits); + if (unit.empty() || unit == "s") return std::chrono::seconds(number); + if (unit == "ms") return std::chrono::milliseconds(number); + if (unit == "m") return std::chrono::minutes(number); + if (unit == "h") return std::chrono::hours(number); + return std::nullopt; +} + +std::int32_t Crafter::Debug::Cli::Run(std::span args, Environment& environment) { + auto parsed = Parse(args, environment); + if (const std::int32_t* code = std::get_if(&parsed)) return *code; + Client client(environment, std::get(std::move(parsed))); + return client.Execute(); +} + +std::string_view Crafter::Debug::Cli::Guide() { + return R"(# crafter-debug: driving a running app + +crafter-debug talks to an app built with Crafter.Debug while it runs: call its +commands, read and set its state, pause and step it, wait for conditions, +trace values over frames, read its log, take screenshots. Breakpoints and +memory are a debugger's job; `crafter-debug debugger` prints how to attach one. + +## The loop + +1. Build and start the app from its debug build, in the background (it does + not exit): `crafter-build --debug-api -r` (use the project's own flag). +2. `./build/crafter-debug ls --wait 60s` until it is listed. +3. `cmds` and `help ` to see what it offers, `get '*'` for its state. +4. Act and observe: `call`, `set`, `pause --when`, `wait`, `step`, `trace`, + `log --level warn`, `screenshot` (open the PNG it prints with Read). +5. `quit` when done. Killing the background task may leave the app running + if crafter-build's alias lacks --die-with-parent; `ls` shows leftovers. + +## Commands + + ls [--wait 60s] + status + cmds [glob] name(args = defaults) -> result -- description + help + call [key=value ...] values typed by the command's arguments: + slot=1 yaw=45 at.x=3 (dotted keys nest), + or --json '{"slot": 1}' + get ... 'fort1.*', 'bodies[3].pos.y' + set a JSON value, or text: set scene Game + pause pause now + pause --when '' pause the frame it holds + pause --on-check [--trap] pause on the next failing Debug::Check; + --trap also stops an attached debugger there + pause --clear disarm + resume + step [n] replies once the n steps are taken + wait '' | --frames n | --event Name | --paused + trace ... [--frames 60] the value every frame, with min/max/mean + trace --history a Debug::Sample's recent history + log [--last 50] [--level info|warn|error] [--cat ] [--grep ] + [--since-frame n] [--kinds log,warn,error,check,event,agent,pause,stdio] + log --follow [--for 30m] streams new entries; run it under Monitor + screenshot [-o shot.png] default ./build/crafter-debug-shots/ + quit [--force] --force exits even if the loop is stuck + debugger how to attach lldb or gdb + version + +Global: -i picks an instance when several run; --json prints raw +JSON (never truncated); --timeout 30s bounds the request (waits included). + +## Conditions + + fort1.core.hp <= 0 + scene == "Game" && !debug.paused + bodies[3].pos.y < -10 || (lives == 0 && score > highScore) + +Compare watch paths with literals or each other; && || ! and parentheses; +a lone path tests truthiness. debug.frame, debug.paused and debug.fps always +exist. A condition that cannot be decided (unknown path, ordering a string +against a number) is an error, not false, so a typo never waits forever. + +## Exit codes + +0 done, 2 usage, 3 no instance, 4 app and client speak different protocols, +5 timed out, 6 the app answered an error. Errors name the next step. + +## Gotchas + +- Calls, gets, sets and waits run when the app calls Debug::Pump(), once per + frame. If `status` says Pump was never called or is long ago, the main loop + is stalled, stopped in a debugger, or its window is hidden. +- pause and step only hold what the app gates on Debug::Advance() or + Debug::Paused(); rendering and Pump keep running while paused. +- A request that timed out before Pump is guaranteed not to have run. +- Run long waits and `log --follow` in the background, and chain short + steps with &&: `call Lobby_Start && wait --frames 30 && screenshot`. +)"; +} diff --git a/implementations/main.cpp b/implementations/main.cpp index 8615dd4..011d3d5 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1,13 +1,26 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -// Spike placeholder for the crafter-debug client: reports where it runs. +// crafter-debug: Cli::Run with the real transport. import std; -namespace fs = std::filesystem; +import Crafter.Debug.Core; +import Crafter.Debug.Cli; +using namespace Crafter::Debug; + +namespace { + // Until the QUIC transport lands: every call reports it unreachable. + class Unbuilt : public Cli::Connection { + public: + std::expected Call(const Value&, std::chrono::milliseconds, const std::function&) override { + return std::unexpected(Cli::CallError{ Cli::CallError::Kind::Unreachable, "this client was built without its transport" }); + } + }; +} int main(int argc, char** argv) { - std::println("crafter-debug spike client at {}", fs::read_symlink("/proc/self/exe").string()); - std::println("inside a Flatpak sandbox: {}", fs::exists("/.flatpak-info")); - for (std::int32_t i = 1; i < argc; ++i) std::println("arg: {}", std::string_view(argv[i])); - return 0; + const std::vector args(argv + 1, argv + argc); + Cli::Environment environment; + environment.connect = [](const InstanceRecord&) -> std::unique_ptr { return std::make_unique(); }; + environment.haveLldb = [] { return std::filesystem::exists("/app/bin/lldb") || std::filesystem::exists("/usr/bin/lldb"); }; + return Cli::Run(args, environment); } diff --git a/interfaces/Crafter.Debug.Cli-Client.cppm b/interfaces/Crafter.Debug.Cli-Client.cppm new file mode 100644 index 0000000..5d8dd09 --- /dev/null +++ b/interfaces/Crafter.Debug.Cli-Client.cppm @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// crafter-debug, the client agents drive an app with. Run is the whole +// program but for the transport: main hands it a Connector that reaches an +// instance over QUIC, and tests hand it one that reaches a Session in the +// same process. +// +// Exit codes: +// 0 done +// 2 a usage error, or no debug build yet (the launcher's stub) +// 3 no instance to talk to +// 4 the app and this client speak different protocols +// 5 timed out +// 6 the app answered with an error +// 1 is left to the launcher and `flatpak run` failing. +export module Crafter.Debug.Cli:Client; +import std; +import Crafter.Debug.Core; + +export namespace Crafter::Debug::Cli { + inline constexpr std::int32_t exitOk = 0; + inline constexpr std::int32_t exitUsage = 2; + inline constexpr std::int32_t exitNoInstance = 3; + inline constexpr std::int32_t exitVersion = 4; + inline constexpr std::int32_t exitTimeout = 5; + inline constexpr std::int32_t exitError = 6; + + struct CallError { + enum class Kind : std::uint8_t { + // No connection: the app is gone, or its port does not answer. + Unreachable, + // Connected, but no reply within the timeout. + TimedOut, + // The reply was not what the protocol says. + Broken, + }; + Kind kind = Kind::Unreachable; + std::string message; + }; + + // One request to an instance. The reply is the last object it sends; + // objects before it are stream lines (`log --follow`), handed to + // `onLine`, which returns false to stop. + class Connection { + public: + virtual ~Connection() = default; + virtual std::expected Call(const Value& request, std::chrono::milliseconds timeout, + const std::function& onLine) = 0; + }; + + using Connector = std::function(const InstanceRecord& instance)>; + + struct Environment { + Connector connect; + std::function()> list = ListInstances; + std::ostream* out = &std::cout; + std::ostream* err = &std::cerr; + // Where a screenshot without -o goes, relative paths included. + std::filesystem::path cwd = std::filesystem::current_path(); + // Whether `lldb` exists here, for the attach command `debugger` prints. + std::function haveLldb; + }; + + std::int32_t Run(std::span args, Environment& environment); + + // The full reference an agent reads with `crafter-debug guide`. + std::string_view Guide(); + + // "30s", "500ms", "2m", "1h"; a bare number is seconds. + std::optional ParseDuration(std::string_view text); +} diff --git a/interfaces/Crafter.Debug.Cli.cppm b/interfaces/Crafter.Debug.Cli.cppm new file mode 100644 index 0000000..0d33b6c --- /dev/null +++ b/interfaces/Crafter.Debug.Cli.cppm @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The crafter-debug client, as a library so tests can run it in process. +export module Crafter.Debug.Cli; +export import :Client; diff --git a/interfaces/Crafter.Debug.Core-Codec.cppm b/interfaces/Crafter.Debug.Core-Codec.cppm index 69940fe..5c6c23c 100644 --- a/interfaces/Crafter.Debug.Core-Codec.cppm +++ b/interfaces/Crafter.Debug.Core-Codec.cppm @@ -181,7 +181,8 @@ export namespace Crafter::Debug { static Value Describe() { Value description = Detail::Type("integer"); - if constexpr (sizeof(T) < sizeof(std::int64_t)) { + // The range only where it tells an agent something: 8 and 16 bits. + if constexpr (sizeof(T) < sizeof(std::int32_t)) { description.Set("minimum", Value(std::numeric_limits::min())); description.Set("maximum", Value(std::numeric_limits::max())); } diff --git a/project.cpp b/project.cpp index 97d8d19..c3af2fc 100644 --- a/project.cpp +++ b/project.cpp @@ -1,11 +1,13 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -// Three configurations: +// Four configurations: // Crafter.Debug.Core what the app and the client share (values, codecs, // the protocol, discovery, build info) +// Crafter.Debug.Cli the client's code, a library so tests can run it // crafter.debug-cli the client, crafter-debug -// Crafter.Debug what an app links (returned), depending on both +// Crafter.Debug what an app links (returned), depending on the core +// and the client // The client is one of the library's dependencies only so that a consumer's // debug build compiles it in the same pass as the app; an executable adds no // link flags to what depends on it. Consumers load this file through @@ -135,7 +137,20 @@ extern "C" Configuration CrafterBuildProject(std::span a client.mtune = cfg.mtune; client.sysroot = cfg.sysroot; client.debug = cfg.debug; - client.dependencies = { &core }; + + // The client's code as a library, so tests can run it in process. + Configuration& cli = owned.emplace_back(); + cli.path = Root(); + cli.name = "Crafter.Debug.Cli"; + cli.outputName = "Crafter.Debug.Cli"; + cli.type = ConfigurationType::LibraryStatic; + cli.target = cfg.target; + cli.march = cfg.march; + cli.mtune = cfg.mtune; + cli.sysroot = cfg.sysroot; + cli.debug = cfg.debug; + cli.dependencies = { &core }; + client.dependencies = { &cli }; { std::array interfaces = { @@ -157,6 +172,11 @@ extern "C" Configuration CrafterBuildProject(std::span a }; core.GetInterfacesAndImplementations(interfaces, implementations); } + { + std::array interfaces = { "interfaces/Crafter.Debug.Cli-Client", "interfaces/Crafter.Debug.Cli" }; + std::array implementations = { "implementations/Crafter.Debug.Cli-Client" }; + cli.GetInterfacesAndImplementations(interfaces, implementations); + } { std::array interfaces = {}; std::array implementations = { "implementations/main" }; @@ -186,6 +206,7 @@ extern "C" Configuration CrafterBuildProject(std::span a if (IsTopLevelProject()) { for (const std::string_view test : coreTests) cfg.AddTest(test).Dependencies({ &core }); for (const std::string_view test : libraryTests) cfg.AddTest(test).Dependencies({ &cfg }); + cfg.AddTest("ShouldRunClientCommands").Dependencies({ &cfg, &cli }); } return cfg; } diff --git a/tests/ShouldRunClientCommands/main.cpp b/tests/ShouldRunClientCommands/main.cpp new file mode 100644 index 0000000..4694e7c --- /dev/null +++ b/tests/ShouldRunClientCommands/main.cpp @@ -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 +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({ "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; +}