// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // The session answers requests the way a client relies on: a wrong token is // Unauthorized, a mistyped method gets a suggestion, hello and status are // answered at once, while calls, gets, sets and screenshots wait for the // app's Pump and run there. A request whose timeout passes before that Pump // is answered Timeout and is never run; a throwing handler is an Exception // reply; an Async result arrives on the Pump it is ready; the agent's // actions appear on the timeline; there is one Session per process; and // requests still queued when it ends are answered ShuttingDown. import std; import Crafter.Debug; using namespace Crafter::Debug; namespace { std::int32_t failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } class FakeExchange : public Detail::Exchange { public: void Reply(const Value& reply) override { std::lock_guard lock(mutex); if (!final) final = reply; } bool Stream(const Value& line) override { std::lock_guard lock(mutex); lines.push_back(line); return true; } std::optional Final() { std::lock_guard lock(mutex); return final; } private: std::mutex mutex; std::optional final; std::vector lines; }; Session* session = nullptr; std::shared_ptr Send(std::string_view method, Value params = {}, std::int64_t timeoutMs = 30'000) { Value request = Value::MakeObject(); request.Set("token", Value(session->Token())); request.Set("method", Value(method)); request.Set("params", std::move(params)); request.Set("timeoutMs", Value(timeoutMs)); auto exchange = std::make_shared(); session->Handle(request, exchange); return exchange; } Value ResultOf(FakeExchange& exchange) { const auto reply = exchange.Final(); return reply && reply->Find("result") ? *reply->Find("result") : Value(); } std::string CodeOf(FakeExchange& exchange) { const auto reply = exchange.Final(); return reply && reply->Find("error") ? reply->Find("error")->Find("code")->AsString() : std::string(); } std::string MessageOf(FakeExchange& exchange) { const auto reply = exchange.Final(); return reply && reply->Find("error") ? reply->Find("error")->Find("message")->AsString() : std::string(); } } int main() { Command("Player_Fire", "Fire.", [] { return std::string("bang"); }); Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); }); std::int32_t polls = 0; Command("Sim_Hash", "Ready on the second poll.", [&]() -> Async { return Async([&]() -> std::optional { if (++polls < 2) return std::nullopt; return 7; }); }); std::int32_t hp = 100; Watch("fort.hp", hp); const std::int32_t level = 3; Watch("level", level); std::shared_ptr leftOver; { Session owned({ .name = "test", .listen = false }); session = &owned; auto wrongToken = std::make_shared(); owned.Handle(Value::Parse(R"({"token": "nope", "method": "debug.status"})"), wrongToken); Check(CodeOf(*wrongToken) == "Unauthorized", "a wrong token is refused"); auto notObject = std::make_shared(); owned.Handle(Value(3), notObject); Check(CodeOf(*notObject) == "InvalidRequest", "a request that is not an object is refused"); const Value hello = ResultOf(*Send("debug.hello")); Check(hello.Find("protocol")->AsInteger() == 1 && hello.Find("name")->AsString() == "test", "hello names the protocol and the instance"); const Value status = ResultOf(*Send("debug.status")); Check(status.Find("frame")->AsInteger() == 0 && !status.Find("paused")->AsBool() && !status.Find("pauseSupported")->AsBool(), "status is answered at once"); const Value list = ResultOf(*Send("debug.list")); Check(list.Find("commands")->Size() == 3 && list.Find("watches")->Size() == 5, "list has the commands and watches, the session's own included"); auto typo = Send("debug.stauts"); Check(CodeOf(*typo) == "UnknownMethod" && MessageOf(*typo).find("did you mean 'debug.status'") != std::string::npos, "a mistyped method gets a suggestion"); auto commandTypo = Send("Player_Fier"); Check(CodeOf(*commandTypo) == "UnknownMethod" && MessageOf(*commandTypo).find("did you mean 'Player_Fire'") != std::string::npos, "a mistyped command gets a suggestion"); auto fire = Send("Player_Fire"); Check(!fire->Final(), "a call waits for Pump"); Pump(); Check(ResultOf(*fire).AsString() == "bang", "Pump runs the call and replies"); auto get = Send("debug.get", Value::Parse(R"({"paths": ["fort.hp", "lev*", "nope"]})")); Pump(); const Value got = ResultOf(*get); Check(got.Find("values")->Find("fort.hp")->AsInteger() == 100 && got.Find("values")->Find("level")->AsInteger() == 3 && got.Find("errors")->Find("nope") != nullptr, "get reads paths and globs, and reports the ones it cannot"); auto badGet = Send("debug.get", Value::Parse(R"({"pathz": []})")); Pump(); Check(CodeOf(*badGet) == "InvalidParams" && MessageOf(*badGet).find("did you mean 'paths'") != std::string::npos, "a mistyped parameter gets a suggestion"); auto set = Send("debug.set", Value::Parse(R"({"path": "fort.hp", "value": 5})")); Pump(); Check(hp == 5 && ResultOf(*set).Find("value")->AsInteger() == 5, "set writes on the Pump thread and replies with the new value"); auto readOnly = Send("debug.set", Value::Parse(R"({"path": "level", "value": 5})")); Pump(); Check(CodeOf(*readOnly) == "ReadOnly", "setting a read-only watch is refused"); auto crash = Send("Game_Crash"); Pump(); Check(CodeOf(*crash) == "Exception" && MessageOf(*crash).find("boom") != std::string::npos, "a throwing handler is an Exception reply"); std::int32_t fired = 0; Command("Count", "Counts.", [&] { ++fired; }); auto late = Send("Count", {}, 1); std::this_thread::sleep_for(std::chrono::milliseconds(20)); Pump(); Check(CodeOf(*late) == "Timeout" && MessageOf(*late).find("not run") != std::string::npos && fired == 0, "a request past its timeout before Pump is never run"); auto hash = Send("Sim_Hash"); Pump(); Check(!hash->Final(), "an Async result is not ready on its first poll"); Pump(); Check(ResultOf(*hash).AsInteger() == 7, "an Async result is replied on the Pump it is ready"); auto noShot = Send("debug.screenshot"); Pump(); Check(CodeOf(*noShot) == "NoCapture", "a screenshot without a capture source says so"); SetCapture([] { return Async>([]() -> std::optional> { return std::vector{ std::byte{ 0x89 }, std::byte{ 'P' }, std::byte{ 'N' }, std::byte{ 'G' } }; }); }); auto shot = Send("debug.screenshot"); Pump(); const auto png = Base64Decode(ResultOf(*shot).Find("png")->AsString()); Check(png && png->size() == 4 && (*png)[1] == std::byte{ 'P' }, "a screenshot's PNG comes back as base64"); auto agent = Send("debug.log", Value::Parse(R"({"kinds": ["agent"], "last": 100})")); const Value entries = *ResultOf(*agent).Find("entries"); Check(std::ranges::any_of(entries.Items(), [](const Value& entry) { return entry.Find("message")->AsString().starts_with("Player_Fire"); }), "the agent's calls are on the timeline"); bool threw = false; try { Session second({ .listen = false }); } catch (const std::logic_error&) { threw = true; } Check(threw, "a second Session is refused"); leftOver = Send("Player_Fire"); } Check(CodeOf(*leftOver) == "ShuttingDown", "a request still queued when the Session ends is answered ShuttingDown"); return failures == 0 ? 0 : 1; }