diff --git a/CMakeLists.txt b/CMakeLists.txt index aefe0cd..ac433c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ include(ECMPoQmTools) include(ECMDeprecationSettings) include(ECMQmlModule) include(ECMAddAppIcon) +include(CTest) include(FeatureSummary) ecm_setup_version(${PROJECT_VERSION} @@ -114,6 +115,10 @@ add_subdirectory(src) add_subdirectory(src/krunner) add_subdirectory(icons) +if (BUILD_TESTING) + add_subdirectory(tests) +endif() + ki18n_install(po) install(FILES org.kde.alakarte.desktop DESTINATION ${KDE_INSTALL_APPDIR}) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..a61a509 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,45 @@ +find_package(Qt6 ${QT_MIN_VERSION} QUIET COMPONENTS Test) + +if (NOT Qt6Test_FOUND OR NOT TARGET Qt6::Test) + return() +endif() + +add_executable(alakarte_dbus_smoketest + dbus_smoketest.cpp +) + +target_link_libraries(alakarte_dbus_smoketest PRIVATE + Qt6::Core + Qt6::DBus + Qt6::Test +) + +add_test(NAME alakarte_dbus_smoketest + COMMAND alakarte_dbus_smoketest + --gamecenter $ + --runnerd $ + --inputd $ +) + +set_tests_properties(alakarte_dbus_smoketest PROPERTIES + TIMEOUT 30 +) + +add_executable(alakarte_stop_launching_regression_test + stop_launching_regression_test.cpp +) + +target_link_libraries(alakarte_stop_launching_regression_test PRIVATE + Qt6::Core + Qt6::DBus + Qt6::Test +) + +add_test(NAME alakarte_stop_launching_regression_test + COMMAND alakarte_stop_launching_regression_test + --gamecenter $ +) + +set_tests_properties(alakarte_stop_launching_regression_test PROPERTIES + TIMEOUT 60 +) diff --git a/tests/dbus_smoketest.cpp b/tests/dbus_smoketest.cpp new file mode 100644 index 0000000..e8d5715 --- /dev/null +++ b/tests/dbus_smoketest.cpp @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +static QString takeArgValue(const QStringList &args, const QString &key) +{ + const int idx = args.indexOf(key); + if (idx < 0) { + return {}; + } + if (idx + 1 >= args.size()) { + return {}; + } + return args.at(idx + 1); +} + +static bool waitForService(QDBusConnection &bus, const QString &service, int timeoutMs) +{ + if (!bus.isConnected() || !bus.interface()) { + return false; + } + + QElapsedTimer t; + t.start(); + while (t.elapsed() < timeoutMs) { + if (bus.interface()->isServiceRegistered(service)) { + return true; + } + QTest::qWait(50); + } + return false; +} + +static QVariant unwrapDbusVariant(QVariant v) +{ + if (v.metaType() == QMetaType::fromType()) { + v = v.value().variant(); + } + return v; +} + +static QVariantMap unwrapVariantMap(QVariant v) +{ + v = unwrapDbusVariant(v); + if (v.metaType() == QMetaType::fromType()) { + const QDBusArgument arg = v.value(); + return qdbus_cast(arg); + } + if (v.canConvert()) { + return v.toMap(); + } + return {}; +} +} + +class DbusSmokeTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + + void pingGameCenter(); + void pingRunner(); + void pingInput(); + void runnerResolveLaunchNative(); + void runnerGameProfiles(); + +private: + QString m_gamecenterPath; + QString m_runnerdPath; + QString m_inputdPath; + + QString m_dbusAddress; + QProcess m_dbusDaemon; + QProcess m_gamecenter; + QProcess m_runnerd; + QProcess m_inputd; + + QString m_testXdgBase; + + QDBusConnection m_bus = QDBusConnection::connectToBus(QString(), QStringLiteral("alakarte_test")); + + bool startPrivateBus(); + bool haveDbusDaemon() const; + void startDaemon(QProcess &p, const QString &program); + void stopProcess(QProcess &p); +}; + +bool DbusSmokeTest::haveDbusDaemon() const +{ + return !QStandardPaths::findExecutable(QStringLiteral("dbus-daemon")).isEmpty(); +} + +bool DbusSmokeTest::startPrivateBus() +{ + const QString dbusDaemon = QStandardPaths::findExecutable(QStringLiteral("dbus-daemon")); + if (dbusDaemon.isEmpty()) { + return false; + } + + m_dbusDaemon.setProgram(dbusDaemon); + m_dbusDaemon.setArguments({QStringLiteral("--session"), QStringLiteral("--nofork"), QStringLiteral("--print-address=1")}); + m_dbusDaemon.setProcessChannelMode(QProcess::MergedChannels); + m_dbusDaemon.start(); + if (!m_dbusDaemon.waitForStarted(5000)) { + return false; + } + + if (!m_dbusDaemon.waitForReadyRead(5000)) { + return false; + } + + m_dbusAddress = QString::fromUtf8(m_dbusDaemon.readLine()).trimmed(); + if (m_dbusAddress.isEmpty()) { + return false; + } + + QDBusConnection::disconnectFromBus(QStringLiteral("alakarte_test")); + m_bus = QDBusConnection::connectToBus(m_dbusAddress, QStringLiteral("alakarte_test")); + return m_bus.isConnected(); +} + +void DbusSmokeTest::startDaemon(QProcess &p, const QString &program) +{ + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("DBUS_SESSION_BUS_ADDRESS"), m_dbusAddress); + if (!m_testXdgBase.isEmpty()) { + env.insert(QStringLiteral("XDG_DATA_HOME"), m_testXdgBase + QStringLiteral("/data")); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), m_testXdgBase + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_CACHE_HOME"), m_testXdgBase + QStringLiteral("/cache")); + } + p.setProcessEnvironment(env); + p.setProgram(program); + p.setArguments({}); + p.setProcessChannelMode(QProcess::MergedChannels); + p.start(); +} + +void DbusSmokeTest::stopProcess(QProcess &p) +{ + if (p.state() == QProcess::NotRunning) { + return; + } + + p.terminate(); + if (!p.waitForFinished(3000)) { + p.kill(); + p.waitForFinished(3000); + } +} + +void DbusSmokeTest::initTestCase() +{ + m_gamecenterPath = qApp ? qApp->property("alakarte_test_gamecenter").toString() : QString(); + m_runnerdPath = qApp ? qApp->property("alakarte_test_runnerd").toString() : QString(); + m_inputdPath = qApp ? qApp->property("alakarte_test_inputd").toString() : QString(); + + if (m_gamecenterPath.isEmpty() || m_runnerdPath.isEmpty() || m_inputdPath.isEmpty()) { + const QStringList args = QCoreApplication::arguments(); + if (m_gamecenterPath.isEmpty()) { + m_gamecenterPath = takeArgValue(args, QStringLiteral("--gamecenter")); + } + if (m_runnerdPath.isEmpty()) { + m_runnerdPath = takeArgValue(args, QStringLiteral("--runnerd")); + } + if (m_inputdPath.isEmpty()) { + m_inputdPath = takeArgValue(args, QStringLiteral("--inputd")); + } + } + + QVERIFY2(!m_gamecenterPath.isEmpty(), "--gamecenter is required"); + QVERIFY2(!m_runnerdPath.isEmpty(), "--runnerd is required"); + QVERIFY2(!m_inputdPath.isEmpty(), "--inputd is required"); + + if (!haveDbusDaemon()) { + QSKIP("dbus-daemon not found"); + } + + if (!startPrivateBus()) { + QSKIP("failed to start private dbus session"); + } + + m_testXdgBase = QDir::tempPath() + QStringLiteral("/alakarte-test-xdg-") + QUuid::createUuid().toString(QUuid::WithoutBraces); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/data"))); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/config"))); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/cache"))); + + startDaemon(m_runnerd, m_runnerdPath); + QVERIFY(m_runnerd.waitForStarted(5000)); + + startDaemon(m_inputd, m_inputdPath); + QVERIFY(m_inputd.waitForStarted(5000)); + + startDaemon(m_gamecenter, m_gamecenterPath); + QVERIFY(m_gamecenter.waitForStarted(5000)); + + QVERIFY2(waitForService(m_bus, QStringLiteral("org.kde.ALaKarte.Runner1"), 5000), "Runner1 service did not appear on the bus"); + QVERIFY2(waitForService(m_bus, QStringLiteral("org.kde.ALaKarte.Input1"), 5000), "Input1 service did not appear on the bus"); + QVERIFY2(waitForService(m_bus, QStringLiteral("org.kde.GameCenter1"), 5000), "GameCenter1 service did not appear on the bus"); +} + +void DbusSmokeTest::cleanupTestCase() +{ + stopProcess(m_gamecenter); + stopProcess(m_inputd); + stopProcess(m_runnerd); + stopProcess(m_dbusDaemon); + QDBusConnection::disconnectFromBus(QStringLiteral("alakarte_test")); + + if (!m_testXdgBase.isEmpty()) { + QDir(m_testXdgBase).removeRecursively(); + m_testXdgBase.clear(); + } +} + +void DbusSmokeTest::pingGameCenter() +{ + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), m_bus); + QVERIFY(iface.isValid()); + iface.setTimeout(2000); + + const QDBusReply reply = iface.call(QStringLiteral("Ping")); + QVERIFY(reply.isValid()); + QCOMPARE(reply.value(), QStringLiteral("ok")); +} + +void DbusSmokeTest::pingRunner() +{ + QDBusInterface iface(QStringLiteral("org.kde.ALaKarte.Runner1"), + QStringLiteral("/org/kde/ALaKarte/Runner1"), + QStringLiteral("org.kde.ALaKarte.Runner1"), + m_bus); + QVERIFY(iface.isValid()); + iface.setTimeout(2000); + + const QDBusReply reply = iface.call(QStringLiteral("Ping")); + QVERIFY(reply.isValid()); + QCOMPARE(reply.value(), QStringLiteral("ok")); +} + +void DbusSmokeTest::pingInput() +{ + QDBusInterface iface(QStringLiteral("org.kde.ALaKarte.Input1"), + QStringLiteral("/org/kde/ALaKarte/Input1"), + QStringLiteral("org.kde.ALaKarte.Input1"), + m_bus); + QVERIFY(iface.isValid()); + iface.setTimeout(2000); + + const QDBusReply reply = iface.call(QStringLiteral("Ping")); + QVERIFY(reply.isValid()); + QCOMPARE(reply.value(), QStringLiteral("ok")); +} + +void DbusSmokeTest::runnerResolveLaunchNative() +{ + QDBusInterface iface(QStringLiteral("org.kde.ALaKarte.Runner1"), + QStringLiteral("/org/kde/ALaKarte/Runner1"), + QStringLiteral("org.kde.ALaKarte.Runner1"), + m_bus); + QVERIFY(iface.isValid()); + iface.setTimeout(2000); + + QVariantMap spec; + spec.insert(QStringLiteral("program"), QStringLiteral("/bin/true")); + spec.insert(QStringLiteral("args"), QStringList{}); + + const QDBusReply reply = iface.call(QStringLiteral("ResolveLaunch"), spec); + QVERIFY(reply.isValid()); + + const QVariantMap out = reply.value(); + QVERIFY(out.value(QStringLiteral("ok")).toBool()); + QCOMPARE(out.value(QStringLiteral("finalProgram")).toString(), QStringLiteral("/bin/true")); + QCOMPARE(out.value(QStringLiteral("finalArgs")).toStringList(), QStringList{}); +} + +void DbusSmokeTest::runnerGameProfiles() +{ + QDBusInterface iface(QStringLiteral("org.kde.ALaKarte.Runner1"), + QStringLiteral("/org/kde/ALaKarte/Runner1"), + QStringLiteral("org.kde.ALaKarte.Runner1"), + m_bus); + QVERIFY(iface.isValid()); + iface.setTimeout(2000); + + const QString gameId = QStringLiteral("alakarte-test-game"); + + { + const QDBusReply reply = iface.call(QStringLiteral("ClearGameProfile"), gameId); + QVERIFY(reply.isValid()); + QVERIFY(reply.value().value(QStringLiteral("ok")).toBool()); + } + + { + QVariantMap spec; + spec.insert(QStringLiteral("gameId"), gameId); + spec.insert(QStringLiteral("runner"), QStringLiteral("wine")); + spec.insert(QStringLiteral("runnerPath"), QStringLiteral("/bin/true")); + spec.insert(QStringLiteral("envOverrides"), QVariantMap{{QStringLiteral("ALAKARTE_TEST_VAR"), QStringLiteral("1")}}); + spec.insert(QStringLiteral("extraArgs"), QStringList{QStringLiteral("--profile-arg")}); + spec.insert(QStringLiteral("dllOverrides"), + QVariantMap{{QStringLiteral("d3d11"), QStringLiteral("native,builtin")}, {QStringLiteral("dxgi"), QStringLiteral("native,builtin")}}); + + const QDBusReply reply = iface.call(QStringLiteral("SetGameProfile"), spec); + QVERIFY(reply.isValid()); + const QVariantMap out = reply.value(); + QVERIFY(out.value(QStringLiteral("ok")).toBool()); + + const QVariantMap profile = unwrapVariantMap(out.value(QStringLiteral("profile"))); + QCOMPARE(profile.value(QStringLiteral("gameId")).toString(), gameId); + const QVariantMap env = unwrapVariantMap(profile.value(QStringLiteral("envOverrides"))); + QCOMPARE(env.value(QStringLiteral("ALAKARTE_TEST_VAR")).toString(), QStringLiteral("1")); + + QCOMPARE(profile.value(QStringLiteral("extraArgs")).toStringList(), QStringList{QStringLiteral("--profile-arg")}); + + const QVariantMap dll = unwrapVariantMap(profile.value(QStringLiteral("dllOverrides"))); + QCOMPARE(dll.value(QStringLiteral("d3d11")).toString(), QStringLiteral("native,builtin")); + QCOMPARE(dll.value(QStringLiteral("dxgi")).toString(), QStringLiteral("native,builtin")); + } + + { + const QDBusReply reply = iface.call(QStringLiteral("GetGameProfile"), gameId); + QVERIFY(reply.isValid()); + const QVariantMap out = reply.value(); + QVERIFY(out.value(QStringLiteral("ok")).toBool()); + const QVariantMap profile = unwrapVariantMap(out.value(QStringLiteral("profile"))); + QCOMPARE(profile.value(QStringLiteral("gameId")).toString(), gameId); + + QCOMPARE(profile.value(QStringLiteral("extraArgs")).toStringList(), QStringList{QStringLiteral("--profile-arg")}); + + const QVariantMap dll = unwrapVariantMap(profile.value(QStringLiteral("dllOverrides"))); + QCOMPARE(dll.value(QStringLiteral("d3d11")).toString(), QStringLiteral("native,builtin")); + QCOMPARE(dll.value(QStringLiteral("dxgi")).toString(), QStringLiteral("native,builtin")); + } + + { + QVariantMap spec; + spec.insert(QStringLiteral("gameId"), gameId); + spec.insert(QStringLiteral("useGameProfile"), true); + spec.insert(QStringLiteral("program"), QStringLiteral("/bin/true")); + spec.insert(QStringLiteral("args"), QStringList{QStringLiteral("--base-arg")}); + spec.insert(QStringLiteral("extraArgs"), QStringList{QStringLiteral("--spec-arg")}); + + const QDBusReply reply = iface.call(QStringLiteral("ResolveLaunch"), spec); + QVERIFY(reply.isValid()); + const QVariantMap out = reply.value(); + QVERIFY(out.value(QStringLiteral("ok")).toBool()); + QCOMPARE(out.value(QStringLiteral("finalProgram")).toString(), QStringLiteral("/bin/true")); + const QStringList expectedFinalArgs = QStringList({ + QStringLiteral("/bin/true"), + QStringLiteral("--base-arg"), + QStringLiteral("--profile-arg"), + QStringLiteral("--spec-arg"), + }); + QCOMPARE(out.value(QStringLiteral("finalArgs")).toStringList(), expectedFinalArgs); + + const QVariantMap env = unwrapVariantMap(out.value(QStringLiteral("effectiveEnv"))); + QCOMPARE(env.value(QStringLiteral("ALAKARTE_TEST_VAR")).toString(), QStringLiteral("1")); + QCOMPARE(env.value(QStringLiteral("WINEDLLOVERRIDES")).toString(), QStringLiteral("d3d11=native,builtin;dxgi=native,builtin")); + } + + { + const QDBusReply reply = iface.call(QStringLiteral("ListGameProfiles")); + QVERIFY(reply.isValid()); + const QVariantList list = reply.value(); + bool found = false; + for (const QVariant &v : list) { + const QVariantMap m = unwrapVariantMap(v); + if (m.value(QStringLiteral("gameId")).toString() == gameId) { + found = true; + break; + } + } + QVERIFY(found); + } + + { + const QDBusReply reply = iface.call(QStringLiteral("ClearGameProfile"), gameId); + QVERIFY(reply.isValid()); + QVERIFY(reply.value().value(QStringLiteral("ok")).toBool()); + } +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + const QStringList args = app.arguments(); + const QString gamecenterPath = takeArgValue(args, QStringLiteral("--gamecenter")); + const QString runnerdPath = takeArgValue(args, QStringLiteral("--runnerd")); + const QString inputdPath = takeArgValue(args, QStringLiteral("--inputd")); + + app.setProperty("alakarte_test_gamecenter", gamecenterPath); + app.setProperty("alakarte_test_runnerd", runnerdPath); + app.setProperty("alakarte_test_inputd", inputdPath); + + QStringList filtered; + filtered.reserve(args.size()); + if (!args.isEmpty()) { + filtered.push_back(args.first()); + } + for (int i = 1; i < args.size(); ++i) { + const QString a = args.at(i); + if (a == QLatin1String("--gamecenter") || a == QLatin1String("--runnerd")) { + ++i; + continue; + } + if (a == QLatin1String("--inputd")) { + ++i; + continue; + } + filtered.push_back(a); + } + + DbusSmokeTest tc; + return QTest::qExec(&tc, filtered); +} + +#include "dbus_smoketest.moc" diff --git a/tests/stop_launching_regression_test.cpp b/tests/stop_launching_regression_test.cpp new file mode 100644 index 0000000..0cde51c --- /dev/null +++ b/tests/stop_launching_regression_test.cpp @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +static QString takeArgValue(const QStringList &args, const QString &key) +{ + const int idx = args.indexOf(key); + if (idx < 0) { + return {}; + } + if (idx + 1 >= args.size()) { + return {}; + } + return args.at(idx + 1); +} + +static bool waitForService(QDBusConnection &bus, const QString &service, int timeoutMs) +{ + if (!bus.isConnected() || !bus.interface()) { + return false; + } + + QElapsedTimer t; + t.start(); + while (t.elapsed() < timeoutMs) { + if (bus.interface()->isServiceRegistered(service)) { + return true; + } + QTest::qWait(50); + } + return false; +} + +static QByteArray readProcFile(const QString &path, qint64 maxSize = 65536) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return f.read(maxSize); +} + +static bool containsNullSeparatedEntry(const QByteArray &blob, const QByteArray &needle) +{ + if (blob.isEmpty() || needle.isEmpty()) { + return false; + } + + int pos = -1; + while (true) { + pos = blob.indexOf(needle, pos + 1); + if (pos < 0) { + return false; + } + + const bool startOk = (pos == 0) || (blob.at(pos - 1) == '\0'); + const int endPos = pos + needle.size(); + const bool endOk = (endPos == blob.size()) || (blob.at(endPos) == '\0'); + if (startOk && endOk) { + return true; + } + } +} + +static QList pidsWithEnvironEntry(const QByteArray &needle) +{ + QList out; + const QDir proc(QStringLiteral("/proc")); + const QStringList entries = proc.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + for (const QString &entry : entries) { + bool ok = false; + const uint pid = entry.toUInt(&ok); + if (!ok || pid <= 1) { + continue; + } + + const QByteArray env = readProcFile(QStringLiteral("/proc/%1/environ").arg(pid)); + if (containsNullSeparatedEntry(env, needle)) { + out.push_back(pid); + } + } + return out; +} + +static bool waitForAnyEnvironEntry(const QByteArray &needle, int timeoutMs) +{ + QElapsedTimer t; + t.start(); + while (t.elapsed() < timeoutMs) { + if (!pidsWithEnvironEntry(needle).isEmpty()) { + return true; + } + QTest::qWait(50); + } + return !pidsWithEnvironEntry(needle).isEmpty(); +} + +static bool waitForNoEnvironEntry(const QByteArray &needle, int timeoutMs) +{ + QElapsedTimer t; + t.start(); + while (t.elapsed() < timeoutMs) { + if (pidsWithEnvironEntry(needle).isEmpty()) { + return true; + } + QTest::qWait(150); + } + return pidsWithEnvironEntry(needle).isEmpty(); +} + +static void killPids(const QList &pids) +{ + for (uint pid : pids) { + if (pid > 1) { + ::kill(static_cast(pid), SIGKILL); + } + } +} + +static void stopProcess(QProcess &p) +{ + if (p.state() == QProcess::NotRunning) { + return; + } + + p.terminate(); + if (!p.waitForFinished(3000)) { + p.kill(); + p.waitForFinished(3000); + } +} + +static bool waitForUnitNotActive(const QString &unitName, int timeoutMs) +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return false; + } + + QDBusInterface manager(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + bus); + if (!manager.isValid()) { + return false; + } + + QElapsedTimer t; + t.start(); + while (t.elapsed() < timeoutMs) { + const QDBusReply unitReply = manager.call(QStringLiteral("GetUnit"), unitName); + if (!unitReply.isValid()) { + if (unitReply.error().name() == QLatin1String("org.freedesktop.systemd1.NoSuchUnit")) { + return true; + } + QTest::qWait(100); + continue; + } + + QDBusInterface props(QStringLiteral("org.freedesktop.systemd1"), unitReply.value().path(), QStringLiteral("org.freedesktop.DBus.Properties"), bus); + if (!props.isValid()) { + return true; + } + + const QDBusReply stateReply = + props.call(QStringLiteral("Get"), QStringLiteral("org.freedesktop.systemd1.Unit"), QStringLiteral("ActiveState")); + if (!stateReply.isValid()) { + return true; + } + + QVariant v = stateReply.value(); + if (v.canConvert()) { + v = v.value().variant(); + } + const QString state = v.toString(); + if (state != QLatin1String("active") && state != QLatin1String("activating") && state != QLatin1String("deactivating")) { + return true; + } + + QTest::qWait(100); + } + + const QDBusReply unitReply = manager.call(QStringLiteral("GetUnit"), unitName); + if (!unitReply.isValid() && unitReply.error().name() == QLatin1String("org.freedesktop.systemd1.NoSuchUnit")) { + return true; + } + return false; +} +} + +class StopLaunchingRegressionTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void cleanupTestCase(); + + void stopWhileLaunchingSteam(); + void stopByGameIdWhileLaunchingSteam(); + void stopWhileLaunchingLutris(); + void stopByGameIdWhileLaunchingLutris(); + + void stopDirectLaunch(); + void stopByGameIdDirectLaunch(); + +private: + QString m_gamecenterPath; + QProcess m_gamecenter; + QString m_testXdgBase; + QTemporaryDir m_stubBin; + QString m_markerValue; +}; + +void StopLaunchingRegressionTest::initTestCase() +{ + m_gamecenterPath = qApp->property("alakarte_test_gamecenter").toString(); + if (m_gamecenterPath.isEmpty()) { + const QStringList args = QCoreApplication::arguments(); + m_gamecenterPath = takeArgValue(args, QStringLiteral("--gamecenter")); + } + QVERIFY2(!m_gamecenterPath.isEmpty(), "--gamecenter is required"); + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected() || !bus.interface()) { + QSKIP("session bus not available"); + } + + QDBusInterface systemd(QStringLiteral("org.freedesktop.systemd1"), + QStringLiteral("/org/freedesktop/systemd1"), + QStringLiteral("org.freedesktop.systemd1.Manager"), + bus); + if (!systemd.isValid()) { + QSKIP("systemd user manager not available on session bus"); + } + + if (bus.interface()->isServiceRegistered(QStringLiteral("org.kde.GameCenter1"))) { + QSKIP("org.kde.GameCenter1 already registered on the session bus"); + } + + if (!m_stubBin.isValid()) { + QSKIP("failed to create temporary directory for stub executables"); + } + + m_markerValue = QUuid::createUuid().toString(QUuid::WithoutBraces); + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + m_testXdgBase = QDir::tempPath() + QStringLiteral("/alakarte-stop-launching-test-xdg-") + QUuid::createUuid().toString(QUuid::WithoutBraces); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/data"))); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/config"))); + QVERIFY(QDir().mkpath(m_testXdgBase + QStringLiteral("/cache"))); + + const QString steamPath = m_stubBin.path() + QStringLiteral("/steam"); + { + QFile steam(steamPath); + QVERIFY(steam.open(QIODevice::WriteOnly | QIODevice::Truncate)); + const QByteArray script = + "#!/bin/sh\n" + "appid=\"\"\n" + "while [ $# -gt 0 ]; do\n" + " if [ \"$1\" = \"-applaunch\" ] && [ $# -ge 2 ]; then appid=\"$2\"; break; fi\n" + " shift\n" + "done\n" + "if [ -z \"$appid\" ]; then appid=\"123456\"; fi\n" + "marker=\"${ALAKARTE_TEST_MARKER_VALUE}\"\n" + "nohup sh -c \"sleep 1; env SteamAppId=$appid SteamGameId=$appid ALAKARTE_TEST_MARKER=$marker /bin/sleep 60\" >/dev/null 2>&1 &\n" + "exit 0\n"; + QVERIFY(steam.write(script) == script.size()); + } + QVERIFY(QFile::setPermissions(steamPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::ExeGroup + | QFileDevice::ReadOther | QFileDevice::ExeOther)); + + const QString lutrisPath = m_stubBin.path() + QStringLiteral("/lutris"); + { + QFile lutris(lutrisPath); + QVERIFY(lutris.open(QIODevice::WriteOnly | QIODevice::Truncate)); + const QByteArray script = + "#!/bin/sh\n" + "id=\"\"\n" + "if [ $# -ge 1 ]; then\n" + " id=\"$1\"\n" + "fi\n" + "case \"$id\" in\n" + " lutris:rungameid/*) id=\"${id##*/}\" ;;\n" + " lutris:rungame/*) id=\"${id##*/}\" ;;\n" + "esac\n" + "if [ -z \"$id\" ]; then id=\"test-lutris\"; fi\n" + "marker=\"${ALAKARTE_TEST_MARKER_VALUE}\"\n" + "nohup sh -c \"sleep 1; env LUTRIS_GAME_SLUG=$id ALAKARTE_TEST_MARKER=$marker /bin/sleep 60\" >/dev/null 2>&1 &\n" + "exit 0\n"; + QVERIFY(lutris.write(script) == script.size()); + } + QVERIFY(QFile::setPermissions(lutrisPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::ExeGroup + | QFileDevice::ReadOther | QFileDevice::ExeOther)); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("PATH"), m_stubBin.path() + QStringLiteral(":") + env.value(QStringLiteral("PATH"))); + env.insert(QStringLiteral("ALAKARTE_TEST_MARKER_VALUE"), m_markerValue); + env.insert(QStringLiteral("XDG_DATA_HOME"), m_testXdgBase + QStringLiteral("/data")); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), m_testXdgBase + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_CACHE_HOME"), m_testXdgBase + QStringLiteral("/cache")); + + m_gamecenter.setProcessEnvironment(env); + m_gamecenter.setProgram(m_gamecenterPath); + m_gamecenter.setArguments({}); + m_gamecenter.setProcessChannelMode(QProcess::MergedChannels); + m_gamecenter.start(); + QVERIFY(m_gamecenter.waitForStarted(5000)); + + QVERIFY2(waitForService(bus, QStringLiteral("org.kde.GameCenter1"), 5000), "GameCenter1 service did not appear on the session bus"); +} + +void StopLaunchingRegressionTest::cleanupTestCase() +{ + stopProcess(m_gamecenter); + + if (!m_markerValue.isEmpty()) { + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + m_markerValue.clear(); + } + + if (!m_testXdgBase.isEmpty()) { + QDir(m_testXdgBase).removeRecursively(); + m_testXdgBase.clear(); + } +} + +void StopLaunchingRegressionTest::stopWhileLaunchingSteam() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QString appId = QStringLiteral("987654"); + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("steam")); + spec.insert(QStringLiteral("command"), QStringLiteral("steam -applaunch %1").arg(appId)); + spec.insert(QStringLiteral("gameId"), QStringLiteral("test-steam-%1").arg(appId)); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QDBusReply stopReply = iface.call(QStringLiteral("Stop"), sessionId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + waitForAnyEnvironEntry(markerNeedle, 5000); + + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + const QList pids = pidsWithEnvironEntry(markerNeedle); + killPids(pids); + } + QVERIFY2(cleaned, "marker process still alive after Stop() during Launching"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after Stop() during Launching"); +} + +void StopLaunchingRegressionTest::stopByGameIdWhileLaunchingSteam() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + const QString appId = QStringLiteral("876543"); + const QString gameId = QStringLiteral("test-steam-stop-by-gameid-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("steam")); + spec.insert(QStringLiteral("command"), QStringLiteral("steam -applaunch %1").arg(appId)); + spec.insert(QStringLiteral("gameId"), gameId); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QDBusReply stopReply = iface.call(QStringLiteral("StopByGameId"), gameId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + waitForAnyEnvironEntry(markerNeedle, 5000); + + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + const QList pids = pidsWithEnvironEntry(markerNeedle); + killPids(pids); + } + QVERIFY2(cleaned, "marker process still alive after StopByGameId() during Launching"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after StopByGameId() during Launching"); +} + +void StopLaunchingRegressionTest::stopWhileLaunchingLutris() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + const QString lutrisId = QStringLiteral("rungameid/%1").arg(QStringLiteral("lutris-test-1")); + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("lutris")); + spec.insert(QStringLiteral("command"), QStringLiteral("lutris lutris:%1").arg(lutrisId)); + spec.insert(QStringLiteral("gameId"), QStringLiteral("test-lutris-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces))); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QDBusReply stopReply = iface.call(QStringLiteral("Stop"), sessionId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + waitForAnyEnvironEntry(markerNeedle, 5000); + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + killPids(pidsWithEnvironEntry(markerNeedle)); + } + QVERIFY2(cleaned, "marker process still alive after Stop() during Launching (lutris)"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after Stop() during Launching (lutris)"); +} + +void StopLaunchingRegressionTest::stopByGameIdWhileLaunchingLutris() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + const QString gameId = QStringLiteral("test-lutris-stop-by-gameid-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("lutris")); + spec.insert(QStringLiteral("command"), QStringLiteral("lutris lutris:rungameid/%1").arg(QStringLiteral("lutris-test-2"))); + spec.insert(QStringLiteral("gameId"), gameId); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QDBusReply stopReply = iface.call(QStringLiteral("StopByGameId"), gameId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + waitForAnyEnvironEntry(markerNeedle, 5000); + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + killPids(pidsWithEnvironEntry(markerNeedle)); + } + QVERIFY2(cleaned, "marker process still alive after StopByGameId() during Launching (lutris)"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after StopByGameId() during Launching (lutris)"); +} + +void StopLaunchingRegressionTest::stopDirectLaunch() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + const QString gameId = QStringLiteral("test-direct-stop-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + + const QString envExe = QStandardPaths::findExecutable(QStringLiteral("env")); + if (envExe.isEmpty()) { + QSKIP("env executable not found"); + } + + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("manual")); + spec.insert(QStringLiteral("program"), envExe); + spec.insert(QStringLiteral("args"), + QStringList{QStringLiteral("ALAKARTE_TEST_MARKER=%1").arg(m_markerValue), QStringLiteral("/bin/sleep"), QStringLiteral("60")}); + spec.insert(QStringLiteral("gameId"), gameId); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QVERIFY2(waitForAnyEnvironEntry(markerNeedle, 5000), "direct launch marker process did not appear"); + + QDBusReply stopReply = iface.call(QStringLiteral("Stop"), sessionId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + killPids(pidsWithEnvironEntry(markerNeedle)); + } + QVERIFY2(cleaned, "marker process still alive after Stop() (direct launch)"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after Stop() (direct launch)"); +} + +void StopLaunchingRegressionTest::stopByGameIdDirectLaunch() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + QSKIP("session bus not available"); + } + + QDBusInterface iface(QStringLiteral("org.kde.GameCenter1"), QStringLiteral("/org/kde/GameCenter1"), QStringLiteral("org.kde.GameCenter1"), bus); + QVERIFY2(iface.isValid(), "GameCenter1 DBus interface not valid"); + + const QByteArray markerNeedle = (QStringLiteral("ALAKARTE_TEST_MARKER=") + m_markerValue).toUtf8(); + killPids(pidsWithEnvironEntry(markerNeedle)); + + const QString gameId = QStringLiteral("test-direct-stop-by-gameid-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + + const QString envExe = QStandardPaths::findExecutable(QStringLiteral("env")); + if (envExe.isEmpty()) { + QSKIP("env executable not found"); + } + + QVariantMap spec; + spec.insert(QStringLiteral("provider"), QStringLiteral("manual")); + spec.insert(QStringLiteral("program"), envExe); + spec.insert(QStringLiteral("args"), + QStringList{QStringLiteral("ALAKARTE_TEST_MARKER=%1").arg(m_markerValue), QStringLiteral("/bin/sleep"), QStringLiteral("60")}); + spec.insert(QStringLiteral("gameId"), gameId); + + QDBusReply launchReply = iface.call(QStringLiteral("Launch"), spec); + QVERIFY2(launchReply.isValid(), qPrintable(launchReply.error().message())); + const QString sessionId = launchReply.value(); + QVERIFY2(!sessionId.isEmpty(), "Launch returned empty sessionId"); + const QString unitName = QStringLiteral("alakarte-game-%1.scope").arg(sessionId); + + QVERIFY2(waitForAnyEnvironEntry(markerNeedle, 5000), "direct launch marker process did not appear"); + + QDBusReply stopReply = iface.call(QStringLiteral("StopByGameId"), gameId); + QVERIFY2(stopReply.isValid(), qPrintable(stopReply.error().message())); + + const bool cleaned = waitForNoEnvironEntry(markerNeedle, 15000); + if (!cleaned) { + killPids(pidsWithEnvironEntry(markerNeedle)); + } + QVERIFY2(cleaned, "marker process still alive after StopByGameId() (direct launch)"); + + QVERIFY2(waitForUnitNotActive(unitName, 15000), "systemd unit still active after StopByGameId() (direct launch)"); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + const QStringList args = app.arguments(); + const QString gamecenterPath = takeArgValue(args, QStringLiteral("--gamecenter")); + app.setProperty("alakarte_test_gamecenter", gamecenterPath); + + QStringList filtered; + filtered.reserve(args.size()); + if (!args.isEmpty()) { + filtered.push_back(args.first()); + } + for (int i = 1; i < args.size(); ++i) { + const QString a = args.at(i); + if (a == QLatin1String("--gamecenter")) { + ++i; + continue; + } + filtered.push_back(a); + } + + StopLaunchingRegressionTest tc; + return QTest::qExec(&tc, filtered); +} + +#include "stop_launching_regression_test.moc"