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(); +}