mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 03:53:26 -08:00
* [Server] Smoke-test the dev server end to end Spawns servatrice-dev on an ephemeral port and drives a real RemoteClient through login, room list, room join and game creation, asserting each response and the Event_GameJoined payload. The test is WITH_SERVER-gated, links only the client remote library, and keeps the dev server's throwaway settings inside a temporary directory. * [Server] Publish the dev server's port through a file The smoke test parsed the listening port out of the dev server's stdout, but the server announces it from the main thread while the logger thread writes to the same stream. A log line can land inside the announcement — Arch CI caught it — leaving the digits on the next line, so the test read nothing and failed. Add --port-file, written through QSaveFile once the server listens, and read the port from there instead of from the log. The file is either absent or complete, so logging cannot corrupt the handshake. * [Tests] Tick the event loop while waiting for the port file The port-file wait loop slept with QThread::msleep and never reached the event dispatcher. QProcess fills its read buffers and flips its state only from its socket notifiers, so drainProcessOutput() was a no-op and a child that died before publishing its port went unnoticed until the full 15 s startup timeout, with empty stdout and stderr in the failure message. Pump the event loop instead, as the previous waitForReadyRead loop did.
294 lines
11 KiB
C++
294 lines
11 KiB
C++
/** @file server_dev_smoke_test.cpp
|
|
* @brief End-to-end smoke test: a real `servatrice-dev` process driven by a real `RemoteClient`.
|
|
* @ingroup Tests
|
|
*/
|
|
|
|
#include "abstract_client.h"
|
|
#include "libcockatrice/interfaces/interface_network_settings_provider.h"
|
|
#include "libcockatrice/protocol/pb/event_game_joined.pb.h"
|
|
#include "libcockatrice/protocol/pb/event_list_rooms.pb.h"
|
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
|
#include "libcockatrice/protocol/pb/room_commands.pb.h"
|
|
#include "libcockatrice/protocol/pb/serverinfo_room.pb.h"
|
|
#include "libcockatrice/protocol/pb/session_commands.pb.h"
|
|
#include "libcockatrice/protocol/pending_command.h"
|
|
#include "remote_client.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QElapsedTimer>
|
|
#include <QEventLoop>
|
|
#include <QFile>
|
|
#include <QList>
|
|
#include <QProcess>
|
|
#include <QProcessEnvironment>
|
|
#include <QString>
|
|
#include <QTemporaryDir>
|
|
#include <QThread>
|
|
#include <QVariant>
|
|
#include <functional>
|
|
#include <gtest/gtest.h>
|
|
#include <memory>
|
|
|
|
namespace
|
|
{
|
|
|
|
// Bounded so the worst-case failure path stays inside the 60 s CTest timeout the test macro
|
|
// applies, while a local-loopback round trip still gets far more time than it needs.
|
|
constexpr int kStartupTimeoutMs = 15000;
|
|
constexpr int kProtocolTimeoutMs = 5000;
|
|
|
|
/** @brief The settings a real socket needs, with no client UI to read them from. */
|
|
class StubNetworkSettingsProvider : public INetworkSettingsProvider
|
|
{
|
|
public:
|
|
QString getClientID() override
|
|
{
|
|
return QStringLiteral("servatrice-dev-smoke-test");
|
|
}
|
|
[[nodiscard]] int getTimeOut() const override
|
|
{
|
|
return 60;
|
|
}
|
|
[[nodiscard]] int getKeepAlive() const override
|
|
{
|
|
return 60;
|
|
}
|
|
[[nodiscard]] bool getNotifyAboutUpdates() const override
|
|
{
|
|
return false;
|
|
}
|
|
void setKnownMissingFeatures(const QString &) override
|
|
{
|
|
}
|
|
QString getKnownMissingFeatures() override
|
|
{
|
|
return {};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @brief Pumps the Qt event loop until `done()` reports success or the timeout expires.
|
|
*
|
|
* The client runs on this thread, so its socket reads and signal emissions need a running
|
|
* event loop; a signal-driven QEventLoop would need one per awaited condition.
|
|
*/
|
|
bool waitFor(const std::function<bool()> &done, int timeoutMs)
|
|
{
|
|
QElapsedTimer elapsed;
|
|
elapsed.start();
|
|
while (elapsed.elapsed() < timeoutMs) {
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
|
|
if (done()) {
|
|
return true;
|
|
}
|
|
QThread::msleep(5);
|
|
}
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 50);
|
|
return done();
|
|
}
|
|
|
|
class ServerDevSmokeTest : public ::testing::Test
|
|
{
|
|
protected:
|
|
void SetUp() override
|
|
{
|
|
ASSERT_TRUE(tempDir.isValid());
|
|
portFilePath = tempDir.filePath(QStringLiteral("listening-port"));
|
|
|
|
// servatrice-dev writes its throwaway settings under QDir::tempPath(); pointing that at
|
|
// a directory the test owns means a terminated server cannot leave an ini behind.
|
|
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
|
|
for (const QString &variable : {QStringLiteral("TMPDIR"), QStringLiteral("TMP"), QStringLiteral("TEMP")}) {
|
|
environment.insert(variable, tempDir.path());
|
|
}
|
|
process.setProcessEnvironment(environment);
|
|
process.setProcessChannelMode(QProcess::SeparateChannels);
|
|
process.start(QStringLiteral(SERVATRICE_DEV_BINARY),
|
|
{QStringLiteral("--port"), QStringLiteral("0"), QStringLiteral("--seed"), QStringLiteral("42"),
|
|
QStringLiteral("--port-file"), portFilePath});
|
|
ASSERT_TRUE(process.waitForStarted(kStartupTimeoutMs)) << process.errorString().toStdString();
|
|
ASSERT_TRUE(readListeningPort()) << "servatrice-dev did not publish its listening port.\nstdout:\n"
|
|
<< stdoutText.toStdString() << "\nstderr:\n"
|
|
<< stderrText.toStdString();
|
|
|
|
client = std::make_unique<RemoteClient>(nullptr, &provider);
|
|
}
|
|
|
|
void TearDown() override
|
|
{
|
|
if (client) {
|
|
client->disconnectFromServer();
|
|
waitFor([this] { return client->getStatus() == StatusDisconnected; }, 2000);
|
|
}
|
|
client.reset();
|
|
|
|
if (process.state() != QProcess::NotRunning) {
|
|
process.terminate();
|
|
if (!process.waitForFinished(2000)) {
|
|
process.kill();
|
|
process.waitForFinished(2000);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// @brief Waits for the child to publish the ephemeral port it listens on.
|
|
bool readListeningPort()
|
|
{
|
|
QElapsedTimer elapsed;
|
|
elapsed.start();
|
|
while (elapsed.elapsed() < kStartupTimeoutMs) {
|
|
// The child logs heavily during startup; keep its pipes drained so it cannot block.
|
|
drainProcessOutput();
|
|
|
|
QFile portFile(portFilePath);
|
|
if (portFile.open(QIODevice::ReadOnly)) {
|
|
bool ok = false;
|
|
const int port = QString::fromLatin1(portFile.readAll()).trimmed().toInt(&ok);
|
|
if (ok && port > 0) {
|
|
listeningPort = port;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (process.state() == QProcess::NotRunning) {
|
|
drainProcessOutput();
|
|
return false;
|
|
}
|
|
// An event tick, not a bare sleep: QProcess fills its read buffers and flips its state
|
|
// only from its socket notifiers, so without this the drain below is a no-op and a dead
|
|
// child is not noticed until the full timeout has elapsed.
|
|
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
|
|
}
|
|
drainProcessOutput();
|
|
return false;
|
|
}
|
|
|
|
/// @brief Keeps the child's pipes drained so logging cannot block it mid-test.
|
|
void drainProcessOutput()
|
|
{
|
|
stdoutText.append(QString::fromLocal8Bit(process.readAllStandardOutput()));
|
|
stderrText.append(QString::fromLocal8Bit(process.readAllStandardError()));
|
|
}
|
|
|
|
/// @brief Waits for a client-side condition, giving up as soon as the child has exited.
|
|
bool waitForClient(const std::function<bool()> &done, int timeoutMs)
|
|
{
|
|
return waitFor([&done, this] { return done() || process.state() == QProcess::NotRunning; }, timeoutMs);
|
|
}
|
|
|
|
/// @brief Logs in as a guest, which is the only identity AuthenticationNone offers.
|
|
bool login()
|
|
{
|
|
QObject::connect(client.get(), &RemoteClient::statusChanged, [this](ClientStatus status) {
|
|
if (status == StatusLoggedIn) {
|
|
loggedIn = true;
|
|
}
|
|
});
|
|
QObject::connect(client.get(), &RemoteClient::loginError,
|
|
[this](Response::ResponseCode code, const QString &reason, quint32, const QList<QString> &) {
|
|
protocolFailure = QStringLiteral("login failed: %1 (code %2)").arg(reason).arg(code);
|
|
});
|
|
QObject::connect(client.get(), &RemoteClient::socketError, [this](const QString &error) {
|
|
protocolFailure = QStringLiteral("socket error: %1").arg(error);
|
|
});
|
|
|
|
client->connectToServer(QStringLiteral("127.0.0.1"), static_cast<unsigned int>(listeningPort),
|
|
QStringLiteral("smoke-guest"), QString());
|
|
return waitForClient([this] { return loggedIn || !protocolFailure.isEmpty(); }, kProtocolTimeoutMs) && loggedIn;
|
|
}
|
|
|
|
/// @brief Sends a session command and returns its response code.
|
|
Response::ResponseCode sendSessionCommand(const google::protobuf::Message &command)
|
|
{
|
|
return awaitResponse(AbstractClient::prepareSessionCommand(command));
|
|
}
|
|
|
|
/// @brief Sends a room command and returns its response code.
|
|
Response::ResponseCode sendRoomCommand(const google::protobuf::Message &command, int roomId)
|
|
{
|
|
return awaitResponse(AbstractClient::prepareRoomCommand(command, roomId));
|
|
}
|
|
|
|
Response::ResponseCode awaitResponse(PendingCommand *pending)
|
|
{
|
|
Response::ResponseCode code = Response::RespInvalidCommand;
|
|
bool finished = false;
|
|
const QMetaObject::Connection connection =
|
|
QObject::connect(pending, &PendingCommand::finished,
|
|
[&code, &finished](const Response &response, const CommandContainer &, const QVariant &) {
|
|
code = response.response_code();
|
|
finished = true;
|
|
});
|
|
client->sendCommand(pending);
|
|
const bool answered = waitForClient([&finished] { return finished; }, kProtocolTimeoutMs);
|
|
if (!answered) {
|
|
// The command may still be in flight; a late response must not write to this frame.
|
|
QObject::disconnect(connection);
|
|
}
|
|
EXPECT_TRUE(answered) << "no response to " << pending->getCommandContainer().DebugString();
|
|
drainProcessOutput();
|
|
return code;
|
|
}
|
|
|
|
QProcess process;
|
|
QTemporaryDir tempDir;
|
|
QString portFilePath;
|
|
StubNetworkSettingsProvider provider;
|
|
std::unique_ptr<RemoteClient> client;
|
|
int listeningPort = 0;
|
|
QString stdoutText;
|
|
QString stderrText;
|
|
QString protocolFailure;
|
|
// State the client's signals write to. It has to outlive every connection, because teardown
|
|
// disconnecting the client emits statusChanged again.
|
|
bool loggedIn = false;
|
|
QList<ServerInfo_Room> rooms;
|
|
bool gameJoined = false;
|
|
QString joinedDescription;
|
|
};
|
|
|
|
TEST_F(ServerDevSmokeTest, LogsInListsRoomsJoinsAndCreatesAGame)
|
|
{
|
|
ASSERT_TRUE(login()) << protocolFailure.toStdString();
|
|
|
|
QObject::connect(client.get(), &RemoteClient::listRoomsEventReceived, [this](const Event_ListRooms &event) {
|
|
for (int i = 0; i < event.room_list_size(); ++i) {
|
|
rooms.append(event.room_list(i));
|
|
}
|
|
});
|
|
|
|
EXPECT_EQ(Response::RespOk, sendSessionCommand(Command_ListRooms()));
|
|
ASSERT_TRUE(waitForClient([this] { return !rooms.isEmpty(); }, kProtocolTimeoutMs))
|
|
<< "the server never sent a room list";
|
|
ASSERT_EQ(1, rooms.size());
|
|
EXPECT_EQ(QStringLiteral("Dev Lobby"), QString::fromStdString(rooms.first().name()));
|
|
const int roomId = rooms.first().room_id();
|
|
ASSERT_GT(roomId, 0);
|
|
|
|
Command_JoinRoom joinRoom;
|
|
joinRoom.set_room_id(static_cast<google::protobuf::uint32>(roomId));
|
|
EXPECT_EQ(Response::RespOk, sendSessionCommand(joinRoom));
|
|
|
|
QObject::connect(client.get(), &RemoteClient::gameJoinedEventReceived, [this](const Event_GameJoined &event) {
|
|
gameJoined = true;
|
|
joinedDescription = QString::fromStdString(event.game_info().description());
|
|
});
|
|
|
|
Command_CreateGame createGame;
|
|
createGame.set_description("servatrice-dev smoke game");
|
|
createGame.set_max_players(2);
|
|
createGame.set_spectators_allowed(true);
|
|
EXPECT_EQ(Response::RespOk, sendRoomCommand(createGame, roomId));
|
|
ASSERT_TRUE(waitForClient([this] { return gameJoined; }, kProtocolTimeoutMs))
|
|
<< "the creator was never joined to the game it created";
|
|
EXPECT_EQ(QStringLiteral("servatrice-dev smoke game"), joinedDescription);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
QCoreApplication app(argc, argv);
|
|
::testing::InitGoogleTest(&argc, argv);
|
|
return RUN_ALL_TESTS();
|
|
}
|