709 lines
38 KiB
C++
709 lines
38 KiB
C++
// Интеграционные тесты 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);
|
|
}
|
|
|
|
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<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"
|