183 lines
8.4 KiB
C++
183 lines
8.4 KiB
C++
|
|
// 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<Value> Final() {
|
||
|
|
std::lock_guard lock(mutex);
|
||
|
|
return final;
|
||
|
|
}
|
||
|
|
|
||
|
|
private:
|
||
|
|
std::mutex mutex;
|
||
|
|
std::optional<Value> final;
|
||
|
|
std::vector<Value> lines;
|
||
|
|
};
|
||
|
|
|
||
|
|
Session* session = nullptr;
|
||
|
|
|
||
|
|
std::shared_ptr<FakeExchange> 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<FakeExchange>();
|
||
|
|
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<std::int32_t> {
|
||
|
|
return Async<std::int32_t>([&]() -> std::optional<std::int32_t> {
|
||
|
|
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<FakeExchange> leftOver;
|
||
|
|
{
|
||
|
|
Session owned({ .name = "test", .listen = false });
|
||
|
|
session = &owned;
|
||
|
|
|
||
|
|
auto wrongToken = std::make_shared<FakeExchange>();
|
||
|
|
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<FakeExchange>();
|
||
|
|
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::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' } };
|
||
|
|
});
|
||
|
|
});
|
||
|
|
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;
|
||
|
|
}
|