// Интеграционные тесты LauncherBackend — того, что видит QML. Сервисы под ним // настоящие; сеть — FakeInternet, java — подставная, файлы — в песочнице. #include "testenv.h" #include "fakemojang.h" #include "fixtures.h" #include "nettest.h" #include "javaruntime.h" #include "launcherbackend.h" #include "launcherpaths.h" #include "localization.h" #include #include #include #include #include #include namespace { const QString kSeasonalIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json"); const QString kElyAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate"); const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json"); const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector.jar"); bool waitFor(const std::function &condition, int ms = 30000) { return QTest::qWaitFor(condition, ms); } QStringList strings(const QSignalSpy &spy, int arg = 0) { QStringList out; for (const QList &call : spy) out << call.value(arg).toString(); return out; } } // namespace class TestLauncherBackend : public QObject { Q_OBJECT FakeInternet m_net; QString m_game; void writeSettings(const QJsonObject &extra = {}) { QJsonObject settings{{"gameDir", m_game}, {"language", "en"}}; for (auto it = extra.begin(); it != extra.end(); ++it) settings.insert(it.key(), it.value()); Fixtures::writeJson(LauncherPaths::settingsFile(), settings); } static std::unique_ptr make() { return std::make_unique(); } // Ждём, пока backend освободится от фоновых операций. static bool waitIdle(LauncherBackend &backend, int ms = 30000) { return waitFor([&] { return !backend.busy() && !backend.downloading() && !backend.switching(); }, ms); } // Установить версию через backend и дождаться результата. static bool installVersion(LauncherBackend &backend, const QString &id) { backend.installVersion(id); return waitFor([&] { return backend.isVersionInstalled(id) && !backend.downloading(); }); } QString fakeJava(const QString &name, const QStringList &config = {}) { return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config); } private slots: void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); } void init() { TestEnv::resetLauncherRoot(); m_net.reset(); m_net.setFallback(FakeResponse::error(503)); m_game = TestEnv::freshDir(QStringLiteral("game")); writeSettings(); Localization::instance().setLanguage(QStringLiteral("en")); } // --- данные и настройки ---------------------------------------------------- void freshStartCreatesDataFiles() { QFile::remove(LauncherPaths::settingsFile()); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); QTest::qWait(50); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QVERIFY(QFile::exists(LauncherPaths::settingsFile())); QVERIFY(QFile::exists(LauncherPaths::profilesFile())); QVERIFY(QFile::exists(LauncherPaths::customBuildsFile())); const QVariantMap s = backend->settings(); QCOMPARE(s.value("maxMemoryMb").toInt(), 4096); QCOMPARE(s.value("minMemoryMb").toInt(), 512); QCOMPARE(s.value("language").toString(), QStringLiteral("system")); QCOMPARE(s.value("resolvedGameDir").toString(), QDir::toNativeSeparators(LauncherPaths::defaultMinecraftDir())); QVERIFY(backend->profileNames().isEmpty()); QCOMPARE(backend->activeBuildIndex(), -1); QVERIFY(!backend->busy()); QVERIFY(!backend->microsoftAvailable()); // тестовая сборка — без WebEngine Localization::instance().setLanguage(QStringLiteral("en")); } void profilesCrudAndPersistence() { { auto backend = make(); QSignalSpy changed(backend.get(), &LauncherBackend::profilesChanged); backend->addProfile("Steve", "steve", QString()); backend->addProfile("Ely", "ely@example.com", "pw", "elyby"); backend->addProfile("Odd", "odd", QString(), "something-unknown"); QCOMPARE(changed.count(), 3); QCOMPARE(backend->profileNames(), QStringList({"Steve", "Ely", "Odd"})); QCOMPARE(backend->profileAt(1).value("authType").toString(), QStringLiteral("elyby")); QCOMPARE(backend->profileAt(2).value("authType").toString(), QStringLiteral("offline")); QVERIFY(backend->profileAt(9).isEmpty()); backend->updateProfile(0, "Steve2", "steve2", QString(), "offline"); backend->removeProfile(2); backend->removeProfile(42); // мимо — без последствий QCOMPARE(backend->profileNames(), QStringList({"Steve2", "Ely"})); } const QJsonArray stored = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array(); QCOMPARE(stored.size(), 2); QVERIFY(!stored.at(0).toObject().value("clientToken").toString().isEmpty()); QVERIFY(stored.at(0).toObject().value("id").toInt() != stored.at(1).toObject().value("id").toInt()); auto reloaded = make(); QCOMPARE(reloaded->profileNames(), QStringList({"Steve2", "Ely"})); QCOMPARE(reloaded->profileAt(1).value("login").toString(), QStringLiteral("ely@example.com")); } void elyTokenDroppedWhenCredentialsChange() { Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ {"id", 1}, {"name", "Ely"}, {"login", "a"}, {"password", "p"}, {"authType", "elyby"}, {"clientToken", "c"}, {"accessToken", "tok"}, {"uuid", "u"}, {"resolvedName", "Nick"}}}).toJson()); auto backend = make(); backend->updateProfile(0, "Ely renamed", "a", "p", "elyby"); // только имя — токен живёт QCOMPARE(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())) .array().at(0).toObject().value("accessToken").toString(), QStringLiteral("tok")); backend->updateProfile(0, "Ely renamed", "a", "new-password", "elyby"); const QJsonObject p = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject(); QVERIFY(p.value("accessToken").toString().isEmpty()); QVERIFY(p.value("resolvedName").toString().isEmpty()); QCOMPARE(p.value("clientToken").toString(), QStringLiteral("c")); } void customBuildsAndActiveSwitch() { auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->addCustomBuild("Alpha", "play.example.com"); backend->addCustomBuild("Beta", QString()); QCOMPARE(backend->customBuildNames(), QStringList({"Alpha", "Beta"})); QCOMPARE(backend->activeBuildIndex(), 0); // настройка пуста — активна первая QCOMPARE(backend->activeBuildName(), QStringLiteral("Alpha")); QVERIFY(!backend->downloading()); // без версии игры ставить нечего // Правка версии сбрасывает выбранную под старую версию сборку модлоадера. backend->updateCustomBuild(0, {{"minecraftVersion", "1.20.1"}, {"loader", "fabric"}, {"loaderVersion", "0.16.10"}, {"resolvedVersionId", "fabric-loader-0.16.10-1.20.1"}}); QCOMPARE(backend->customBuildAt(0).value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1")); backend->updateCustomBuild(0, {{"minecraftVersion", "1.21.1"}}); QVariantMap alpha = backend->customBuildAt(0); QCOMPARE(alpha.value("loader").toString(), QStringLiteral("fabric")); QVERIFY(alpha.value("loaderVersion").toString().isEmpty()); QVERIFY(alpha.value("resolvedVersionId").toString().isEmpty()); QCOMPARE(alpha.value("launchVersionId").toString(), QStringLiteral("1.21.1")); QCOMPARE(backend->customBuildAt(1).value("launchVersionId").toString(), QStringLiteral("Beta")); // по имени QCOMPARE(backend->requiredJavaMajor(0), 21); QCOMPARE(backend->requiredJavaMajor(5), 0); // Переключение активной сборки уносит содержимое .minecraft в архив. Fixtures::write(m_game + "/mods/alpha.jar", "a"); QSignalSpy active(backend.get(), &LauncherBackend::activeBuildChanged); backend->setActiveBuildIndex(1); QVERIFY(waitIdle(*backend)); QCOMPARE(backend->activeBuildIndex(), 1); QVERIFY(active.count() > 0); QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar")); QCOMPARE(backend->settings().value("activeBuildId").toInt(), Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt()); const QVariantMap info = backend->customBuildRemovalInfo(0); QCOMPARE(info.value("name").toString(), QStringLiteral("Alpha")); QVERIFY(info.value("hasArchive").toBool()); QVERIFY(!info.value("active").toBool()); // Удаление активной — активной становится первая из оставшихся, её архив разворачивается. backend->removeCustomBuild(1); QVERIFY(waitIdle(*backend)); QCOMPARE(backend->customBuildNames(), QStringList{"Alpha"}); QCOMPARE(backend->activeBuildIndex(), 0); QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("a")); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); } void settingsPersistAndGameDirChange() { Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", m_game}, {"language", "en"}, {"launcherName", "old"}, {"launcherVersion", "1.0"}}); const QString other = TestEnv::freshDir(QStringLiteral("other-game")); { auto backend = make(); QSignalSpy installedChanged(backend.get(), &LauncherBackend::installedVersionsChanged); QSignalSpy settingsChanged(backend.get(), &LauncherBackend::settingsChanged); backend->updateSettings({{"maxMemoryMb", 2048}, {"jvmArgs", "-XX:+UseZGC"}}); QCOMPARE(installedChanged.count(), 0); backend->updateSettings({{"gameDir", other}}); QCOMPARE(installedChanged.count(), 1); QCOMPARE(settingsChanged.count(), 2); QCOMPARE(backend->settings().value("resolvedGameDir").toString(), QDir::toNativeSeparators(other)); } const QJsonObject stored = Fixtures::readJsonFile(LauncherPaths::settingsFile()); QCOMPARE(stored.value("maxMemoryMb").toInt(), 2048); QVERIFY(!stored.contains("launcherName")); // устаревшие ключи вычищены QVERIFY(!stored.contains("launcherVersion")); auto reloaded = make(); QCOMPARE(reloaded->settings().value("jvmArgs").toString(), QStringLiteral("-XX:+UseZGC")); } void languageSettingSwitchesLocalization() { auto backend = make(); backend->updateSettings({{"language", "ru"}}); QCOMPARE(Localization::instance().language(), QStringLiteral("ru")); backend->updateSettings({{"language", "en"}}); QCOMPARE(Localization::instance().language(), QStringLiteral("en")); } void legacyBuildsFileIsMigrated() { Fixtures::write(LauncherPaths::legacyCustomBuildsFile(), QJsonDocument(QJsonArray{ QJsonObject{{"id", 3}, {"name", "Old build"}, {"serverUrl", "mc.example.com"}, {"versionId", "1.12.2"}}}).toJson()); auto backend = make(); QCOMPARE(backend->customBuildNames(), QStringList{"Old build"}); QCOMPARE(backend->customBuildAt(0).value("minecraftVersion").toString(), QStringLiteral("1.12.2")); QVERIFY(QFile::exists(LauncherPaths::customBuildsFile())); QVERIFY(!QFile::exists(LauncherPaths::legacyCustomBuildsFile())); QVERIFY(QFile::exists(LauncherPaths::legacyCustomBuildsFile() + ".migrated")); // Новые сборки получают id после старых. backend->addCustomBuild("New", QString()); const QJsonArray builds = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).array(); QCOMPARE(builds.at(1).toObject().value("id").toInt(), 4); } void corruptFilesAreSetAsideAndReported() { Fixtures::write(LauncherPaths::profilesFile(), "{ this is not json"); Fixtures::write(LauncherPaths::customBuildsFile(), "{\"object\": \"not an array\"}"); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); QVERIFY(errors.wait(2000)); // сообщение откладывается до подключения интерфейса const QString message = errors.constFirst().constFirst().toString(); QVERIFY(message.contains(QDir::toNativeSeparators(LauncherPaths::profilesFile() + ".bak"))); QVERIFY(QFile::exists(LauncherPaths::profilesFile() + ".bak")); QVERIFY(QFile::exists(LauncherPaths::customBuildsFile() + ".bak")); // Свежие файлы созданы заново и валидны. QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).isArray()); QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).isArray()); QVERIFY(backend->profileNames().isEmpty()); } void unusableBuildRecordsAreSkipped() { Fixtures::write(LauncherPaths::customBuildsFile(), QJsonDocument(QJsonArray{ QJsonObject{{"id", 1}, {"name", " "}}, QJsonObject{{"id", 2}, {"name", "Good"}}}).toJson()); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); QVERIFY(errors.wait(2000)); QCOMPARE(backend->customBuildNames(), QStringList{"Good"}); } // --- версии ------------------------------------------------------------------ void versionCatalogInstallAndRemove() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"}); Fixtures::write(m_game + "/versions/my-pack/my-pack.json", "{\"mainClass\":\"x\"}"); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->refreshVersionCatalog(); QVERIFY(waitFor([&] { return backend->versionCatalog().size() == 3; })); const QVariantList catalog = backend->versionCatalog(); QCOMPARE(catalog.at(0).toMap().value("id").toString(), QStringLiteral("1.20.1")); QCOMPARE(catalog.at(0).toMap().value("category").toString(), QStringLiteral("release")); QVERIFY(!catalog.at(0).toMap().value("installed").toBool()); QCOMPARE(catalog.at(2).toMap().value("id").toString(), QStringLiteral("my-pack")); // неизвестная манифесту QCOMPARE(catalog.at(2).toMap().value("category").toString(), QStringLiteral("other")); QSignalSpy installed(backend.get(), &LauncherBackend::installedVersionsChanged); QVERIFY(installVersion(*backend, "1.20.1")); QVERIFY(installed.count() > 0); QVERIFY(backend->installedVersions().contains("1.20.1")); QVERIFY(backend->versionCatalog().at(0).toMap().value("installed").toBool()); backend->addCustomBuild("Uses 1.20.1", QString(), "1.20.1"); const QVariantMap info = backend->versionRemovalInfo("1.20.1"); QVERIFY(info.value("installed").toBool()); QVERIFY(info.value("sizeMb").toInt() >= 1); QCOMPARE(info.value("builds").toStringList(), QStringList{"Uses 1.20.1"}); backend->removeVersion("1.20.1"); QVERIFY(!backend->isVersionInstalled("1.20.1")); QVERIFY(!QDir(m_game + "/versions/1.20.1").exists()); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); } void addingBuildWithVersionStartsDownload() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); auto backend = make(); backend->addCustomBuild("Auto", QString(), "1.20.1"); QVERIFY(backend->downloading()); QCOMPARE(backend->downloadVersion(), QStringLiteral("1.20.1")); QVERIFY(waitFor([&] { return backend->isVersionInstalled("1.20.1") && !backend->downloading(); })); QVERIFY(backend->checkInstallation(0).isEmpty()); } void checkInstallationMessages() { auto backend = make(); QCOMPARE(backend->checkInstallation(0), QStringList{Loc::text("common.status.noBuildSelected")}); backend->addCustomBuild("Missing", QString()); QStringList issues = backend->checkInstallation(0); QCOMPARE(issues.size(), 1); QVERIFY(!issues.constFirst().isEmpty()); } void downloadFailureIsReported() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); mojang.overrideFile("versions/1.20.1/1.20.1.jar", FakeResponse::error(404)); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->installVersion("1.20.1"); QVERIFY(errors.wait(30000)); QVERIFY(errors.constFirst().constFirst().toString().startsWith( Loc::text("version.error.downloadFailed").arg("1.20.1", QString()).section(u'%', 0, 0))); QVERIFY(waitIdle(*backend)); } // --- запуск -------------------------------------------------------------- void launchValidationErrors() { auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->launchGame(0, 0); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noProfile")); backend->addProfile("Steve", "steve", QString()); backend->launchGame(0, 0); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noBuild")); backend->addCustomBuild("B", QString()); backend->launchGame(0, 0); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noVersionsInstalled").arg(QDir::toNativeSeparators(m_game + "/versions"))); QVERIFY(!backend->busy()); } void offlineLaunchRunsJava() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString argsFile = m_game + "/../game-args.txt"; const QString java = fakeJava("jdk-launch", {"args_file=" + argsFile, "stdout=[Render thread/INFO]: Setting user: Steve"}); writeSettings({{"javaPath", java}, {"maxMemoryMb", 3072}, {"jvmArgs", "-Dtest.flag=1"}}); auto backend = make(); QVERIFY(installVersion(*backend, "1.20.1")); backend->addProfile("Steve", "steve", QString()); backend->addCustomBuild("Server build", "play.example.com:25570", "1.20.1"); QVERIFY(waitIdle(*backend)); QSignalSpy launched(backend.get(), &LauncherBackend::launched); QSignalSpy finished(backend.get(), &LauncherBackend::gameFinished); QSignalSpy output(backend.get(), &LauncherBackend::gameOutput); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->launchGame(0, 0); QVERIFY2(waitFor([&] { return launched.count() + errors.count() > 0; }), "нет ни launched, ни launchError"); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QCOMPARE(launched.constFirst().at(0).toString(), QStringLiteral("Steve")); QCOMPARE(launched.constFirst().at(1).toString(), QStringLiteral("Server build")); QVERIFY(waitFor([&] { return finished.count() == 1; })); QVERIFY(!backend->gameRunning()); QVERIFY(!backend->busy()); QVERIFY(strings(output).contains("[Render thread/INFO]: Setting user: Steve")); const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); QVERIFY(args.contains("-Xmx3072M")); QVERIFY(args.contains("-Dtest.flag=1")); QVERIFY(args.contains("net.minecraft.client.main.Main")); const qsizetype server = args.indexOf("--server"); QVERIFY(server > 0); QCOMPARE(args.at(server + 1), QStringLiteral("play.example.com")); QCOMPARE(args.at(server + 3), QStringLiteral("25570")); } void elyLaunchWithTwoFactor() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}})); m_net.route(kInjectorJar, FakeResponse::bytes("injector")); m_net.route(kElyAuth, [](const FakeRequest &request) { const QJsonObject in = QJsonDocument::fromJson(request.body).object(); if (in.value("password").toString() == QLatin1String("pw:654321")) return FakeResponse::json(QJsonObject{{"accessToken", "ely-token"}, {"selectedProfile", QJsonObject{{"id", "aaaa-bbbb"}, {"name", "ElyNick"}}}}); return FakeResponse::json(QJsonObject{{"errorMessage", "Account protected with two factor auth."}}, 401); }); const QString argsFile = m_game + "/../ely-args.txt"; writeSettings({{"javaPath", fakeJava("jdk-ely", {"args_file=" + argsFile})}}); auto backend = make(); QVERIFY(installVersion(*backend, "1.20.1")); backend->addProfile("Ely", "ely@example.com", "pw", "elyby"); backend->addCustomBuild("B", QString(), "1.20.1"); QVERIFY(waitIdle(*backend)); QSignalSpy twoFactor(backend.get(), &LauncherBackend::twoFactorRequired); QSignalSpy launched(backend.get(), &LauncherBackend::launched); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->launchGame(0, 0); QVERIFY(waitFor([&] { return twoFactor.count() + errors.count() > 0; })); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QCOMPARE(twoFactor.constFirst().constFirst().toString(), QStringLiteral("Ely")); QVERIFY(!backend->busy()); // ждём ввода кода backend->submitTwoFactorCode(" "); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.ely.error.enterCode")); errors.clear(); backend->submitTwoFactorCode("654321"); QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; })); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000); QTRY_VERIFY_WITH_TIMEOUT(!backend->gameRunning(), 15000); const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); QVERIFY(args.constFirst().startsWith("-javaagent:")); QVERIFY(args.constFirst().endsWith("=ely.by")); QVERIFY(args.contains("ElyNick")); QVERIFY(args.contains("ely-token")); // Токен и ник запомнены — в следующий раз пароль не понадобится. const QJsonObject profile = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject(); QCOMPARE(profile.value("accessToken").toString(), QStringLiteral("ely-token")); QCOMPARE(profile.value("resolvedName").toString(), QStringLiteral("ElyNick")); QCOMPARE(profile.value("uuid").toString(), QStringLiteral("aaaabbbb")); } void microsoftProfileWithFreshTokenLaunchesOffline() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); const QString argsFile = m_game + "/../ms-args.txt"; writeSettings({{"javaPath", fakeJava("jdk-ms", {"args_file=" + argsFile})}}); Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ {"id", 1}, {"name", "MS"}, {"authType", "microsoft"}, {"clientToken", "c"}, {"accessToken", "mc-token"}, {"refreshToken", "r"}, {"uuid", "069a79f444e94726a5befca90e38aaf5"}, {"resolvedName", "Notch"}, {"xuid", "253"}, {"userType", "msa"}, {"tokenExpiry", QDateTime::currentDateTimeUtc().addSecs(3600).toString(Qt::ISODate)}}}).toJson()); auto backend = make(); QVERIFY(installVersion(*backend, "1.20.1")); backend->addCustomBuild("B", QString(), "1.20.1"); QVERIFY(waitIdle(*backend)); const int requestsBefore = m_net.requests().size(); QSignalSpy launched(backend.get(), &LauncherBackend::launched); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->launchGame(0, 0); QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; })); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QCOMPARE(m_net.requests().size(), requestsBefore); // токен жив — в Microsoft не ходили QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000); const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); QVERIFY(args.contains("Notch")); QVERIFY(args.contains("mc-token")); } void microsoftProfileWithoutSessionAsksRelogin() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ {"id", 1}, {"name", "MS"}, {"authType", "microsoft"}}}).toJson()); auto backend = make(); QVERIFY(installVersion(*backend, "1.20.1")); backend->addCustomBuild("B", QString(), "1.20.1"); QVERIFY(waitIdle(*backend)); QSignalSpy relogin(backend.get(), &LauncherBackend::microsoftReloginRequired); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->launchGame(0, 0); QCOMPARE(relogin.count(), 1); QCOMPARE(relogin.constFirst().constFirst().toInt(), 0); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.msa.error.profileNeedsLogin").arg("MS")); QVERIFY(!backend->busy()); } void microsoftLoginNeedsWebEngine() { auto backend = make(); QSignalSpy failed(backend.get(), &LauncherBackend::microsoftLoginFailed); backend->startMicrosoftLogin(-1); QCOMPARE(failed.count(), 1); QCOMPARE(failed.constFirst().constFirst().toString(), Loc::text("auth.msa.error.noWebEngine")); const QVariantMap redirect = backend->inspectMicrosoftRedirect( "https://login.live.com/oauth20_desktop.srf?code=abc"); QVERIFY(redirect.value("matched").toBool()); QCOMPARE(redirect.value("code").toString(), QStringLiteral("abc")); QVERIFY(!backend->inspectMicrosoftRedirect("https://example.com/").value("matched").toBool()); } // --- Java ---------------------------------------------------------------- void javaCatalogInstallSelectRemove() { const QByteArray zip = Fixtures::zipBytes({{QStringLiteral("jdk-21/bin/") + Fixtures::javaExecutableName(), "bin"}}); m_net.route("https://api.adoptium.net/v3/info/available_releases", FakeResponse::json(QJsonObject{{"available_lts_releases", QJsonArray{21}}, {"available_releases", QJsonArray{21}}})); m_net.route("https://api.adoptium.net/v3/assets/feature_releases/*", [zip](const FakeRequest &request) { const QString arch = request.queryItems().queryItemValue("architecture"); const QJsonObject binary{ {"architecture", arch}, {"image_type", "jdk"}, {"package", QJsonObject{{"name", "jdk.zip"}, {"link", "https://github.com/adoptium/x/jdk.zip"}, {"checksum", Fixtures::sha256(zip)}, {"size", zip.size()}}}}; const QJsonArray releases{QJsonObject{ {"release_name", "jdk-21.0.2+13"}, {"timestamp", "2025-01-20T00:00:00Z"}, {"binaries", QJsonArray{binary}}, {"version_data", QJsonObject{{"major", 21}, {"openjdk_version", "21.0.2+13-LTS"}}}}}; return FakeResponse::json(QJsonDocument(releases).toJson()); }); m_net.route("https://github.com/adoptium/x/jdk.zip", FakeResponse::bytes(zip)); auto backend = make(); QSignalSpy installedSpy(backend.get(), &LauncherBackend::javaRuntimeInstalled); backend->refreshJavaCatalog(); QVERIFY(waitFor([&] { return !backend->javaCatalog().isEmpty() && !backend->javaCatalogLoading(); })); const QVariantMap row = backend->javaCatalog().constFirst().toMap(); QCOMPARE(row.value("major").toInt(), 21); QVERIFY(row.value("lts").toBool()); QVERIFY(!row.value("installed").toBool()); const QString id = row.value("id").toString(); QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool()); backend->installJavaRuntime(id); QVERIFY(waitFor([&] { return installedSpy.count() == 1; })); QCOMPARE(installedSpy.constFirst().constFirst().toString(), id); QVERIFY(backend->javaRuntimeInfo(id).value("installed").toBool()); QVERIFY(backend->javaCatalog().constFirst().toMap().value("installed").toBool()); // Выбранная сборка Java попадает в настройки, а при удалении настройка очищается. backend->updateSettings({{"javaRuntime", id}}); const QString javaPath = backend->javaRuntimeInfo(id).value("javaPath").toString(); QVERIFY(backend->detectedJava().join('\n').contains(javaPath)); backend->removeJavaRuntime(id); QVERIFY(backend->settings().value("javaRuntime").toString().isEmpty()); QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool()); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->installJavaRuntime("no-such-runtime"); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("java.error.buildNotInCatalog").arg("no-such-runtime")); } // --- сезонные сборки -------------------------------------------------------- void seasonalBuildEndToEnd() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); // Java 17 уже скачана лаунчером — шаг Java пропускается. InstalledJavaRuntime runtime; runtime.id = QStringLiteral("temurin-jdk-17-test"); runtime.kind = QStringLiteral("jdk"); runtime.major = 17; runtime.version = QStringLiteral("17.0.8"); runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8"); QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr)); const auto publishSeason = [&](int revision, const QMap &files) { const QByteArray pack = Fixtures::zipBytes(files); const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-6-r%1.zip").arg(revision); m_net.route(url, FakeResponse::bytes(pack)); const QJsonObject entry{ {"id", "season-6"}, {"name", "Season 6"}, {"revision", revision}, {"minecraftVersion", "1.20.1"}, {"loader", ""}, {"loaderVersion", ""}, {"modCount", files.size()}, {"seasonStart", "2026-01-01"}, {"serverUrl", "season6.example.com"}, {"javaMajor", 17}, {"archive", QJsonObject{{"url", url}, {"size", pack.size()}, {"sha256", Fixtures::sha256(pack)}}}}; m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}})); }; publishSeason(1, {{"mods/a.jar", "a1"}, {"mods/old.jar", "old"}, {"config/c.toml", "c=1"}}); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); QSignalSpy done(backend.get(), &LauncherBackend::seasonalInstallFinished); backend->refreshSeasonalCatalog(); QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; })); QVariantMap row = backend->seasonalCatalog().constFirst().toMap(); QCOMPARE(row.value("status").toString(), Loc::text("seasonal.status.notInstalled")); QCOMPARE(row.value("loaderTitle").toString(), Loc::text("loader.label.vanilla")); QCOMPARE(row.value("seasonStart").toString(), QStringLiteral("01.01.2026")); backend->installSeasonalBuild("season-6"); QVERIFY(backend->seasonalInstalling()); QVERIFY(backend->busy()); QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000)); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QCOMPARE(done.constFirst().at(0).toString(), QStringLiteral("season-6")); QVERIFY(!backend->seasonalInstalling()); QVERIFY(waitIdle(*backend)); // Сборка заведена, активна, версия игры стоит, файлы пака разложены, ревизия записана. QCOMPARE(backend->customBuildNames(), QStringList{"Season 6"}); QCOMPARE(backend->activeBuildIndex(), 0); QVERIFY(backend->checkInstallation(0).isEmpty()); QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a1")); QCOMPARE(Fixtures::readFile(m_game + "/config/c.toml"), QByteArray("c=1")); const QVariantMap build = backend->customBuildAt(0); QCOMPARE(build.value("seasonalId").toString(), QStringLiteral("season-6")); QCOMPARE(build.value("seasonalRevision").toInt(), 1); QCOMPARE(build.value("serverUrl").toString(), QStringLiteral("season6.example.com")); const int buildId = Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(); const QJsonObject state = Fixtures::readJsonFile(LauncherPaths::seasonalStateFile(buildId)); QCOMPARE(state.value("revision").toInt(), 1); QCOMPARE(state.value("entries").toArray().size(), 3); QCOMPARE(backend->seasonalCatalog().constFirst().toMap().value("status").toString(), Loc::text("seasonal.status.installed")); // Ревизия 2: мод old.jar из сборки убрали, a.jar обновили. Пользовательский мир не трогаем. Fixtures::write(m_game + "/saves/MyWorld/level.dat", "mine"); publishSeason(2, {{"mods/a.jar", "a2"}, {"config/c.toml", "c=2"}}); backend->refreshSeasonalCatalog(true); QVERIFY(waitFor([&] { return backend->seasonalCatalog().constFirst().toMap().value("updateAvailable").toBool(); })); done.clear(); backend->installSeasonalBuild("season-6"); QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000)); QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a2")); QVERIFY(!QFile::exists(m_game + "/mods/old.jar")); QCOMPARE(Fixtures::readFile(m_game + "/saves/MyWorld/level.dat"), QByteArray("mine")); QCOMPARE(backend->customBuildNames().size(), 1); // та же запись, а не вторая QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 2); // Служебный файл пака за собой не оставляем. QVERIFY(QDir(LauncherPaths::buildDir(buildId)).entryList({".pack-*"}, QDir::Files | QDir::Hidden).isEmpty()); } void seasonalPackChecksumFailureAborts() { FakeMojang mojang(m_net); mojang.publish({.id = "1.20.1"}); InstalledJavaRuntime runtime; runtime.id = QStringLiteral("temurin-jdk-17-test2"); runtime.kind = QStringLiteral("jdk"); runtime.major = 17; runtime.version = QStringLiteral("17.0.8"); runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8"); QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr)); const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/bad.zip"); m_net.route(url, FakeResponse::bytes("not what was promised")); const QJsonObject entry{ {"id", "bad-season"}, {"name", "Bad"}, {"revision", 1}, {"minecraftVersion", "1.20.1"}, {"loader", ""}, {"javaMajor", 17}, {"archive", QJsonObject{{"url", url}, {"size", 21}, {"sha256", QString(64, u'0')}}}}; m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}})); auto backend = make(); QSignalSpy errors(backend.get(), &LauncherBackend::launchError); backend->refreshSeasonalCatalog(); QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; })); backend->installSeasonalBuild("bad-season"); QVERIFY(waitFor([&] { return !backend->seasonalInstalling(); }, 60000)); const QString all = strings(errors).join('\n'); QVERIFY2(all.contains(Loc::text("seasonal.error.checksumMismatch")), qPrintable(all)); QVERIFY(!backend->busy()); QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 0); // не отмечена установленной backend->installSeasonalBuild("unknown-id"); QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("seasonal.error.notInCatalog").arg("unknown-id")); } }; BILAUNCHER_GUI_TEST_MAIN(TestLauncherBackend) #include "tst_launcherbackend.moc"