Files
BiLauncher/tests/backend/tst_launcherbackend.cpp
T

809 lines
43 KiB
C++
Raw Normal View History

2026-10-12 13:14:08 +03:00
// Интеграционные тесты 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 <QDir>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
#include <memory>
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<bool()> &condition, int ms = 30000)
{
return QTest::qWaitFor(condition, ms);
}
QStringList strings(const QSignalSpy &spy, int arg = 0)
{
QStringList out;
for (const QList<QVariant> &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<LauncherBackend> make() { return std::make_unique<LauncherBackend>(); }
// Ждём, пока 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);
}
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
static QStringList populateGameDir(const QString &dir)
{
QDir(dir).removeRecursively();
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
Fixtures::read("versions/" + fabric + ".json"));
Fixtures::writeJson(dir + "/launcher_profiles.json",
{{"profiles", QJsonObject{{"fabric", QJsonObject{
{"type", "custom"}, {"lastVersionId", fabric},
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
Fixtures::write(dir + "/mods/sodium.jar", "jar");
Fixtures::write(dir + "/saves/World/level.dat", "world");
Fixtures::write(dir + "/options.txt", "fov:90");
return Fixtures::listTree(dir);
}
2026-10-12 13:14:08 +03:00
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 firstRunImportsGameDir()
{
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
QFile::remove(LauncherPaths::settingsFile());
const QString game = LauncherPaths::defaultMinecraftDir();
const QStringList before = populateGameDir(game);
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
QCOMPARE(backend->activeBuildIndex(), 0);
const QVariantMap build = backend->customBuildAt(0);
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
.array().at(0).toObject().value("id").toInt();
QCOMPARE(id, 1);
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
// Ничего не качается, ничего не переключается, папка игры та же.
QVERIFY(!backend->downloading());
QVERIFY(!backend->switching());
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
}
QCOMPARE(Fixtures::listTree(game), before);
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
// Второй запуск — уже не первый: новой сборки нет.
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
}
QDir(game).removeRecursively();
}
void importOnlyOnFirstRun()
{
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
populateGameDir(m_game);
{
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QVERIFY(backend->customBuildNames().isEmpty());
}
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
TestEnv::resetLauncherRoot();
QVERIFY(LauncherPaths::ensureRootExists());
const QString game = LauncherPaths::defaultMinecraftDir();
populateGameDir(game);
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
{
auto backend = make();
QTest::qWait(50);
QVERIFY(backend->customBuildNames().isEmpty());
}
QDir(game).removeRecursively();
}
void firstRunWithEmptyGameDirCreatesNothing()
{
QFile::remove(LauncherPaths::settingsFile());
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
auto backend = make();
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
QTest::qWait(50);
QVERIFY(imported.isEmpty());
QCOMPARE(backend->activeBuildIndex(), -1);
}
2026-10-12 13:14:08 +03:00
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<QString, QByteArray> &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"