From ccda73fe5c9fccb4f00360f511e5ee37abc08e55 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:16:36 +0100 Subject: [PATCH] [Server] Smoke-test the dev server end to end (#7865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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. --- servatrice/dev/main.cpp | 38 ++++- tests/CMakeLists.txt | 19 +++ tests/server_dev_smoke_test.cpp | 294 ++++++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 tests/server_dev_smoke_test.cpp diff --git a/servatrice/dev/main.cpp b/servatrice/dev/main.cpp index 39195f565..9ebba095b 100644 --- a/servatrice/dev/main.cpp +++ b/servatrice/dev/main.cpp @@ -15,12 +15,14 @@ #include "smtpclient.h" #include "version_string.h" +#include #include #include #include #include #include #include +#include #include #include #include @@ -88,6 +90,23 @@ void dumpState(Servatrice &server, const Servatrice_InMemoryDatabaseInterface &d std::cout << "servatrice-dev: log entries=" << database.getLogEntries().size() << std::endl; } +/** + * @brief Publishes the bound port the way a script can read it. + * + * The stdout announcement is written from this thread while the logger thread writes to the same + * stream, so a reader can see a log line inside it. A file written through QSaveFile is either + * absent or complete, which makes the port deterministic to consume. + */ +bool writeListeningPort(const QString &path, int port) +{ + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + file.write(QByteArray::number(port) + '\n'); + return file.commit(); +} + } // namespace int main(int argc, char *argv[]) @@ -103,9 +122,13 @@ int main(int argc, char *argv[]) parser.addVersionOption(); QCommandLineOption portOption({"p", "port"}, "TCP port to listen on; 0 picks an ephemeral port.", "port", "0"); + QCommandLineOption portFileOption("port-file", + "Write the bound listening port to this file once the server listens.", + "path"); QCommandLineOption seedOption("seed", "Seed for the game RNG; random when omitted.", "seed"); QCommandLineOption dumpStateOption("dump-state", "Print the server state after startup and keep serving."); parser.addOption(portOption); + parser.addOption(portFileOption); parser.addOption(seedOption); parser.addOption(dumpStateOption); parser.process(app); @@ -169,11 +192,18 @@ int main(int argc, char *argv[]) std::cerr << "servatrice-dev: the server is not listening" << std::endl; retval = EXIT_FAILURE; } else { - std::cout << "servatrice-dev: listening on port " << listeningPort << std::endl; - if (parser.isSet(dumpStateOption)) { - dumpState(*server, *server->getInMemoryDatabase()); + const QString portFilePath = parser.value(portFileOption); + if (!portFilePath.isEmpty() && !writeListeningPort(portFilePath, listeningPort)) { + std::cerr << "servatrice-dev: cannot write the listening port to " << portFilePath.toStdString() + << std::endl; + retval = EXIT_FAILURE; + } else { + std::cout << "servatrice-dev: listening on port " << listeningPort << std::endl; + if (parser.isSet(dumpStateOption)) { + dumpState(*server, *server->getInMemoryDatabase()); + } + retval = QCoreApplication::exec(); } - retval = QCoreApplication::exec(); } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b825b3e58..5f04d448a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -394,6 +394,25 @@ cockatrice_add_test( LABELS server ) +if(WITH_SERVER) + # The only test that starts a separately scheduled server process. servatrice-dev is built by the + # servatrice subdirectory, which exists only when WITH_SERVER is on; the binary path is baked in + # because CTest runs the test from its own working directory. + cockatrice_add_test( + NAME + server_dev_smoke_test + SOURCES + ${VERSION_STRING_CPP} + server_dev_smoke_test.cpp + LIBS + libcockatrice_network_client_remote + LABELS + server + DEFINITIONS + "SERVATRICE_DEV_BINARY=\"$\"" + ) + add_dependencies(server_dev_smoke_test servatrice-dev) +endif() cockatrice_add_test( NAME client_login_status_test diff --git a/tests/server_dev_smoke_test.cpp b/tests/server_dev_smoke_test.cpp new file mode 100644 index 000000000..dd5d7d505 --- /dev/null +++ b/tests/server_dev_smoke_test.cpp @@ -0,0 +1,294 @@ +/** @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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 &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(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 &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 &) { + 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(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 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 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(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(); +}