mudlet/test/functional_tests/TDiscordModeTest.cpp
Vadim Peretokin 827b32e875
Infrastructure: add Discord IPC server stub for end-to-end tests (#9475)
#### Brief overview of PR changes/additions

Adds `DiscordIpcServerStub`, a `QLocalServer` that speaks the
discord-rpc wire protocol: it performs the handshake, replies with a
`DISPATCH`/`READY` carrying a configurable username, and records
`SET_ACTIVITY` frames for assertions (with Windows named-pipe support).
This lets tests reach genuine Discord states headlessly with no Discord
client running. Two new end-to-end cases exercise real-IPC read-only
gating and `SET_ACTIVITY` payload capture.

#### Motivation for adding to Mudlet

Groundwork for later Discord Lua-API spec coverage: tests can now drive
the real discord-rpc handshake end-to-end without a live Discord client.

#### Other info (issues closed, discussion etc)

Stacked on #9472 (`fix-discord-permission-gating`); merge after that
base gating PR.

Tested by hand. Squash-merge with:
```
Assisted-by: Claude:claude-fable-5
Signed-off-by: Vadim Peretokin <vadim.peretokin@mudlet.org>
```
2026-07-27 20:40:16 +02:00

602 lines
27 KiB
C++

/***************************************************************************
* Copyright (C) 2025 by Mudlet Makers *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
/*
* Functional tests for Discord mode gating, server-origin tracking, and
* the privacy filtering logic.
*
* Uses TelnetServerStub to create a Host with a live profile. GMCP Discord
* messages are delivered via Host::processDiscordGMCP() which is the same
* entry point the telnet layer calls.
*
* Run with: ctest -R TDiscordModeTest -V
*/
#include <QtTest/QtTest>
#include <chrono>
#include <QJsonDocument>
#include <QJsonObject>
#include <utility>
#include "DiscordIpcServerStub.h"
#include "MudletInstanceCoordinator.h"
#include "TLuaInterpreter.h"
#include "TelnetServerStub.h"
#include "discord.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
extern "C" {
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
#include <lua5.1/lauxlib.h>
#include <lua5.1/lua.h>
#else
#include <lauxlib.h>
#include <lua.h>
#endif
}
using namespace std::chrono_literals;
extern void qInitResources_mudlet();
extern void qInitResources_qm();
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
extern void qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
extern void qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
extern void qInitResources_mudlet_fonts_posix();
#endif
#endif
void initializeQRCResourcesForDiscordModeTest();
class TDiscordModeTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
DiscordIpcServerStub* mpDiscordIpcStub = nullptr;
Host* mpHost = nullptr;
const QString mHostname = "Discord-Mode-Test";
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = "localhost";
const QString mDiscordStubUserName = "StubDiscordUser";
// Evaluates a Lua expression in the profile's global state. Returns the
// first result (invalid QVariant for nil or a compile/runtime error) plus
// the second result as a string: the error message on denial, or the Lua
// error text when the expression fails to compile or run.
std::pair<QVariant, QString> evalLua(const QString& expression)
{
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
const int base = lua_gettop(L);
QVariant first;
QString second;
if (luaL_dostring(L, qsl("return %1").arg(expression).toUtf8().constData()) == 0) {
if (lua_gettop(L) > base) {
if (lua_isboolean(L, base + 1)) {
first = static_cast<bool>(lua_toboolean(L, base + 1));
} else if (lua_isnumber(L, base + 1)) {
first = lua_tonumber(L, base + 1);
} else if (lua_isstring(L, base + 1)) {
first = QString::fromUtf8(lua_tostring(L, base + 1));
}
}
if (lua_gettop(L) > base + 1 && lua_isstring(L, base + 2)) {
second = QString::fromUtf8(lua_tostring(L, base + 2));
}
} else if (lua_gettop(L) > base && lua_isstring(L, base + 1)) {
// A compile or runtime error leaves the message on the stack;
// surface it so a failing test reports the real Lua error.
second = QString::fromUtf8(lua_tostring(L, base + 1));
}
lua_settop(L, base);
return {first, second};
}
// Returns the activity object of the most recent SET_ACTIVITY frame the
// IPC stub received, or an empty object if none arrived yet.
QJsonObject lastSetActivity() const
{
const QStringList frames = mpDiscordIpcStub->framePayloads();
for (auto it = frames.crbegin(); it != frames.crend(); ++it) {
const QJsonObject frame = QJsonDocument::fromJson(it->toUtf8()).object();
if (frame.value(qsl("cmd")).toString() == qsl("SET_ACTIVITY")) {
return frame.value(qsl("args")).toObject().value(qsl("activity")).toObject();
}
}
return {};
}
// Brings up a genuine discord-rpc connection to the IPC stub, or reuses one
// that is already active and whose most recent READY came from the stub,
// and waits for the READY handshake to populate Discord::smUserName.
// Reusing an established login lets the end-to-end tests share a single
// connection.
//
// The reuse check (mRpcActive && logged in as the stub) does not prove the
// current socket completed a handshake - smUserName is never cleared on
// disconnect - only that RPC is up and the last READY was the stub's, which
// is all a test that just needs to be logged in requires.
//
// When a fresh handshake is unavoidable the timeout is larger than one full
// backoff period (discord-rpc's reconnect backoff has a 60s ceiling). A
// fresh handshake needs two backoff-gated Open() attempts (handshake write,
// then READY read), so a pathologically inflated backoff can exceed even
// this - the ctest TIMEOUT gives further headroom. That process-global
// backoff, inflated by the suite's init/shutdown churn, together with a
// fixed short (10s) timeout, is what used to make these tests flake on slow
// runners.
bool establishDiscordLogin()
{
auto& discord = mudlet::self()->mDiscord;
if (discord.mRpcActive && Discord::getLoggedInUserName() == mDiscordStubUserName) {
return true;
}
// Fresh handshake: drop any half-open connection and reconnect. Snapshot
// the stub's handshake tally so this proves a real op-0 handshake (not a
// cached READY) drove the login.
const int handshakesBefore = mpDiscordIpcStub->handshakeCount();
discord.shutdownRpc();
Discord::smUserName.clear();
discord.UpdatePresence();
const bool loggedIn = QTest::qWaitFor(
[this]() {
return Discord::getLoggedInUserName() == mDiscordStubUserName;
},
65000);
return loggedIn && mpDiscordIpcStub->handshakeCount() > handshakesBefore;
}
private slots:
void initTestCase()
{
initializeQRCResourcesForDiscordModeTest();
// The discord-rpc library connects to $XDG_RUNTIME_DIR/discord-ipc-0,
// so the stub must be listening and the environment pointed at it
// before the Discord instance (created inside mudlet::start()) first
// initializes the RPC connection:
mpDiscordIpcStub = new DiscordIpcServerStub(qApp);
if (mpDiscordIpcStub->start(mDiscordStubUserName) && !mpDiscordIpcStub->runtimeDir().isEmpty()) {
qputenv("XDG_RUNTIME_DIR", mpDiscordIpcStub->runtimeDir().toLocal8Bit());
}
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
QTimer::singleShot(0ms, qApp, [this]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mHostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mPort);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(1000)) {
QFAIL("Profile took too long to load.");
}
mpHost = mudlet::self()->getActiveHost();
if (!mpHost) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(500)) {
QFAIL("Could not connect with the host.");
}
// Warm up the real discord-rpc connection once, up front. A completed
// handshake resets the library's internal reconnect backoff (capped at
// 60s), which the per-test init/shutdown churn below can otherwise
// inflate. The end-to-end tests reuse this connection where they can.
auto& discord = mudlet::self()->mDiscord;
if (discord.libraryLoaded() && mpDiscordIpcStub->listening()) {
mpHost->mDiscordMode = Host::DiscordShowGameDetails;
mpHost->mRequiredDiscordUserName.clear();
QVERIFY2(establishDiscordLogin(), "Discord warm-up handshake did not complete in time");
}
}
void init()
{
QVERIFY(mpHost);
auto& discord = mudlet::self()->mDiscord;
// Restore the default gating state BEFORE resetData(): resetData() calls
// UpdatePresence() internally, and a Disabled mode or username mismatch
// left over from the previous test would make that call tear the shared
// RPC connection down.
mpHost->mDiscordMode = Host::DiscordShowGameDetails;
mpHost->mDiscordAccessFlags = Host::DiscordSetSubMask;
mpHost->mRequiredDiscordUserName.clear();
discord.resetData(mpHost);
// Deliberately do NOT clear Discord::smUserName here: a completed
// handshake populated it and the reuse check in establishDiscordLogin()
// depends on it. Tests that need a specific logged-in identity set
// Discord::smUserName themselves.
}
// -- Mode gating tests --
void testGMCPIgnoredInDisabledMode()
{
mpHost->mDiscordMode = Host::DiscordDisabled;
auto& discord = mudlet::self()->mDiscord;
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Exploring the forest","state":"Level 50 Mage"})"));
QVERIFY2(discord.getDetailText(mpHost).isEmpty(), "GMCP details should be ignored in Disabled mode");
QVERIFY2(discord.getStateText(mpHost).isEmpty(), "GMCP state should be ignored in Disabled mode");
}
void testGMCPIgnoredInMudletOnlyMode()
{
mpHost->mDiscordMode = Host::DiscordShowMudletOnly;
auto& discord = mudlet::self()->mDiscord;
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Exploring the forest","state":"Level 50 Mage"})"));
QVERIFY2(discord.getDetailText(mpHost).isEmpty(), "GMCP details should be ignored in MudletOnly mode");
QVERIFY2(discord.getStateText(mpHost).isEmpty(), "GMCP state should be ignored in MudletOnly mode");
}
void testGMCPProcessedInGameDetailsMode()
{
mpHost->mDiscordMode = Host::DiscordShowGameDetails;
auto& discord = mudlet::self()->mDiscord;
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Exploring the forest","state":"Level 50 Mage"})"));
QCOMPARE(discord.getDetailText(mpHost), qsl("Exploring the forest"));
QCOMPARE(discord.getStateText(mpHost), qsl("Level 50 Mage"));
}
void testGMCPInfoIgnoredOutsideGameDetailsMode()
{
mpHost->mDiscordMode = Host::DiscordShowMudletOnly;
auto& discord = mudlet::self()->mDiscord;
mpHost->processDiscordGMCP(qsl("External.Discord.Info"), qsl(R"({"applicationid":"123456789"})"));
// Application ID should remain default (empty or Mudlet's) since Info was ignored
QVERIFY2(discord.getApplicationId(mpHost).isEmpty() || discord.getApplicationId(mpHost) == Discord::mMudletApplicationId, "GMCP Info should be ignored outside GameDetails mode");
}
// -- Server-origin tracking tests --
void testGMCPSetsServerOrigin()
{
mpHost->mDiscordMode = Host::DiscordShowGameDetails;
auto& discord = mudlet::self()->mDiscord;
QVERIFY2(!discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Detail should not be server-origin before GMCP");
QVERIFY2(!discord.isServerOrigin(mpHost, Host::DiscordSetState), "State should not be server-origin before GMCP");
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Hunting","state":"In Combat"})"));
QVERIFY2(discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Detail should be server-origin after GMCP set it");
QVERIFY2(discord.isServerOrigin(mpHost, Host::DiscordSetState), "State should be server-origin after GMCP set it");
}
void testLuaSetterClearsServerOrigin()
{
auto& discord = mudlet::self()->mDiscord;
// First, have the server set it
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Server set this"})"));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetDetail));
// Now simulate what the Lua API does: clear server origin, then set
discord.clearServerOrigin(mpHost, Host::DiscordSetDetail);
discord.setDetailText(mpHost, qsl("Lua set this"));
QVERIFY2(!discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Detail should not be server-origin after Lua cleared it");
QCOMPARE(discord.getDetailText(mpHost), qsl("Lua set this"));
}
void testServerOriginNotSetForUnsentFields()
{
auto& discord = mudlet::self()->mDiscord;
// Send GMCP with only detail, not state
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Only details"})"));
QVERIFY2(discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Detail should be server-origin");
QVERIFY2(!discord.isServerOrigin(mpHost, Host::DiscordSetState), "State should NOT be server-origin when server didn't send it");
}
// -- Privacy flag tests --
void testPrivacyFlagBlocksServerField()
{
auto& discord = mudlet::self()->mDiscord;
// Server sets detail
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Secret details","state":"Visible state"})"));
// Disable the detail privacy flag (user chose to hide it)
mpHost->mDiscordAccessFlags &= ~Host::DiscordSetDetail;
// Detail is server-origin and privacy flag is off - shouldShow would return false
QVERIFY2(discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Detail is server-origin");
QVERIFY2(!(mpHost->mDiscordAccessFlags & Host::DiscordSetDetail), "Detail privacy flag should be disabled");
// State privacy flag is still on - shouldShow would return true
QVERIFY2(discord.isServerOrigin(mpHost, Host::DiscordSetState), "State is server-origin");
QVERIFY2(mpHost->mDiscordAccessFlags & Host::DiscordSetState, "State privacy flag should still be enabled");
}
// -- GMCP Status field coverage --
void testGMCPStatusSetsAllFields()
{
auto& discord = mudlet::self()->mDiscord;
mpHost->processDiscordGMCP(qsl("External.Discord.Status"),
qsl(R"({"details":"Hunting","state":"Level 50","smallimagetext":"Warrior","largeimagetext":"Achaea","starttime":1234567890,"partysize":3,"partymax":6})"));
QCOMPARE(discord.getDetailText(mpHost), qsl("Hunting"));
QCOMPARE(discord.getStateText(mpHost), qsl("Level 50"));
QCOMPARE(discord.getSmallImageText(mpHost), qsl("Warrior"));
QCOMPARE(discord.getLargeImageText(mpHost), qsl("Achaea"));
QCOMPARE(discord.getTimeStamps(mpHost).first, static_cast<int64_t>(1234567890));
QCOMPARE(discord.getParty(mpHost).first, 3);
QCOMPARE(discord.getParty(mpHost).second, 6);
// All set fields should be server-origin
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetDetail));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetState));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetSmallIconText));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetLargeIconText));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetTimeInfo));
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetPartyInfo));
}
void testResetDataClearsEverything()
{
auto& discord = mudlet::self()->mDiscord;
// Set some data via GMCP
mpHost->processDiscordGMCP(qsl("External.Discord.Status"), qsl(R"({"details":"Test","state":"Test"})"));
QVERIFY(!discord.getDetailText(mpHost).isEmpty());
QVERIFY(discord.isServerOrigin(mpHost, Host::DiscordSetDetail));
// Reset
discord.resetData(mpHost);
QVERIFY2(discord.getDetailText(mpHost).isEmpty(), "Detail text should be cleared after resetData");
QVERIFY2(discord.getStateText(mpHost).isEmpty(), "State text should be cleared after resetData");
QVERIFY2(!discord.isServerOrigin(mpHost, Host::DiscordSetDetail), "Server-origin flags should be cleared after resetData");
}
// -- Lua API permission gating tests --
// Setters and resetDiscordData() need write access (denied while the API
// is read-only because the logged-in Discord user does not match the
// profile's restriction); getters only need read access.
void testLuaSetterDeniedWhenDisabled()
{
auto& discord = mudlet::self()->mDiscord;
discord.setDetailText(mpHost, qsl("original"));
mpHost->mDiscordMode = Host::DiscordDisabled;
auto [result, error] = evalLua(qsl("setDiscordDetail(\"changed\")"));
QVERIFY2(!result.isValid(), "setDiscordDetail() should be denied when Discord is disabled");
QVERIFY2(!error.isEmpty(), "denial should come with an error message");
QCOMPARE(discord.getDetailText(mpHost), qsl("original"));
}
void testLuaResetDeniedWhenDisabled()
{
auto& discord = mudlet::self()->mDiscord;
discord.setDetailText(mpHost, qsl("keep me"));
mpHost->mDiscordMode = Host::DiscordDisabled;
auto [result, error] = evalLua(qsl("resetDiscordData()"));
QVERIFY2(!result.isValid(), "resetDiscordData() should be denied when Discord is disabled");
QVERIFY2(!error.isEmpty(), "denial should come with an error message");
QCOMPARE(discord.getDetailText(mpHost), qsl("keep me"));
}
void testLuaSetterAndResetDeniedWhenReadOnly()
{
auto& discord = mudlet::self()->mDiscord;
if (!discord.libraryLoaded()) {
QSKIP("Discord RPC library not available - cannot test the read-only gate");
}
discord.setDetailText(mpHost, qsl("original"));
// Simulate being logged into Discord with a different account than
// the profile requires - that makes the Lua API read-only:
Discord::smUserName = qsl("someone_else");
mpHost->mRequiredDiscordUserName = qsl("profile_owner");
auto [setResult, setError] = evalLua(qsl("setDiscordDetail(\"changed\")"));
QVERIFY2(!setResult.isValid(), "setter should be denied while the API is read-only");
QVERIFY2(setError.contains(qsl("read-only")), "denial should say the API is read-only");
QCOMPARE(discord.getDetailText(mpHost), qsl("original"));
auto [resetResult, resetError] = evalLua(qsl("resetDiscordData()"));
QVERIFY2(!resetResult.isValid(), "resetDiscordData() should be denied while the API is read-only");
QVERIFY2(resetError.contains(qsl("read-only")), "denial should say the API is read-only");
QCOMPARE(discord.getDetailText(mpHost), qsl("original"));
}
void testLuaGettersAllowedWhenReadOnly()
{
auto& discord = mudlet::self()->mDiscord;
if (!discord.libraryLoaded()) {
QSKIP("Discord RPC library not available - cannot test the read-only gate");
}
discord.setSmallImage(mpHost, qsl("shield"));
discord.setSmallImageText(mpHost, qsl("Guardian"));
discord.setLargeImageText(mpHost, qsl("Achaea"));
discord.setParty(mpHost, 2, 5);
Discord::smUserName = qsl("someone_else");
mpHost->mRequiredDiscordUserName = qsl("profile_owner");
QCOMPARE(evalLua(qsl("getDiscordSmallIcon()")).first, QVariant(qsl("shield")));
QCOMPARE(evalLua(qsl("getDiscordSmallIconText()")).first, QVariant(qsl("Guardian")));
QCOMPARE(evalLua(qsl("getDiscordLargeIconText()")).first, QVariant(qsl("Achaea")));
QCOMPARE(evalLua(qsl("getDiscordParty()")).first, QVariant(2.0));
}
void testLuaSetterAndResetAllowedWithWriteAccess()
{
auto& discord = mudlet::self()->mDiscord;
if (!discord.libraryLoaded()) {
QSKIP("Discord RPC library not available - cannot test the write-access path");
}
// No username restriction - write access is granted:
auto [setResult, setError] = evalLua(qsl("setDiscordDetail(\"set from Lua\")"));
QCOMPARE(setResult, QVariant(true));
QCOMPARE(discord.getDetailText(mpHost), qsl("set from Lua"));
auto [resetResult, resetError] = evalLua(qsl("resetDiscordData()"));
QCOMPARE(resetResult, QVariant(true));
QVERIFY2(discord.getDetailText(mpHost).isEmpty(), "resetDiscordData() should clear the data when permitted");
}
// -- End-to-end tests against the Discord IPC stub --
// These drive the REAL discord-rpc library: it connects to
// DiscordIpcServerStub over the discord-ipc-0 socket, performs the
// genuine handshake, and its READY dispatch is what sets
// Discord::smUserName - the friend-class seam is only used to clear
// state, never to fake the logged-in user.
void testIpcHandshakeMakesApiReadOnlyEndToEnd()
{
auto& discord = mudlet::self()->mDiscord;
if (!discord.libraryLoaded()) {
QSKIP("Discord RPC library not available - cannot test the IPC handshake");
}
if (!mpDiscordIpcStub->listening()) {
QSKIP("Discord IPC stub is not listening - cannot test the IPC handshake");
}
discord.setSmallImage(mpHost, qsl("shield"));
// In a full-suite run the preceding read-only tests leave a non-stub
// logged-in name, so this deterministically drives a genuine op-0
// handshake - establishDiscordLogin() only returns true once the stub's
// handshake tally has advanced. The ready callback arrives via
// Discord_RunCallbacks, pumped every 50ms by Discord's timer while the
// event loop spins:
QVERIFY2(establishDiscordLogin(), "the discord-rpc handshake did not complete in time");
// The profile demands a different account, so the API turns read-only:
mpHost->mRequiredDiscordUserName = qsl("profile_owner");
// getDiscordSmallIcon() used to (wrongly) demand write access - it
// must keep working while the API is read-only:
QCOMPARE(evalLua(qsl("getDiscordSmallIcon()")).first, QVariant(qsl("shield")));
auto [setResult, setError] = evalLua(qsl("setDiscordDetail(\"changed\")"));
QVERIFY2(!setResult.isValid(), "setter should be denied while the API is read-only");
QVERIFY2(setError.contains(qsl("read-only")), "denial should say the API is read-only");
// No need to restore mRequiredDiscordUserName here: the next init()
// resets the gating state before resetData()'s UpdatePresence() runs, so
// the shared connection is not torn down on the way into the next test.
}
void testSetActivityReachesDiscordEndToEnd()
{
auto& discord = mudlet::self()->mDiscord;
if (!discord.libraryLoaded()) {
QSKIP("Discord RPC library not available - cannot test presence delivery");
}
if (!mpDiscordIpcStub->listening()) {
QSKIP("Discord IPC stub is not listening - cannot test presence delivery");
}
// Reuse the genuine discord-rpc connection the preceding end-to-end test
// established (seeded by the initTestCase warm-up) and kept alive across
// init(); only fall back to a fresh (backoff-tolerant) handshake if it
// was torn down. This is a real connection, not a faked login:
QVERIFY2(establishDiscordLogin(), "the discord-rpc handshake did not complete in time");
mpDiscordIpcStub->clearRecordedFrames();
// No username restriction, so write access is granted:
QCOMPARE(evalLua(qsl("setDiscordDetail(\"Exploring the IPC stub\")")).first, QVariant(true));
QCOMPARE(evalLua(qsl("setDiscordState(\"end-to-end\")")).first, QVariant(true));
// discord-rpc serializes SET_ACTIVITY on its own IO thread, so give
// the frames generous time to arrive:
QTRY_COMPARE_WITH_TIMEOUT(lastSetActivity().value(qsl("details")).toString(), qsl("Exploring the IPC stub"), 10000);
QTRY_COMPARE_WITH_TIMEOUT(lastSetActivity().value(qsl("state")).toString(), qsl("end-to-end"), 10000);
}
void cleanupTestCase()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
delete mudlet::self();
delete mpDiscordIpcStub;
mpDiscordIpcStub = nullptr;
}
};
void initializeQRCResourcesForDiscordModeTest()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
#include "TDiscordModeTest.moc"
QTEST_MAIN(TDiscordModeTest)