new unit tests, hehe 67
tests / linux (push) Failing after 2m18s
tests / windows (push) Skipped
tests / macos (push) Skipped
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped

Signed-off-by: galeon <dertiellis@gmail.com>
This commit is contained in:
galeon committed 2026-10-12 13:14:08 +03:00
1 parent ebf8ff7869
commit 41599fa82f
67 files changed
+8775 -4

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Модульные тесты: без сети, только файлы в песочнице и подставная java.
foreach(test IN ITEMS
tst_launcherpaths
tst_javaruntime
tst_modloader
tst_localization
tst_minecraftversion
tst_authoffline
tst_msaredirect
tst_gamelauncher
tst_javalocator
tst_zlibreference
tst_buildarchiveworker
tst_buildswitcher)
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
endforeach()
+67
View File
@@ -0,0 +1,67 @@
#include "testenv.h"
#include "authservice.h"
#include "localization.h"
#include <QCryptographicHash>
#include <QRegularExpression>
#include <QUuid>
class TestAuthOffline : public QObject
{
Q_OBJECT
private slots:
void emptyNicknameFails()
{
const AuthResult result = AuthService::offline(QString());
QVERIFY(!result.ok);
QCOMPARE(result.error, Loc::text("auth.offline.error.noNickname"));
}
void uuidMatchesVanillaOfflineScheme()
{
// Так же UUID офлайн-игрока считает сервер в offline-mode:
// UUID v3 от MD5("OfflinePlayer:<ник>").
const AuthResult result = AuthService::offline(QStringLiteral("Notch"));
QVERIFY(result.ok);
QCOMPARE(result.playerName, QStringLiteral("Notch"));
QCOMPARE(result.uuid, QStringLiteral("b50ad385829d3141a2167e7d7539ba7f"));
QCOMPARE(result.accessToken, QStringLiteral("0"));
QCOMPARE(result.clientToken, QStringLiteral("0"));
QCOMPARE(result.userType, QStringLiteral("legacy"));
}
void uuidIsStableAndWellFormed_data()
{
QTest::addColumn<QString>("nick");
QTest::newRow("ascii") << "Steve";
QTest::newRow("digits") << "player_123";
QTest::newRow("cyrillic") << QStringLiteral("Игрок");
}
void uuidIsStableAndWellFormed()
{
QFETCH(QString, nick);
const AuthResult a = AuthService::offline(nick);
const AuthResult b = AuthService::offline(nick);
QCOMPARE(a.uuid, b.uuid);
static const QRegularExpression hex(QStringLiteral("^[0-9a-f]{32}$"));
QVERIFY(hex.match(a.uuid).hasMatch());
QCOMPARE(a.uuid.at(12), QChar(u'3')); // версия 3
QVERIFY(QStringLiteral("89ab").contains(a.uuid.at(16))); // вариант RFC 4122
QVERIFY(AuthService::offline(nick + u'x').uuid != a.uuid);
}
void clientTokenIsRandomUuid()
{
const QString a = AuthService::generateClientToken();
const QString b = AuthService::generateClientToken();
QVERIFY(a != b);
QVERIFY(!QUuid::fromString(a).isNull());
QVERIFY(!a.startsWith(u'{'));
}
};
BILAUNCHER_TEST_MAIN(TestAuthOffline)
#include "tst_authoffline.moc"
+236
View File
@@ -0,0 +1,236 @@
#include "testenv.h"
#include "fixtures.h"
#include "buildarchiveworker.h"
#include "localization.h"
#include <QDir>
#include <QSignalSpy>
class TestBuildArchiveWorker : public QObject
{
Q_OBJECT
QString m_game;
QString m_work;
void populateGame()
{
Fixtures::write(m_game + "/options.txt", "fov:90");
Fixtures::write(m_game + "/mods/a.jar", "mod-a");
Fixtures::write(m_game + "/mods/sub/b.jar", "mod-b");
Fixtures::write(m_game + "/config/x/y.toml", "y=1");
Fixtures::write(m_game + "/saves/World/level.dat", "level"); // общее — исключается
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}"); // общее — исключается
}
// Последний вызов finished(): (ok, error)
static QPair<bool, QString> result(const QSignalSpy &spy)
{
if (spy.isEmpty())
return {false, QStringLiteral("finished не пришёл")};
return {spy.constLast().at(0).toBool(), spy.constLast().at(1).toString()};
}
private slots:
void init()
{
m_game = TestEnv::freshDir(QStringLiteral("game"));
m_work = TestEnv::freshDir(QStringLiteral("archives"));
}
void archiveClearRestoreRoundTrip()
{
populateGame();
const QStringList shared{"saves", "versions"};
const QString zip = m_work + "/build.zip";
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
QSignalSpy progress(&worker, &BuildArchiveWorker::progress);
worker.archive(m_game, zip, shared);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::zipEntries(zip),
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar", "options.txt"}));
// progress: (0,total) в начале и (total,total) в конце.
QCOMPARE(progress.constFirst().at(0).toInt(), 0);
QCOMPARE(progress.constLast().at(0).toInt(), 4);
QCOMPARE(progress.constLast().at(1).toInt(), 4);
worker.clear(m_game, shared);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
worker.restore(zip, m_game);
QCOMPARE(result(finished), qMakePair(true, QString()));
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar",
"options.txt", "saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
QCOMPARE(Fixtures::readFile(m_game + "/mods/sub/b.jar"), QByteArray("mod-b"));
}
void dotFilesSurviveRoundTrip()
{
// В .minecraft живут .fabric/, .mixin.out/, на macOS ещё .DS_Store.
Fixtures::write(m_game + "/.fabric/remappedJars/x.jar", "cache");
Fixtures::write(m_game + "/mods/a.jar", "a");
const QString zip = m_work + "/dots.zip";
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.archive(m_game, zip, {});
QVERIFY(result(finished).first);
worker.clear(m_game, {});
worker.restore(zip, m_game);
QVERIFY(result(finished).first);
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
// QZipReader при чтении срезает ведущие «.» и «/» в именах записей, поэтому
// после переключения сборок .fabric/ возвращается как fabric/.
QEXPECT_FAIL("", "QZipReader срезает ведущую точку: .fabric/ восстанавливается как fabric/", Continue);
QVERIFY(QFile::exists(m_game + "/.fabric/remappedJars/x.jar"));
}
void archiveMissingGameDir()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
const QString missing = m_game + "/nope";
worker.archive(missing, m_work + "/x.zip", {});
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(missing))));
}
void archiveCancelRemovesTempZip()
{
populateGame();
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.archive(m_game, m_work + "/cancel.zip", {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.archivingCancelled")));
QVERIFY(!QFile::exists(m_work + "/cancel.zip"));
worker.clearCancel();
worker.archive(m_game, m_work + "/ok.zip", {});
QVERIFY(result(finished).first);
}
void clearMissingDirIsOk()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.clear(m_game + "/nope", {});
QVERIFY(result(finished).first);
}
void restoreRejectsUnreadableAndUnsafeArchives()
{
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
const QString missing = m_work + "/missing.zip";
worker.restore(missing, m_game);
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(missing))));
// Zip-slip: запись с «..» не должна вылезти из папки игры.
const QString evil = m_work + "/evil.zip";
// Ведущие «../» QZipReader срезает сам, а «..» в середине пути — нет.
QVERIFY(Fixtures::makeZip(evil, {{"ok.txt", "ok"}, {"mods/../../escaped.txt", "pwned"}}));
QVERIFY(Fixtures::zipEntries(evil).contains("mods/../../escaped.txt"));
worker.restore(evil, m_game);
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.archiveBadPath").arg("mods/../../escaped.txt")));
QVERIFY(!QFile::exists(QFileInfo(m_game).absolutePath() + "/escaped.txt"));
}
void restoreCancel()
{
const QString zip = m_work + "/r.zip";
QVERIFY(Fixtures::makeZip(zip, {{"a.txt", "a"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.restore(zip, m_game);
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.extractCancelled")));
}
void applyPackRemovesThenWrites()
{
populateGame();
Fixtures::write(m_game + "/mods/old/legacy.jar", "old");
Fixtures::write(m_game + "/mods/keep.jar", "keep");
const QString pack = m_work + "/pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"},
{"config/x/y.toml", "y=2"},
{"options.txt", "fov:70"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
QSignalSpy entries(&worker, &BuildArchiveWorker::packEntries);
worker.applyPack(pack, m_game,
{"mods/old/legacy.jar", "mods/missing.jar",
"../outside.txt", "versions/1.20.1/1.20.1.json"},
{"versions", "saves"});
QCOMPARE(result(finished), qMakePair(true, QString()));
QVERIFY(!QFile::exists(m_game + "/mods/old/legacy.jar"));
QVERIFY(!QDir(m_game + "/mods/old").exists()); // опустевшая папка убрана
QVERIFY(QFile::exists(m_game + "/mods/keep.jar"));
// Удаление в запретной зоне молча пропускается.
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
QCOMPARE(Fixtures::readFile(m_game + "/config/x/y.toml"), QByteArray("y=2"));
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("fov:70"));
QCOMPARE(entries.count(), 1);
QStringList applied = entries.constFirst().constFirst().toStringList();
applied.sort();
QCOMPARE(applied, QStringList({"config/x/y.toml", "mods/new.jar", "options.txt"}));
}
void applyPackRefusesLauncherFolders()
{
populateGame();
const QString pack = m_work + "/bad.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"versions/hack/hack.json", "{}"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.applyPack(pack, m_game, {"mods/a.jar"}, {"versions"});
QCOMPARE(result(finished),
qMakePair(false, Loc::text("switch.error.packTouchesLauncher").arg("versions/hack/hack.json")));
// Проверка до любых изменений: ничего не удалено и не записано.
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
QVERIFY(!QFile::exists(m_game + "/mods/new.jar"));
}
void applyPackRefusesUnsafePaths()
{
const QString pack = m_work + "/slip.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/../../../etc/x", "x"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.applyPack(pack, m_game, {}, {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.packBadPath").arg("mods/../../../etc/x")));
}
void applyPackCancel()
{
const QString pack = m_work + "/c.zip";
QVERIFY(Fixtures::makeZip(pack, {{"a.txt", "a"}}));
BuildArchiveWorker worker;
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
worker.requestCancel();
worker.applyPack(pack, m_game, {}, {});
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.installCancelled")));
QVERIFY(!QFile::exists(m_game + "/a.txt"));
}
};
BILAUNCHER_TEST_MAIN(TestBuildArchiveWorker)
#include "tst_buildarchiveworker.moc"
+302
View File
@@ -0,0 +1,302 @@
#include "testenv.h"
#include "fixtures.h"
#include "buildswitcher.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QJsonArray>
#include <QSignalSpy>
class TestBuildSwitcher : public QObject
{
Q_OBJECT
QString m_game;
static QJsonObject index()
{
return Fixtures::readJsonFile(LauncherPaths::buildStorageDir() + "/index.json");
}
static QStringList zipsOf(int buildId)
{
return QDir(LauncherPaths::buildDir(buildId)).entryList({"*.zip"}, QDir::Files | QDir::Hidden);
}
// Ждёт finished или failed; возвращает true при finished(expectedId).
static bool waitDone(QSignalSpy &finished, QSignalSpy &failed, int expectedId)
{
if (!QTest::qWaitFor([&] { return finished.count() + failed.count() > 0; }, 20000)) {
qWarning("ни finished, ни failed за 20 с");
return false;
}
if (!failed.isEmpty()) {
qWarning("failed: %s", qPrintable(failed.constFirst().at(1).toString()));
return false;
}
return finished.constFirst().constFirst().toInt() == expectedId;
}
void writeShared()
{
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}");
Fixtures::write(m_game + "/libraries/x/y.jar", "lib");
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
Fixtures::write(m_game + "/launcher_profiles.json", "{}");
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
void sharedTopLevelIsStable()
{
QStringList shared = BuildSwitcher::sharedTopLevel();
shared.sort();
QCOMPARE(shared, QStringList({"assets", "launcher_profiles.json", "libraries", "runtime", "versions"}));
}
void switchBackAndForthKeepsEachBuildContent()
{
writeShared();
Fixtures::write(m_game + "/mods/alpha.jar", "alpha");
Fixtures::write(m_game + "/saves/A/level.dat", "A");
Fixtures::write(m_game + "/options.txt", "alpha-options");
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
// 1 → 2: у сборки 2 архива ещё нет, папка игры очищается до общих файлов.
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
QVERIFY(switcher.isRunning());
QVERIFY(waitDone(finished, failed, 2));
QVERIFY(!switcher.isRunning());
QCOMPARE(Fixtures::listTree(m_game),
QStringList({"assets/indexes/5.json", "launcher_profiles.json",
"libraries/x/y.jar", "versions/1.20.1/1.20.1.json"}));
QVERIFY(switcher.hasArchive(1));
QVERIFY(!switcher.hasArchive(2));
const QStringList zips = zipsOf(1);
QCOMPARE(zips.size(), 1);
QVERIFY(zips.constFirst().endsWith("_Alpha_build.zip")); // имя из названия сборки
QJsonObject idx = index();
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
QCOMPARE(idx.value("active").toInt(), 2);
QCOMPARE(idx.value("builds").toObject().value("1").toObject().value("name").toString(),
QStringLiteral("Alpha build"));
// Наполняем сборку 2 и возвращаемся в 1.
Fixtures::write(m_game + "/mods/beta.jar", "beta");
finished.clear();
switcher.switchTo(2, QStringLiteral("Beta"), 1, QStringLiteral("Alpha build"), m_game);
QVERIFY(waitDone(finished, failed, 1));
QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("alpha"));
QCOMPARE(Fixtures::readFile(m_game + "/saves/A/level.dat"), QByteArray("A"));
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("alpha-options"));
QVERIFY(!QFile::exists(m_game + "/mods/beta.jar"));
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); // общее не тронуто
QVERIFY(switcher.hasArchive(2));
// И снова в 2 — его моды на месте.
finished.clear();
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
QVERIFY(waitDone(finished, failed, 2));
QCOMPARE(Fixtures::readFile(m_game + "/mods/beta.jar"), QByteArray("beta"));
QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar"));
// У сборки хранится один актуальный архив, временных не остаётся.
QCOMPARE(zipsOf(1).size(), 1);
QVERIFY(zipsOf(1).filter(".tmp-").isEmpty());
}
void switchWithoutSourceBuildOnlyClears()
{
writeShared();
Fixtures::write(m_game + "/mods/stray.jar", "x");
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.switchTo(-1, QString(), 5, QStringLiteral("Five"), m_game);
QVERIFY(waitDone(finished, failed, 5));
QVERIFY(!QFile::exists(m_game + "/mods/stray.jar"));
QVERIFY(QFile::exists(m_game + "/libraries/x/y.jar"));
QVERIFY(QDir(LauncherPaths::buildStorageDir()).entryList({"*.zip"}).isEmpty());
}
void secondSwitchWhileBusyIsRejected()
{
writeShared();
for (int i = 0; i < 50; ++i)
Fixtures::write(m_game + QStringLiteral("/mods/m%1.jar").arg(i), QByteArray(4096, 'x'));
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.switchTo(1, "A", 2, "B", m_game);
switcher.switchTo(1, "A", 3, "C", m_game);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.constFirst().at(0).toInt(), 3);
QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("common.error.switchInProgress"));
QCOMPARE(failed.constFirst().at(2).toBool(), true);
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
}
void interruptedSwitchIsReportedAndResumed()
{
writeShared();
Fixtures::write(m_game + "/mods/half-cleared.jar", "x");
// Архив сборки 7 записан, переключение 3 → 7 оборвалось посреди очистки.
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(7) + "/2026-01-01_Seven.zip",
{{"mods/seven.jar", "seven"}, {"options.txt", "seven-options"}}));
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "switching"}, {"from", 3}, {"to", 7},
{"builds", QJsonObject{{"7", QJsonObject{{"name", "Seven"},
{"archive", "2026-01-01_Seven.zip"}}}}}});
// Мусор от прерванной архивации подметается конструктором.
Fixtures::write(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip", "partial");
BuildSwitcher switcher;
QVERIFY(!QFile::exists(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip"));
const QString warning = switcher.interruptedSwitchWarning();
QCOMPARE(warning, Loc::text("switch.status.switchInterrupted")
.arg(Loc::text("switch.status.targetSuffix").arg("Seven")));
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QVERIFY(waitDone(finished, failed, 7));
QVERIFY(!QFile::exists(m_game + "/mods/half-cleared.jar"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/seven.jar"), QByteArray("seven"));
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
QVERIFY(switcher.interruptedSwitchWarning().isEmpty());
}
void applyPackAndRecordNote()
{
writeShared();
Fixtures::write(m_game + "/mods/old.jar", "old");
const QString pack = LauncherPaths::buildDir(9) + "/season-pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"config/c.toml", "c"}}));
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
const QJsonObject note{{"seasonalId", "season-5"}, {"revision", 3}};
switcher.applyPack(9, QStringLiteral("Season 5"), pack, {"mods/old.jar"}, note, m_game);
QVERIFY(waitDone(finished, failed, 9));
QVERIFY(!QFile::exists(m_game + "/mods/old.jar"));
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
QVERIFY(!QFile::exists(pack)); // пакет удаляется после установки
QStringList entries = switcher.lastPackEntries();
entries.sort();
QCOMPARE(entries, QStringList({"config/c.toml", "mods/new.jar"}));
QCOMPARE(switcher.lastPackNote(), note);
const QJsonObject idx = index();
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
QCOMPARE(idx.value("active").toInt(), 9);
QVERIFY(!idx.contains("pack"));
}
void applyPackErrors()
{
BuildSwitcher switcher;
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
const QString missing = LauncherPaths::buildDir(4) + "/nope.zip";
switcher.applyPack(4, "Four", missing, {}, {}, m_game);
QCOMPARE(failed.count(), 1);
QCOMPARE(failed.constFirst().at(1).toString(),
Loc::text("switch.error.archiveMissing").arg(QDir::toNativeSeparators(missing)));
// Пакет лезет в общие папки лаунчера — отказ, папка игры цела.
const QString bad = LauncherPaths::buildDir(4) + "/bad.zip";
QVERIFY(Fixtures::makeZip(bad, {{"versions/evil/evil.json", "{}"}}));
failed.clear();
switcher.applyPack(4, "Four", bad, {}, {}, m_game);
QTRY_COMPARE_WITH_TIMEOUT(failed.count(), 1, 20000);
QCOMPARE(failed.constFirst().at(1).toString(),
Loc::text("switch.error.packTouchesLauncher").arg("versions/evil/evil.json"));
QCOMPARE(failed.constFirst().at(2).toBool(), true);
}
void interruptedApplyIsResumedOrCleaned()
{
writeShared();
const QString pack = LauncherPaths::buildDir(11) + "/pack.zip";
QVERIFY(Fixtures::makeZip(pack, {{"mods/resumed.jar", "r"}}));
const QJsonObject note{{"k", "v"}};
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "applying"}, {"to", 11}, {"pack", "pack.zip"},
{"packName", "Eleven"}, {"packRemove", QJsonArray{"mods/gone.jar"}},
{"packNote", note}});
Fixtures::write(m_game + "/mods/gone.jar", "g");
{
BuildSwitcher switcher;
QCOMPARE(switcher.interruptedSwitchWarning(),
Loc::text("switch.status.applyInterrupted")
.arg(Loc::text("switch.status.nameSuffix").arg("Eleven")));
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QVERIFY(waitDone(finished, failed, 11));
QVERIFY(QFile::exists(m_game + "/mods/resumed.jar"));
QVERIFY(!QFile::exists(m_game + "/mods/gone.jar"));
QCOMPARE(switcher.lastPackNote(), note);
}
// Пакет пропал — индекс просто приводится в порядок, без сигналов.
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "applying"}, {"to", 12}, {"pack", "lost.zip"}});
BuildSwitcher switcher;
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
switcher.resumeInterrupted(m_game);
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
QVERIFY(!index().contains("pack"));
QCOMPARE(finished.count() + failed.count(), 0);
}
void forgetBuild()
{
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(5) + "/a.zip", {{"x", "y"}}));
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
QJsonObject{{"state", "idle"}, {"active", 5},
{"builds", QJsonObject{{"5", QJsonObject{{"name", "Five"}}},
{"6", QJsonObject{{"name", "Six"}}}}}});
BuildSwitcher switcher;
QVERIFY(switcher.hasArchive(5));
QVERIFY(switcher.forgetBuild(5));
QVERIFY(!QDir(LauncherPaths::buildDir(5)).exists());
QVERIFY(!switcher.hasArchive(5));
const QJsonObject idx = index();
QVERIFY(!idx.contains("active"));
QVERIFY(!idx.value("builds").toObject().contains("5"));
QVERIFY(idx.value("builds").toObject().contains("6"));
QVERIFY(switcher.forgetBuild(-1));
}
void fractionAndStage()
{
BuildSwitcher switcher;
QCOMPARE(switcher.fraction(), -1.0);
QVERIFY(switcher.stage().isEmpty());
writeShared();
Fixtures::write(m_game + "/mods/a.jar", "a");
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
QSignalSpy progress(&switcher, &BuildSwitcher::progressChanged);
switcher.switchTo(1, "A", 2, "B", m_game);
QCOMPARE(switcher.stage(), Loc::text("switch.progress.saving").arg("A"));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
QVERIFY(progress.count() > 2);
QCOMPARE(switcher.fraction(), -1.0);
}
};
BILAUNCHER_TEST_MAIN(TestBuildSwitcher)
#include "tst_buildswitcher.moc"
+450
View File
@@ -0,0 +1,450 @@
#include "testenv.h"
#include "fixtures.h"
#include "gamelauncher.h"
#include "javalocator.h"
#include "localization.h"
#include "minecraftversion.h"
#include <QDir>
#include <QSignalSpy>
namespace {
#if defined(Q_OS_WIN)
const QChar kSep = u';';
const char *kNativeName = "lwjgl.dll";
#elif defined(Q_OS_MACOS)
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.dylib";
#else
const QChar kSep = u':';
const char *kNativeName = "liblwjgl.so";
#endif
QString nat(const QString &path)
{
return QDir::toNativeSeparators(path);
}
MinecraftLibrary lib(const QString &name, const QString &path, bool native = false)
{
MinecraftLibrary library;
library.name = name;
library.path = path;
library.native = native;
return library;
}
} // namespace
class TestGameLauncher : public QObject
{
Q_OBJECT
QString m_game;
MinecraftVersion modernVersion() const
{
MinecraftVersion v;
v.id = QStringLiteral("1.20.1");
v.mainClass = QStringLiteral("net.minecraft.client.main.Main");
v.type = QStringLiteral("release");
v.assetIndexId = QStringLiteral("5");
v.assetsKind = QStringLiteral("5");
v.clientJarPath = m_game + QStringLiteral("/versions/1.20.1/1.20.1.jar");
v.javaMajor = 17;
v.jvmArgs = QStringList{"-Djava.library.path=${natives_directory}",
"-Dminecraft.launcher.brand=${launcher_name}",
"-Dminecraft.launcher.version=${launcher_version}",
"-Dunknown=${not_a_variable}",
"-cp", "${classpath}"};
v.gameArgs = QStringList{"--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--clientId", "${clientid}",
"--xuid", "${auth_xuid}", "--userType", "${user_type}",
"--versionType", "${version_type}", "--mystery", "${unknown_thing}"};
v.libraries << lib("com.mojang:brigadier:1.1.8", "com/mojang/brigadier-1.1.8.jar")
<< lib("org.lwjgl:lwjgl:3.3.1", "org/lwjgl/lwjgl-3.3.1.jar")
<< lib("org.lwjgl:lwjgl:3.3.1:natives", "org/lwjgl/lwjgl-3.3.1-natives.jar", true);
return v;
}
LaunchOptions options() const
{
LaunchOptions o;
o.gameDir = m_game;
o.versionId = QStringLiteral("1.20.1");
o.playerName = QStringLiteral("Steve");
o.uuid = QStringLiteral("0123456789abcdef0123456789abcdef");
o.accessToken = QStringLiteral("secret-token");
o.userType = QStringLiteral("msa");
o.minMemoryMb = 1024;
o.maxMemoryMb = 6144;
return o;
}
static QString valueAfter(const QStringList &args, const QString &flag)
{
const qsizetype i = args.indexOf(flag);
return (i >= 0 && i + 1 < args.size()) ? args.at(i + 1) : QString();
}
void createGameFiles(const MinecraftVersion &v)
{
Fixtures::write(v.clientJarPath, "client");
Fixtures::write(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar", "lib");
Fixtures::write(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar", "lib");
Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar",
{{QStringLiteral("META-INF/MANIFEST.MF"), "x"},
{QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"},
{QStringLiteral("skip/") + kNativeName + ".sha1", "hash"}});
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
QDir().mkpath(m_game + "/assets/objects");
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
// --- buildArguments ---------------------------------------------------------
void substitutesVariables()
{
const MinecraftVersion v = modernVersion();
const QString natives = m_game + "/versions/1.20.1/natives";
const QStringList args = GameLauncher::buildArguments(options(), v, natives);
QCOMPARE(args.at(0), QStringLiteral("-Xms1024M"));
QCOMPARE(args.at(1), QStringLiteral("-Xmx6144M"));
QVERIFY(args.contains("-Djava.library.path=" + nat(natives)));
QVERIFY(args.contains("-Dminecraft.launcher.brand=BiLauncher"));
QVERIFY(args.contains(QStringLiteral("-Dminecraft.launcher.version=") + QStringLiteral(LAUNCHER_VERSION)));
// Неизвестный плейсхолдер — аргумент выбрасывается целиком.
QVERIFY(!args.join(' ').contains("not_a_variable"));
QVERIFY(!args.contains("${unknown_thing}"));
QVERIFY(args.contains("--mystery")); // сам флаг остаётся, как у официального лаунчера
// classpath: обычные библиотеки, без нативов, клиентский jar последним.
const QStringList cp = valueAfter(args, "-cp").split(kSep);
QCOMPARE(cp, QStringList({nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar"),
nat(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar"),
nat(v.clientJarPath)}));
// mainClass — после jvm-аргументов, перед игровыми.
const qsizetype main = args.indexOf(v.mainClass);
QVERIFY(main > args.indexOf("-cp"));
QVERIFY(main < args.indexOf("--username"));
QCOMPARE(valueAfter(args, "--username"), QStringLiteral("Steve"));
QCOMPARE(valueAfter(args, "--version"), QStringLiteral("1.20.1"));
QCOMPARE(valueAfter(args, "--gameDir"), nat(m_game));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets"));
QCOMPARE(valueAfter(args, "--assetIndex"), QStringLiteral("5"));
QCOMPARE(valueAfter(args, "--uuid"), QStringLiteral("0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--accessToken"), QStringLiteral("secret-token"));
QCOMPARE(valueAfter(args, "--clientId"), QStringLiteral("0")); // пустой → «0»
QCOMPARE(valueAfter(args, "--xuid"), QStringLiteral("0"));
QCOMPARE(valueAfter(args, "--userType"), QStringLiteral("msa"));
QCOMPARE(valueAfter(args, "--versionType"), QStringLiteral("release"));
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
QVERIFY(!args.contains("--server"));
}
void legacyAssetsAndSession()
{
MinecraftVersion v = modernVersion();
v.assetsKind = QStringLiteral("legacy");
v.gameArgs = QStringList{"--session", "${auth_session}", "--assetsDir", "${game_assets}"};
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(valueAfter(args, "--session"),
QStringLiteral("token:secret-token:0123456789abcdef0123456789abcdef"));
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("pre-1.6");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets/virtual/legacy"));
v.assetsKind = QStringLiteral("5");
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
nat(m_game + "/assets"));
}
void memoryAndExtraJvmArgs()
{
LaunchOptions o = options();
o.minMemoryMb = 0;
o.maxMemoryMb = 0;
o.extraJvmArgs = QStringList{"-XX:+UseG1GC", "-Dfoo=bar"};
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.join(' ').contains("-Xms"));
QVERIFY(!args.join(' ').contains("-Xmx"));
QCOMPARE(args.at(0), QStringLiteral("-XX:+UseG1GC"));
QCOMPARE(args.at(1), QStringLiteral("-Dfoo=bar"));
}
void authlibInjectorGoesFirst()
{
LaunchOptions o = options();
o.authlibInjectorPath = m_game + "/authlib-injector.jar";
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QCOMPARE(args.at(0), "-javaagent:" + nat(o.authlibInjectorPath) + "=ely.by");
QCOMPARE(args.at(1), QStringLiteral("-Dauthlibinjector.side=client"));
QCOMPARE(args.at(2), QStringLiteral("-Xms1024M"));
}
void windowSizeAndFullscreen()
{
LaunchOptions o = options();
o.windowWidth = 1280;
o.windowHeight = 720;
o.fullscreen = true;
MinecraftVersion v = modernVersion();
QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
QCOMPARE(valueAfter(args, "--height"), QStringLiteral("720"));
QCOMPARE(args.count("--fullscreen"), 1);
// У версии свои аргументы разрешения — лаунчер свои не дублирует.
v.hasCustomResolutionArgs = true;
v.gameArgs << "--width" << "${resolution_width}" << "--height" << "${resolution_height}";
args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.count("--width"), 1);
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
// Только ширина — не передаём ничего.
o.windowHeight = 0;
o.fullscreen = false;
args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
QVERIFY(!args.contains("--width"));
QVERIFY(!args.contains("--fullscreen"));
}
void serverAutoJoin_data()
{
QTest::addColumn<bool>("quickPlay");
QTest::addColumn<QString>("address");
QTest::addColumn<QStringList>("expectedTail");
QTest::newRow("quickplay") << true << "play.example.com:25570"
<< QStringList{"--quickPlayMultiplayer", "play.example.com:25570"};
QTest::newRow("legacy with port") << false << "play.example.com:25570"
<< QStringList{"--server", "play.example.com", "--port", "25570"};
QTest::newRow("legacy default port") << false << "play.example.com"
<< QStringList{"--server", "play.example.com", "--port", "25565"};
}
void serverAutoJoin()
{
QFETCH(bool, quickPlay);
QFETCH(QString, address);
QFETCH(QStringList, expectedTail);
LaunchOptions o = options();
o.serverAddress = address;
MinecraftVersion v = modernVersion();
v.supportsQuickPlay = quickPlay;
const QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
QCOMPARE(args.mid(args.size() - expectedTail.size()), expectedTail);
}
void forgeIgnoreListGetsClientJar()
{
MinecraftVersion v = modernVersion();
v.jvmArgs.prepend("-DignoreList=bootstraplauncher,securejarhandler,${version_name}.jar");
QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
// ${version_name} = 1.20.1 совпадает с именем jar — дописывать нечего.
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1.jar"));
// Профиль Forge со своим id, а jar родительский — имя jar дописывается.
v.id = QStringLiteral("1.20.1-forge-47.4.0");
args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("-DignoreList=").constFirst(),
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1-forge-47.4.0.jar,1.20.1.jar"));
}
void loggingArgumentOnlyWhenConfigExists()
{
MinecraftVersion v = modernVersion();
v.loggingArgument = QStringLiteral("-Dlog4j.configurationFile=${path}");
v.loggingConfigPath = m_game + "/assets/log_configs/client-1.12.xml";
QVERIFY(GameLauncher::buildArguments(options(), v, m_game + "/n").filter("log4j").isEmpty());
Fixtures::write(v.loggingConfigPath, "<xml/>");
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
QCOMPARE(args.filter("log4j"), QStringList{"-Dlog4j.configurationFile=" + nat(v.loggingConfigPath)});
QVERIFY(args.indexOf(args.filter("log4j").constFirst()) < args.indexOf(v.mainClass));
}
// --- missingFiles / extractNatives -------------------------------------------
void missingFiles()
{
const MinecraftVersion v = modernVersion();
LaunchOptions o = options();
o.gameDir = m_game + "/does-not-exist";
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(o.gameDir)});
o = options();
QStringList missing = GameLauncher::missingFiles(o, v);
QVERIFY(missing.contains(nat(v.clientJarPath)));
QVERIFY(missing.contains(nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar")));
QVERIFY(missing.contains(nat(m_game + "/assets/indexes/5.json")));
QCOMPARE(GameLauncher::missingFiles(o, v, 2).size(), 2);
createGameFiles(v);
QVERIFY(GameLauncher::missingFiles(o, v).isEmpty());
QDir(m_game + "/assets/objects").removeRecursively();
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(m_game + "/assets/objects")});
}
void extractNatives()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString natives = m_game + "/versions/1.20.1/natives";
QString error;
QVERIFY2(GameLauncher::extractNatives(options(), v, natives, &error), qPrintable(error));
// Только сама библиотека: без META-INF и без .sha1, в плоскую папку.
QCOMPARE(Fixtures::listTree(natives), QStringList{QString::fromLatin1(kNativeName)});
QCOMPARE(Fixtures::readFile(natives + u'/' + kNativeName), QByteArray("native-bytes"));
// Повторный запуск поверх готовой папки — успех.
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
// extractExclude по префиксу пути внутри архива.
QDir(natives).removeRecursively();
v.libraries[2].extractExclude = QStringList{"linux/"};
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
QVERIFY(Fixtures::listTree(natives).isEmpty());
// Архива нет вовсе.
const QString jar = m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar";
QFile::remove(jar);
error.clear();
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
QCOMPARE(error, Loc::text("launch.error.nativeReadFailed").arg(nat(jar)));
// Битый архив: QZipReader::isReadable() проверяет только, что файл открылся,
// поэтому испорченный jar «распаковывается» в ничто без ошибки, и игра
// падает уже на загрузке LWJGL. Фиксируем текущее поведение.
Fixtures::write(jar, "not a zip");
error.clear();
QEXPECT_FAIL("", "битый jar с нативами не распознаётся как ошибка (QZipReader::isReadable)", Continue);
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
}
// --- launch (с подставной java) ----------------------------------------------
void launchRunsJavaAndReportsExit()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
const QString argsFile = m_game + "/java-args.txt";
const QString java = Fixtures::installFakeJava(m_game + "/jdk", "17.0.8",
{"args_file=" + argsFile, "stdout=[main] Setting user: Steve",
"stdout=Game crashed? no", "exit=0"});
LaunchOptions o = options();
o.javaPath = java;
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QSignalSpy output(&launcher, &GameLauncher::output);
QSignalSpy progress(&launcher, &GameLauncher::progress);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QCOMPARE(started.count(), 1);
// Токен скрыт в показанной строке запуска.
const QString shown = started.constFirst().constFirst().toString();
QVERIFY(!shown.contains("secret-token"));
QVERIFY(shown.contains("--accessToken"));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(0).toInt(), 0);
QCOMPARE(finished.constFirst().at(1).toBool(), false);
QVERIFY(!launcher.isRunning());
QVERIFY(progress.count() >= 3);
QStringList lines;
for (const QList<QVariant> &call : output)
lines << call.constFirst().toString();
QVERIFY(lines.contains("[main] Setting user: Steve"));
// Java получила те же аргументы, что строит buildArguments, и в рабочей папке игры.
const QStringList received = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(received, GameLauncher::buildArguments(o, v, m_game + "/versions/1.20.1/natives"));
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/natives/" + kNativeName));
}
void launchReportsCrash()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-crash", "17.0.8", {"crash=1"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QCOMPARE(finished.constFirst().at(1).toBool(), true);
}
void launchRefusesWhileRunningAndTerminate()
{
MinecraftVersion v = modernVersion();
createGameFiles(v);
LaunchOptions o = options();
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-slow", "17.0.8", {"sleep_ms=30000"});
GameLauncher launcher;
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
QString error;
QVERIFY(launcher.launch(o, v, &error));
QVERIFY(launcher.isRunning());
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("common.error.gameRunning"));
launcher.terminate();
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
QVERIFY(!launcher.isRunning());
}
void launchFailsOnMissingFilesAndOldJava()
{
MinecraftVersion v = modernVersion();
LaunchOptions o = options();
GameLauncher launcher;
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
QString error;
QVERIFY(!launcher.launch(o, v, &error));
QVERIFY(error.startsWith(Loc::text("launch.error.filesMissing").section(u'%', 0, 0)));
createGameFiles(v);
// На машине может стоять своя Java 17+: тогда лаунчер законно выберет её.
for (const QString &candidate : JavaLocator::findAll(m_game)) {
if (JavaLocator::majorVersion(candidate) >= 17)
QSKIP("в системе есть Java 17+, проверка «слишком старой Java» невозможна");
}
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk8", "1.8.0_392");
error.clear();
QVERIFY(!launcher.launch(o, v, &error));
QCOMPARE(error, Loc::text("java.error.tooOld").arg(17).arg(8).arg(nat(o.javaPath)));
QCOMPARE(started.count(), 0);
}
};
BILAUNCHER_TEST_MAIN(TestGameLauncher)
#include "tst_gamelauncher.moc"
+145
View File
@@ -0,0 +1,145 @@
#include "testenv.h"
#include "fixtures.h"
#include "javalocator.h"
#include "javaruntime.h"
#include "localization.h"
#include <QDir>
class TestJavaLocator : public QObject
{
Q_OBJECT
QString m_game;
int m_unique = 0;
// majorVersion() кеширует ответ по пути, поэтому каждая подставная java — в своей папке.
QString fakeJava(const QString &version)
{
return Fixtures::installFakeJava(m_game + QStringLiteral("/jdk-%1").arg(++m_unique), version);
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
void majorVersion_data()
{
QTest::addColumn<QString>("version");
QTest::addColumn<int>("major");
QTest::newRow("java8") << "1.8.0_392" << 8;
QTest::newRow("java11") << "11.0.21" << 11;
QTest::newRow("java17") << "17.0.8" << 17;
QTest::newRow("java21 short") << "21" << 21;
QTest::newRow("java25 ea") << "25-ea" << 25;
}
void majorVersion()
{
QFETCH(QString, version);
QFETCH(int, major);
QCOMPARE(JavaLocator::majorVersion(fakeJava(version)), major);
}
void majorVersionOfNonJava()
{
QCOMPARE(JavaLocator::majorVersion(m_game + QStringLiteral("/nope/java")), 0);
}
void windowlessVariant()
{
const QString java = fakeJava("17.0.8");
#if defined(Q_OS_WIN)
QCOMPARE(JavaLocator::windowlessVariant(java), QFileInfo(java).absolutePath() + "/javaw.exe");
QFile::remove(QFileInfo(java).absolutePath() + "/javaw.exe");
QCOMPARE(JavaLocator::windowlessVariant(java), java);
#else
QCOMPARE(JavaLocator::windowlessVariant(java), java);
#endif
QCOMPARE(JavaLocator::windowlessVariant(QStringLiteral("/opt/custom/launcher")),
QStringLiteral("/opt/custom/launcher"));
}
void findAllSeesManagedAndGameRuntimes()
{
// Java, поставленная лаунчером (JavaRuntimeStore).
InstalledJavaRuntime managed;
managed.id = QStringLiteral("temurin-jdk-21");
managed.kind = QStringLiteral("jdk");
managed.major = 21;
managed.version = QStringLiteral("21.0.2");
managed.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(managed.id), "21.0.2");
QVERIFY(JavaRuntimeStore::writeMeta(managed, nullptr));
// Java официального лаунчера внутри .minecraft/runtime.
const QString mojang = Fixtures::installFakeJava(
m_game + "/runtime/java-runtime-gamma/linux/java-runtime-gamma", "17.0.8");
const QStringList all = JavaLocator::findAll(m_game);
QVERIFY(all.size() >= 2);
QCOMPARE(QFileInfo(all.at(0)).canonicalFilePath(), QFileInfo(managed.javaPath).canonicalFilePath());
bool hasMojang = false;
for (const QString &path : all)
hasMojang |= QFileInfo(path).canonicalFilePath() == QFileInfo(mojang).canonicalFilePath();
QVERIFY(hasMojang);
QStringList unique = all;
QCOMPARE(unique.removeDuplicates(), 0);
}
void selectPrefersPreferredWhenSuitable()
{
const QString java = fakeJava("21.0.2");
QString error;
QCOMPARE(JavaLocator::select(m_game, 17, java, &error), QFileInfo(java).absoluteFilePath());
QVERIFY(error.isEmpty());
// Папка JDK тоже годится: берётся bin/java.
const QString fromDir = JavaLocator::select(m_game, 17, QFileInfo(java).absolutePath() + "/..", &error);
QCOMPARE(QFileInfo(fromDir).canonicalFilePath(), QFileInfo(java).canonicalFilePath());
}
void selectReportsMissingPreferred()
{
QString error;
const QString missing = m_game + "/no-such-java";
QVERIFY(JavaLocator::select(m_game, 17, missing, &error).isEmpty());
QCOMPARE(error, Loc::text("java.error.notAtPath").arg(QDir::toNativeSeparators(missing)));
}
void selectFallsBackToManagedRuntime()
{
InstalledJavaRuntime managed;
managed.id = QStringLiteral("temurin-jdk-21b");
managed.kind = QStringLiteral("jdk");
managed.major = 21;
managed.version = QStringLiteral("21.0.2");
managed.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(managed.id), "21.0.2");
QVERIFY(JavaRuntimeStore::writeMeta(managed, nullptr));
const QString old = fakeJava("1.8.0_392");
QString error;
const QString chosen = JavaLocator::select(m_game, 21, old, &error);
QCOMPARE(QFileInfo(chosen).canonicalFilePath(), QFileInfo(managed.javaPath).canonicalFilePath());
}
void selectExplainsTooOld()
{
for (const QString &candidate : JavaLocator::findAll(m_game)) {
if (JavaLocator::majorVersion(candidate) >= 99)
QSKIP("в системе есть Java 99+");
}
const QString old = fakeJava("17.0.8");
QString error;
QVERIFY(JavaLocator::select(m_game, 99, old, &error).isEmpty());
// Сообщение называет лучшую из найденных — она не ниже нашей 17.
QVERIFY(!error.isEmpty());
QVERIFY(error.contains(QStringLiteral("99")));
}
};
BILAUNCHER_TEST_MAIN(TestJavaLocator)
#include "tst_javalocator.moc"
+233
View File
@@ -0,0 +1,233 @@
#include "testenv.h"
#include "fixtures.h"
#include "javaruntime.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFile>
class TestJavaRuntime : public QObject
{
Q_OBJECT
private:
static QString fakeBinary(const QString &root, const QString &relativeBinDir)
{
const QString path = root + u'/' + relativeBinDir + u'/' + Fixtures::javaExecutableName();
Fixtures::write(path, "#!/bin/sh\n");
return path;
}
static InstalledJavaRuntime makeRuntime(const QString &id, int major, const QString &version)
{
InstalledJavaRuntime runtime;
runtime.id = id;
runtime.kind = QStringLiteral("jdk");
runtime.major = major;
runtime.version = version;
runtime.size = 12345;
runtime.javaPath = fakeBinary(JavaRuntimeStore::dirFor(id), QStringLiteral("jdk/bin"));
return runtime;
}
private slots:
void init() { TestEnv::resetLauncherRoot(); }
void kindKeysRoundTrip()
{
for (JavaRuntimeKind kind : {JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk, JavaRuntimeKind::Jre}) {
const auto back = javaKindFromKey(javaKindKey(kind));
QVERIFY(back.has_value());
QCOMPARE(*back, kind);
QVERIFY(!javaKindTitle(kind).isEmpty());
}
QCOMPARE(javaKindKey(JavaRuntimeKind::Mojang), QStringLiteral("java"));
QVERIFY(!javaKindFromKey(QStringLiteral("graalvm")).has_value());
QVERIFY(!javaKindFromKey(QString()).has_value());
}
void entryValidity()
{
JavaRuntimeEntry entry;
QVERIFY(!entry.isValid());
entry.id = QStringLiteral("temurin-jdk-21");
entry.major = 21;
QVERIFY(!entry.isValid());
entry.url = QUrl(QStringLiteral("https://example.org/jdk.zip"));
QVERIFY(entry.isValid());
}
void sanitizeId_data()
{
QTest::addColumn<QString>("input");
QTest::addColumn<QString>("expected");
QTest::newRow("plain") << "temurin-jdk-21.0.2_13" << "temurin-jdk-21.0.2_13";
QTest::newRow("plus") << "jdk-21.0.2+13" << "jdk-21.0.2_13";
QTest::newRow("slashes") << "../../etc/passwd" << "_.._etc_passwd";
QTest::newRow("backslash") << "a\\b" << "a_b";
QTest::newRow("leadingDots") << "...hidden" << "hidden";
QTest::newRow("onlyDots") << ".." << "";
QTest::newRow("empty") << "" << "";
}
void sanitizeId()
{
QFETCH(QString, input);
QFETCH(QString, expected);
QCOMPARE(JavaRuntimeStore::sanitizeId(input), expected);
}
void dirForStaysInsideJavaDir()
{
QCOMPARE(JavaRuntimeStore::dirFor(QStringLiteral("jdk-17")),
LauncherPaths::javaDir() + QStringLiteral("/jdk-17"));
QVERIFY(JavaRuntimeStore::dirFor(QStringLiteral("..")).isEmpty());
QVERIFY(JavaRuntimeStore::dirFor(QStringLiteral("../x")).startsWith(LauncherPaths::javaDir() + u'/'));
}
void locateBinaryPrefersShortestBinPath()
{
const QString root = TestEnv::freshDir(QStringLiteral("locate"));
QVERIFY(JavaRuntimeStore::locateBinary(root).isEmpty());
QVERIFY(JavaRuntimeStore::locateBinary(root + QStringLiteral("/missing")).isEmpty());
QVERIFY(JavaRuntimeStore::locateBinary(QString()).isEmpty());
// java вне папки bin не считается
Fixtures::write(root + u'/' + Fixtures::javaExecutableName(), "x");
QVERIFY(JavaRuntimeStore::locateBinary(root).isEmpty());
const QString deep = fakeBinary(root, QStringLiteral("jdk-21/lib/legal/jre/bin"));
QCOMPARE(JavaRuntimeStore::locateBinary(root), deep);
const QString macLayout = fakeBinary(root, QStringLiteral("jdk-21/Contents/Home/bin"));
QCOMPARE(JavaRuntimeStore::locateBinary(root), macLayout);
const QString flat = fakeBinary(root, QStringLiteral("jdk-21/bin"));
QCOMPARE(JavaRuntimeStore::locateBinary(root), flat);
}
void writeMetaFindInstalledRemove()
{
QString error;
const InstalledJavaRuntime older = makeRuntime(QStringLiteral("temurin-jdk-17.0.9"), 17, QStringLiteral("17.0.9"));
const InstalledJavaRuntime newer = makeRuntime(QStringLiteral("temurin-jdk-21.0.2"), 21, QStringLiteral("21.0.2"));
const InstalledJavaRuntime newerPatch = makeRuntime(QStringLiteral("temurin-jdk-21.0.5"), 21, QStringLiteral("21.0.5"));
QVERIFY2(JavaRuntimeStore::writeMeta(older, &error), qPrintable(error));
QVERIFY2(JavaRuntimeStore::writeMeta(newer, &error), qPrintable(error));
QVERIFY2(JavaRuntimeStore::writeMeta(newerPatch, &error), qPrintable(error));
// Путь хранится относительным: папку можно переносить.
const QJsonObject meta = Fixtures::readJsonFile(JavaRuntimeStore::dirFor(newer.id) + "/runtime.json");
QCOMPARE(meta.value("javaPath").toString(), "jdk/bin/" + Fixtures::javaExecutableName());
const auto found = JavaRuntimeStore::find(newer.id);
QVERIFY(found.has_value());
QCOMPARE(found->major, 21);
QCOMPARE(found->version, QStringLiteral("21.0.2"));
QCOMPARE(found->kind, QStringLiteral("jdk"));
QCOMPARE(found->size, qint64(12345));
QCOMPARE(QFileInfo(found->javaPath).canonicalFilePath(), QFileInfo(newer.javaPath).canonicalFilePath());
// Сортировка: свежая major первой, внутри — по версии по убыванию.
const QList<InstalledJavaRuntime> all = JavaRuntimeStore::installed();
QCOMPARE(all.size(), 3);
QCOMPARE(all.at(0).id, newerPatch.id);
QCOMPARE(all.at(1).id, newer.id);
QCOMPARE(all.at(2).id, older.id);
QVERIFY(JavaRuntimeStore::remove(older.id, &error));
QVERIFY(!QDir(JavaRuntimeStore::dirFor(older.id)).exists());
QVERIFY(!JavaRuntimeStore::find(older.id).has_value());
// Удаление несуществующего — не ошибка.
QVERIFY(JavaRuntimeStore::remove(older.id, &error));
QCOMPARE(JavaRuntimeStore::installed().size(), 2);
}
void findRejectsBrokenMeta()
{
const InstalledJavaRuntime runtime = makeRuntime(QStringLiteral("jdk-x"), 21, QStringLiteral("21"));
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
QVERIFY(JavaRuntimeStore::find(runtime.id).has_value());
// Бинарник пропал — запись больше не считается установленной.
QFile::remove(runtime.javaPath);
QVERIFY(!JavaRuntimeStore::find(runtime.id).has_value());
// id в файле не совпадает с папкой.
const InstalledJavaRuntime other = makeRuntime(QStringLiteral("jdk-y"), 17, QStringLiteral("17"));
QVERIFY(JavaRuntimeStore::writeMeta(other, nullptr));
QJsonObject meta = Fixtures::readJsonFile(JavaRuntimeStore::dirFor(other.id) + "/runtime.json");
meta["id"] = "jdk-z";
Fixtures::writeJson(JavaRuntimeStore::dirFor(other.id) + "/runtime.json", meta);
QVERIFY(!JavaRuntimeStore::find(other.id).has_value());
// Мусор вместо json.
Fixtures::write(JavaRuntimeStore::dirFor(other.id) + "/runtime.json", "{not json");
QVERIFY(!JavaRuntimeStore::find(other.id).has_value());
QVERIFY(JavaRuntimeStore::installed().isEmpty());
}
void invalidIdsAreRejected()
{
QString error;
InstalledJavaRuntime runtime;
runtime.id = QStringLiteral("..");
QVERIFY(!JavaRuntimeStore::writeMeta(runtime, &error));
QCOMPARE(error, Loc::text("java.error.invalidRuntimeId"));
error.clear();
QVERIFY(!JavaRuntimeStore::remove(QStringLiteral(".."), &error));
QCOMPARE(error, Loc::text("java.error.invalidRuntimeId"));
QVERIFY(!JavaRuntimeStore::find(QString()).has_value());
}
void minimumJavaFor_data()
{
QTest::addColumn<QString>("version");
QTest::addColumn<int>("java");
QTest::newRow("empty") << "" << 0;
QTest::newRow("spaces") << " " << 0;
QTest::newRow("1.7.10") << "1.7.10" << 8;
QTest::newRow("1.12.2") << "1.12.2" << 8;
QTest::newRow("1.16.5") << "1.16.5" << 8;
QTest::newRow("1.17") << "1.17" << 16;
QTest::newRow("1.17.1") << "1.17.1" << 16;
QTest::newRow("1.18") << "1.18" << 17;
QTest::newRow("1.19.4") << "1.19.4" << 17;
QTest::newRow("1.20.1") << "1.20.1" << 17;
QTest::newRow("1.20.4") << "1.20.4" << 17;
QTest::newRow("1.20.5") << "1.20.5" << 21;
QTest::newRow("1.20.6") << "1.20.6" << 21;
QTest::newRow("1.21") << "1.21" << 21;
QTest::newRow("1.21.8") << "1.21.8" << 21;
QTest::newRow("forge-suffix") << "1.20.1-forge-47.4.0" << 17;
QTest::newRow("newEpoch") << "26.1" << 21;
QTest::newRow("snapshot20") << "20w45a" << 8;
QTest::newRow("snapshot21") << "21w19a" << 16;
QTest::newRow("snapshot22") << "22w45a" << 17;
QTest::newRow("snapshot24") << "24w14a" << 21;
QTest::newRow("snapshot25") << "25w33a" << 21;
QTest::newRow("beta") << "b1.7.3" << 8;
QTest::newRow("trimmed") << " 1.18.2 " << 17;
}
void minimumJavaFor()
{
QFETCH(QString, version);
QFETCH(int, java);
QCOMPARE(JavaRequirement::minimumFor(version), java);
}
void coverage()
{
QCOMPARE(JavaRequirement::coverage(25), Loc::text("java.range.modern"));
QCOMPARE(JavaRequirement::coverage(21), Loc::text("java.range.modern"));
QCOMPARE(JavaRequirement::coverage(17), Loc::text("java.range.mid"));
QCOMPARE(JavaRequirement::coverage(16), Loc::text("java.range.seventeen"));
QCOMPARE(JavaRequirement::coverage(11), Loc::text("java.range.unsuitable"));
QCOMPARE(JavaRequirement::coverage(8), Loc::text("java.range.legacy"));
QVERIFY(JavaRequirement::coverage(0).isEmpty());
}
};
BILAUNCHER_TEST_MAIN(TestJavaRuntime)
#include "tst_javaruntime.moc"
+97
View File
@@ -0,0 +1,97 @@
#include "testenv.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
class TestLauncherPaths : public QObject
{
Q_OBJECT
private slots:
void init() { TestEnv::resetLauncherRoot(); }
void rootIsInsideContainer()
{
QVERIFY(LauncherPaths::rootDir().startsWith(LauncherPaths::containerDir() + u'/'));
QCOMPARE(QFileInfo(LauncherPaths::rootDir()).fileName(), QStringLiteral("BiLauncher"));
}
void containerPerPlatform()
{
const QString home = QDir::homePath();
#if defined(Q_OS_WIN)
QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/AppData/Roaming"));
QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/.minecraft");
#elif defined(Q_OS_MACOS)
QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/Library/Application Support"));
QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/minecraft");
#else
QCOMPARE(LauncherPaths::containerDir(), home);
QCOMPARE(LauncherPaths::defaultMinecraftDir(), home + "/.minecraft");
#endif
}
void filesLayout_data()
{
QTest::addColumn<QString>("actual");
QTest::addColumn<QString>("relative");
QTest::newRow("settings") << LauncherPaths::settingsFile() << "settings.json";
QTest::newRow("profiles") << LauncherPaths::profilesFile() << "profiles.json";
QTest::newRow("customBuilds") << LauncherPaths::customBuildsFile() << "customBuilds.json";
QTest::newRow("legacyBuilds") << LauncherPaths::legacyCustomBuildsFile() << "versions.json";
QTest::newRow("buildStorage") << LauncherPaths::buildStorageDir() << "builds";
QTest::newRow("buildDir") << LauncherPaths::buildDir(7) << "builds/7";
QTest::newRow("season") << LauncherPaths::seasonalStateFile(7) << "builds/7/season.json";
QTest::newRow("runtime") << LauncherPaths::runtimeDir() << "runtime";
QTest::newRow("java") << LauncherPaths::javaDir() << "java";
QTest::newRow("cache") << LauncherPaths::cacheDir() << "cache";
QTest::newRow("javaCatalog") << LauncherPaths::javaCatalogFile() << "cache/java_runtimes.json";
QTest::newRow("javaDownload") << LauncherPaths::javaDownloadDir() << "cache/java";
QTest::newRow("manifest") << LauncherPaths::versionManifestFile() << "cache/version_manifest.json";
QTest::newRow("seasonal") << LauncherPaths::seasonalCatalogFile() << "cache/seasonal_builds.json";
QTest::newRow("loaderCache") << LauncherPaths::loaderCacheFile("forge") << "cache/loader_forge.json";
QTest::newRow("loaderDownload") << LauncherPaths::loaderDownloadDir() << "cache/loaders";
}
void filesLayout()
{
QFETCH(QString, actual);
QFETCH(QString, relative);
QCOMPARE(actual, LauncherPaths::rootDir() + u'/' + relative);
}
void ensureRootCreatesTree()
{
QString error;
QVERIFY2(LauncherPaths::ensureRootExists(&error), qPrintable(error));
for (const QString &dir : {LauncherPaths::rootDir(), LauncherPaths::runtimeDir(),
LauncherPaths::cacheDir(), LauncherPaths::loaderDownloadDir(),
LauncherPaths::buildStorageDir(), LauncherPaths::javaDir(),
LauncherPaths::javaDownloadDir()}) {
QVERIFY2(QFileInfo(dir).isDir(), qPrintable(dir));
}
// Повторный вызов на готовом дереве — тоже успех.
QVERIFY(LauncherPaths::ensureRootExists(&error));
}
void ensureRootFailsWhenFileBlocksFolder()
{
QDir().mkpath(LauncherPaths::rootDir());
QFile blocker(LauncherPaths::cacheDir());
QVERIFY(blocker.open(QIODevice::WriteOnly));
blocker.close();
QString error;
QVERIFY(!LauncherPaths::ensureRootExists(&error));
QVERIFY(!error.isEmpty());
QVERIFY(error.contains(QDir::toNativeSeparators(LauncherPaths::cacheDir())));
}
};
BILAUNCHER_TEST_MAIN(TestLauncherPaths)
#include "tst_launcherpaths.moc"
+138
View File
@@ -0,0 +1,138 @@
#include "testenv.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QJsonArray>
#include <QSignalSpy>
class TestLocalization : public QObject
{
Q_OBJECT
Localization &loc() { return Localization::instance(); }
// Значения каталога напрямую из файла — эталон для сравнения.
static QJsonObject catalogEntry(const QString &key)
{
return Fixtures::readJsonFile(QStringLiteral(BILAUNCHER_SOURCE_DIR "/i18n/translations.json"))
.value(QStringLiteral("strings"))
.toObject()
.value(key)
.toObject();
}
private slots:
void init()
{
TestEnv::resetLauncherRoot();
loc().setLanguage(QStringLiteral("en"));
}
void textFollowsLanguage()
{
const QJsonObject entry = catalogEntry(QStringLiteral("app.tooltip.openMods"));
QCOMPARE(Loc::text("app.tooltip.openMods"), entry.value("en").toString());
QSignalSpy spy(&loc(), &Localization::languageChanged);
loc().setLanguage(QStringLiteral("ru"));
QCOMPARE(spy.count(), 1);
QCOMPARE(loc().language(), QStringLiteral("ru"));
QCOMPARE(loc().resolvedLanguage(), QStringLiteral("ru"));
QCOMPARE(Loc::text("app.tooltip.openMods"), entry.value("ru").toString());
// Тот же язык ещё раз — без сигнала.
loc().setLanguage(QStringLiteral("ru"));
QCOMPARE(spy.count(), 1);
}
void unknownLanguageFallsBackToSystem()
{
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("klingon")));
loc().setLanguage(QStringLiteral("klingon"));
QCOMPARE(loc().language(), QStringLiteral("system"));
const QString resolved = loc().resolvedLanguage();
QVERIFY(resolved == QLatin1String("ru") || resolved == QLatin1String("en"));
}
void missingKeyReturnsKey()
{
#ifndef QT_NO_DEBUG
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no\\.such\\.key")));
#endif
QCOMPARE(Loc::text("no.such.key"), QStringLiteral("no.such.key"));
#ifndef QT_NO_DEBUG
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no\\.such\\.list")));
#endif
QCOMPARE(Loc::list("no.such.list"), QStringList{QStringLiteral("no.such.list")});
}
void listsHaveSameShapeInAllLanguages()
{
const QJsonObject strings =
Fixtures::readJsonFile(QStringLiteral(BILAUNCHER_SOURCE_DIR "/i18n/translations.json"))
.value(QStringLiteral("strings"))
.toObject();
int lists = 0;
for (auto it = strings.constBegin(); it != strings.constEnd(); ++it) {
if (!it.value().toObject().value("en").isArray())
continue;
++lists;
const QByteArray key = it.key().toLatin1();
loc().setLanguage(QStringLiteral("en"));
const QStringList en = Loc::list(key.constData());
loc().setLanguage(QStringLiteral("ru"));
const QStringList ru = Loc::list(key.constData());
QCOMPARE(en.size(), ru.size());
QCOMPARE(en.size(), it.value().toObject().value("en").toArray().size());
}
if (lists == 0)
QSKIP("в каталоге нет списков");
}
void availableLanguages()
{
const QVariantList languages = loc().availableLanguages();
QVERIFY(languages.size() >= 3);
QCOMPARE(languages.first().toMap().value("code").toString(), QStringLiteral("system"));
QStringList codes;
for (const QVariant &item : languages)
codes << item.toMap().value("code").toString();
QVERIFY(codes.contains("ru"));
QVERIFY(codes.contains("en"));
for (const QVariant &item : languages)
QVERIFY(!item.toMap().value("name").toString().isEmpty());
}
void storedLanguageIsReadOnLoad()
{
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"language", "ru"}});
QVERIFY(loc().load());
QCOMPARE(loc().language(), QStringLiteral("ru"));
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"language", "xx"}});
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("xx")));
QVERIFY(loc().load());
QCOMPARE(loc().language(), QStringLiteral("system"));
Fixtures::write(LauncherPaths::settingsFile(), "garbage");
QVERIFY(loc().load());
QCOMPARE(loc().language(), QStringLiteral("system"));
}
void everyCppKeyExists()
{
// Тексты, которые код запрашивает через Loc::text("…"), должны быть в каталоге —
// иначе пользователь увидит сам ключ. Полная проверка — tools/check_translations.py,
// здесь — выборка ключей, которыми пользуются тесты.
for (const char *key : {"storage.error.createFailed", "storage.error.notWritable",
"java.error.invalidRuntimeId", "version.error.invalidId",
"launch.error.filesMissing", "auth.offline.error.noNickname"}) {
QVERIFY2(Loc::text(key) != QLatin1String(key), key);
}
}
};
BILAUNCHER_TEST_MAIN(TestLocalization)
#include "tst_localization.moc"
+353
View File
@@ -0,0 +1,353 @@
#include "testenv.h"
#include "fixtures.h"
#include "localization.h"
#include "minecraftversion.h"
#include <QDir>
#include <QJsonArray>
#include <QSysInfo>
namespace {
void writeVersion(const QString &gameDir, const QString &id, const QByteArray &json, bool withJar)
{
const QString dir = gameDir + "/versions/" + id;
Fixtures::write(dir + u'/' + id + ".json", json);
if (withJar)
Fixtures::write(dir + u'/' + id + ".jar", "jar-" + id.toUtf8());
}
QString otherOs()
{
return Fixtures::otherOsName();
}
// Современный ванильный json (1.19+): arguments, natives-классификаторы, logging, quickPlay.
QByteArray modernVanilla()
{
return Fixtures::readTemplate(QStringLiteral("versions/modern-1.20.1.json"));
}
} // namespace
class TestMinecraftVersion : public QObject
{
Q_OBJECT
QString m_game;
private slots:
void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); }
// --- правила ---------------------------------------------------------------
void rulesAllow_data()
{
QTest::addColumn<QByteArray>("rules");
QTest::addColumn<QStringList>("features");
QTest::addColumn<bool>("allowed");
QTest::newRow("no rules") << QByteArray("[]") << QStringList{} << true;
QTest::newRow("allow all") << QByteArray("[{\"action\":\"allow\"}]") << QStringList{} << true;
QTest::newRow("disallow all") << QByteArray("[{\"action\":\"disallow\"}]") << QStringList{} << false;
QTest::newRow("allow this os")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"name\":\"%OS%\"}}]") << QStringList{} << true;
QTest::newRow("allow other os only")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"name\":\"%OTHER%\"}}]") << QStringList{} << false;
QTest::newRow("allow all but this os")
<< QByteArray("[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"%OS%\"}}]")
<< QStringList{} << false;
QTest::newRow("allow all but other os")
<< QByteArray("[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"%OTHER%\"}}]")
<< QStringList{} << true;
QTest::newRow("arch matches")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"arch\":\"%ARCH%\"}}]") << QStringList{} << true;
QTest::newRow("arch differs")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"arch\":\"sparc\"}}]") << QStringList{} << false;
QTest::newRow("os version regex any")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\".*\"}}]") << QStringList{} << true;
QTest::newRow("os version regex none")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\"^nope$\"}}]") << QStringList{} << false;
QTest::newRow("os version invalid regex")
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\"(\"}}]") << QStringList{} << false;
QTest::newRow("feature missing")
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]") << QStringList{} << false;
QTest::newRow("feature present")
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]")
<< QStringList{"is_demo_user"} << true;
QTest::newRow("feature must be absent")
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"has_custom_resolution\":false}}]")
<< QStringList{} << true;
QTest::newRow("last match wins")
<< QByteArray("[{\"action\":\"disallow\"},{\"action\":\"allow\"}]") << QStringList{} << true;
}
void rulesAllow()
{
QFETCH(QByteArray, rules);
QFETCH(QStringList, features);
QFETCH(bool, allowed);
rules = Fixtures::applyTemplate(rules);
const QSet<QString> set(features.cbegin(), features.cend());
QCOMPARE(VersionLoader::rulesAllow(QJsonDocument::fromJson(rules).array(), set), allowed);
}
void platformNames()
{
const QString os = VersionLoader::osName();
QVERIFY(QStringList({"windows", "osx", "linux"}).contains(os));
QVERIFY(!VersionLoader::osArch().isEmpty());
const QString classifier = VersionLoader::nativeClassifier();
QVERIFY(classifier.startsWith(QStringLiteral("natives-")));
#if defined(Q_OS_MACOS)
QCOMPARE(os, QStringLiteral("osx"));
QVERIFY(classifier.startsWith(QStringLiteral("natives-macos")));
#elif defined(Q_OS_WIN)
QCOMPARE(os, QStringLiteral("windows"));
#else
QCOMPARE(os, QStringLiteral("linux"));
#endif
if (QSysInfo::currentCpuArchitecture() == QLatin1String("arm64"))
QVERIFY(classifier.endsWith(QStringLiteral("-arm64")));
}
// --- load -------------------------------------------------------------------
void loadModernVanilla()
{
writeVersion(m_game, "1.20.1", modernVanilla(), true);
Fixtures::write(m_game + "/assets/log_configs/client-1.12.xml", "<xml/>");
QString error;
const MinecraftVersion v = VersionLoader::load(m_game, "1.20.1", {}, &error);
QVERIFY2(error.isEmpty(), qPrintable(error));
QVERIFY(v.isValid());
QCOMPARE(v.id, QStringLiteral("1.20.1"));
QCOMPARE(v.mainClass, QStringLiteral("net.minecraft.client.main.Main"));
QCOMPARE(v.type, QStringLiteral("release"));
QCOMPARE(v.assetIndexId, QStringLiteral("5"));
QCOMPARE(v.assetsKind, QStringLiteral("5"));
QCOMPARE(v.javaMajor, 17);
QCOMPARE(v.clientJarPath, m_game + "/versions/1.20.1/1.20.1.jar");
QCOMPARE(v.loggingArgument, QStringLiteral("-Dlog4j.configurationFile=${path}"));
QCOMPARE(v.loggingConfigPath, m_game + "/assets/log_configs/client-1.12.xml");
QVERIFY(v.supportsQuickPlay);
QVERIFY(v.hasCustomResolutionArgs);
// jvm: правило по ОС, своего -cp у версии есть — лаунчер своё не добавляет.
QVERIFY(v.jvmArgs.contains("-Dthis.os=yes"));
QVERIFY(!v.jvmArgs.contains("-Dother.os=yes"));
QCOMPARE(v.jvmArgs.count("-cp"), 1);
QVERIFY(!v.jvmArgs.contains("-Dminecraft.launcher.brand=${launcher_name}"));
// game: без фич — ни --demo, ни --width, ни quickPlay.
QVERIFY(v.gameArgs.contains("--username"));
QVERIFY(!v.gameArgs.contains("--demo"));
QVERIFY(!v.gameArgs.contains("--width"));
QVERIFY(!v.gameArgs.contains("--quickPlayMultiplayer"));
// Библиотеки: чужая ОС отфильтрована, натив только текущей платформы.
QStringList names;
for (const MinecraftLibrary &lib : v.libraries)
names << lib.name;
QVERIFY(names.contains("com.mojang:brigadier:1.1.8"));
QVERIFY(names.contains("org.lwjgl:lwjgl:3.3.1"));
QVERIFY(names.contains("org.lwjgl:lwjgl:3.3.1:" + VersionLoader::nativeClassifier()));
QVERIFY(!names.contains("org.lwjgl:lwjgl:3.3.1:natives-other"));
QVERIFY(!names.contains("only.other:lib:1.0"));
for (const MinecraftLibrary &lib : v.libraries) {
if (lib.name == "com.mojang:brigadier:1.1.8") {
QCOMPARE(lib.path, QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"));
QCOMPARE(lib.sha1, QStringLiteral("5244ce82c3337bba4a196a3ce858bfaecc74404a"));
QCOMPARE(lib.size, qint64(77392));
QVERIFY(!lib.native);
}
if (lib.name.endsWith(VersionLoader::nativeClassifier()))
QVERIFY(lib.native);
}
}
void featuresEnableArguments()
{
writeVersion(m_game, "1.20.1", modernVanilla(), true);
const QSet<QString> features{"has_custom_resolution", "is_quick_play_multiplayer", "is_demo_user"};
const MinecraftVersion v = VersionLoader::load(m_game, "1.20.1", features, nullptr);
QVERIFY(v.gameArgs.contains("--demo"));
const qsizetype width = v.gameArgs.indexOf("--width");
QVERIFY(width >= 0);
QCOMPARE(v.gameArgs.at(width + 1), QStringLiteral("${resolution_width}"));
QVERIFY(v.gameArgs.contains("--quickPlayMultiplayer"));
}
void legacyMinecraftArguments()
{
writeVersion(m_game, "1.7.10", Fixtures::read(QStringLiteral("versions/legacy-1.7.10.json")), true);
const MinecraftVersion v = VersionLoader::load(m_game, "1.7.10", {}, nullptr);
QVERIFY(v.isValid());
QCOMPARE(v.gameArgs,
QStringList({"--username", "${auth_player_name}", "--session", "${auth_session}",
"--gameDir", "${game_directory}"}));
QCOMPARE(v.assetIndexId, QStringLiteral("1.7.10")); // нет assetIndex — берётся assets
QCOMPARE(v.type, QStringLiteral("release")); // нет type — release
QCOMPARE(v.javaMajor, 8); // нет javaVersion — 8
QVERIFY(!v.supportsQuickPlay);
// Нет -cp в jvm — лаунчер добавляет стандартный набор.
QVERIFY(v.jvmArgs.contains("-cp"));
QVERIFY(v.jvmArgs.contains("${classpath}"));
QVERIFY(v.jvmArgs.contains("-Djava.library.path=${natives_directory}"));
QVERIFY(v.jvmArgs.contains("-Dminecraft.launcher.brand=${launcher_name}"));
bool sawNative = false;
for (const MinecraftLibrary &lib : v.libraries) {
if (lib.name == "net.minecraft:launchwrapper:1.12") {
// Без downloads: путь из координат, url = репозиторий + путь.
QCOMPARE(lib.path, QStringLiteral("net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar"));
QCOMPARE(lib.url, QStringLiteral("https://libraries.example/maven/net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar"));
}
if (lib.native) {
sawNative = true;
QCOMPARE(lib.extractExclude, QStringList{"META-INF/"});
#if defined(Q_OS_WIN)
QVERIFY(lib.path == "lw/natives-windows-64.jar" || lib.path == "lw/natives-windows-32.jar");
#elif defined(Q_OS_MACOS)
QCOMPARE(lib.path, QStringLiteral("lw/natives-osx.jar"));
#else
QCOMPARE(lib.path, QStringLiteral("lw/natives-linux.jar"));
#endif
}
}
QVERIFY(sawNative);
}
void inheritanceMergesChain()
{
writeVersion(m_game, "1.20.1", modernVanilla(), true);
writeVersion(m_game, "fabric-loader-0.15.0-1.20.1", Fixtures::read(QStringLiteral("versions/fabric-loader-0.15.0-1.20.1.json")), false);
QString error;
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.15.0-1.20.1", {}, &error);
QVERIFY2(error.isEmpty(), qPrintable(error));
QCOMPARE(v.id, QStringLiteral("fabric-loader-0.15.0-1.20.1"));
QCOMPARE(v.mainClass, QStringLiteral("net.fabricmc.loader.impl.launch.knot.KnotClient"));
QCOMPARE(v.javaMajor, 17); // от родителя
QCOMPARE(v.assetIndexId, QStringLiteral("5")); // от родителя
QCOMPARE(v.clientJarPath, m_game + "/versions/1.20.1/1.20.1.jar"); // своего jar нет
QVERIFY(v.jvmArgs.contains("-DFabricMcEmu= net.minecraft.client.main.Main "));
QVERIFY(v.jvmArgs.contains("-Dthis.os=yes")); // jvm родителя
QVERIFY(v.gameArgs.contains("--username"));
// Дочерние библиотеки первыми и вытесняют те же group:artifact родителя.
QCOMPARE(v.libraries.first().name, QStringLiteral("net.fabricmc:fabric-loader:0.15.0"));
QStringList names;
for (const MinecraftLibrary &lib : v.libraries)
names << lib.name;
QVERIFY(names.contains("com.mojang:brigadier:9.9.9"));
QVERIFY(!names.contains("com.mojang:brigadier:1.1.8"));
QCOMPARE(v.libraries.first().url,
QStringLiteral("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"));
}
void childJarWinsOverParent()
{
writeVersion(m_game, "1.20.1", modernVanilla(), true);
writeVersion(m_game, "custom", "{\"id\":\"custom\",\"inheritsFrom\":\"1.20.1\"}", true);
const MinecraftVersion v = VersionLoader::load(m_game, "custom", {}, nullptr);
QCOMPARE(v.clientJarPath, m_game + "/versions/custom/custom.jar");
QCOMPARE(v.mainClass, QStringLiteral("net.minecraft.client.main.Main"));
}
void loadErrors()
{
QString error;
MinecraftVersion v = VersionLoader::load(m_game, "missing", {}, &error);
QVERIFY(!v.isValid());
QVERIFY(error.contains(QDir::toNativeSeparators(m_game + "/versions/missing/missing.json")));
writeVersion(m_game, "broken", "{ nope", false);
error.clear();
v = VersionLoader::load(m_game, "broken", {}, &error);
QVERIFY(!v.isValid());
QVERIFY(!error.isEmpty());
writeVersion(m_game, "a", "{\"id\":\"a\",\"inheritsFrom\":\"b\",\"mainClass\":\"M\"}", false);
writeVersion(m_game, "b", "{\"id\":\"b\",\"inheritsFrom\":\"a\"}", false);
error.clear();
v = VersionLoader::load(m_game, "a", {}, &error);
QVERIFY(!v.isValid());
QCOMPARE(error, Loc::text("version.error.inheritsCycle").arg("a"));
// Цепочка длиннее 10 уровней.
for (int i = 0; i < 12; ++i)
writeVersion(m_game, QStringLiteral("v%1").arg(i),
QStringLiteral("{\"inheritsFrom\":\"v%1\",\"mainClass\":\"M\"}").arg(i + 1).toUtf8(), false);
writeVersion(m_game, "v12", "{\"mainClass\":\"M\"}", false);
error.clear();
v = VersionLoader::load(m_game, "v0", {}, &error);
QVERIFY(!v.isValid());
QCOMPARE(error, Loc::text("common.error.inheritsChainTooLong").arg("v0"));
writeVersion(m_game, "nomain", "{\"id\":\"nomain\"}", false);
error.clear();
v = VersionLoader::load(m_game, "nomain", {}, &error);
QVERIFY(!v.isValid());
QCOMPARE(error, Loc::text("version.error.noMainClass").arg("nomain"));
}
// --- инвентарь --------------------------------------------------------------
void installedDependentsSizeRemove()
{
writeVersion(m_game, "1.20.1", modernVanilla(), true);
writeVersion(m_game, "forge-1", "{\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"F\"}", false);
writeVersion(m_game, "fabric-1", "{\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"F\"}", false);
writeVersion(m_game, "other", "{\"inheritsFrom\":\"1.19\",\"mainClass\":\"F\"}", false);
QDir().mkpath(m_game + "/versions/emptydir"); // без json — не версия
QCOMPARE(VersionLoader::installedVersions(m_game),
QStringList({"1.20.1", "fabric-1", "forge-1", "other"}));
QStringList dependents = VersionLoader::dependentsOf(m_game, "1.20.1");
dependents.sort();
QCOMPARE(dependents, QStringList({"fabric-1", "forge-1"}));
QVERIFY(VersionLoader::dependentsOf(m_game, QString()).isEmpty());
const qint64 expected = QFileInfo(m_game + "/versions/1.20.1/1.20.1.json").size()
+ QFileInfo(m_game + "/versions/1.20.1/1.20.1.jar").size();
QCOMPARE(VersionLoader::installedSize(m_game, "1.20.1"), expected);
QCOMPARE(VersionLoader::installedSize(m_game, "../.."), qint64(0));
QString error;
QVERIFY(VersionLoader::remove(m_game, "forge-1", &error));
QVERIFY(!QDir(m_game + "/versions/forge-1").exists());
QVERIFY(VersionLoader::remove(m_game, "forge-1", &error)); // уже нет — не ошибка
QCOMPARE(VersionLoader::installedVersions(m_game), QStringList({"1.20.1", "fabric-1", "other"}));
}
void removeRejectsUnsafeIds_data()
{
QTest::addColumn<QString>("id");
QTest::newRow("empty") << "";
QTest::newRow("dotdot") << "..";
QTest::newRow("hidden") << ".git";
QTest::newRow("slash") << "a/b";
QTest::newRow("backslash") << "a\\b";
}
void removeRejectsUnsafeIds()
{
QFETCH(QString, id);
Fixtures::write(m_game + "/keep.txt", "keep");
QString error;
QVERIFY(!VersionLoader::remove(m_game, id, &error));
QCOMPARE(error, Loc::text("version.error.invalidId"));
QVERIFY(QFile::exists(m_game + "/keep.txt"));
error.clear();
QVERIFY(!VersionLoader::remove(QString(), "1.20.1", &error));
}
};
BILAUNCHER_TEST_MAIN(TestMinecraftVersion)
#include "tst_minecraftversion.moc"
+46
View File
@@ -0,0 +1,46 @@
#include "testenv.h"
#include "modloader.h"
class TestModLoader : public QObject
{
Q_OBJECT
private slots:
void keysRoundTrip()
{
for (ModLoader loader : {ModLoader::Forge, ModLoader::Fabric, ModLoader::NeoForge, ModLoader::Quilt}) {
const auto back = loaderFromKey(loaderKey(loader));
QVERIFY(back.has_value());
QCOMPARE(*back, loader);
QVERIFY(!loaderTitle(loader).isEmpty());
}
}
void knownKeys()
{
QCOMPARE(loaderKey(ModLoader::Forge), QStringLiteral("forge"));
QCOMPARE(loaderKey(ModLoader::Fabric), QStringLiteral("fabric"));
QCOMPARE(loaderKey(ModLoader::NeoForge), QStringLiteral("neoforge"));
QCOMPARE(loaderKey(ModLoader::Quilt), QStringLiteral("quilt"));
QCOMPARE(loaderTitle(ModLoader::NeoForge), QStringLiteral("NeoForge"));
}
void unknownKeys()
{
QVERIFY(!loaderFromKey(QString()).has_value());
QVERIFY(!loaderFromKey(QStringLiteral("Forge")).has_value()); // регистр важен
QVERIFY(!loaderFromKey(QStringLiteral("liteloader")).has_value());
}
void entryDefaults()
{
const LoaderVersionEntry entry;
QVERIFY(entry.stable);
QVERIFY(!entry.recommended);
QVERIFY(entry.installerUrl.isEmpty());
}
};
BILAUNCHER_TEST_MAIN(TestModLoader)
#include "tst_modloader.moc"
+75
View File
@@ -0,0 +1,75 @@
#include "testenv.h"
#include "msaauthservice.h"
#include <QUrlQuery>
class TestMsaRedirect : public QObject
{
Q_OBJECT
private slots:
void authorizationUrlCarriesClientAndRedirect()
{
const QUrl url = MsaAuthService::authorizationUrl();
QCOMPARE(url.scheme(), QStringLiteral("https"));
QCOMPARE(url.host(), QStringLiteral("login.live.com"));
const QUrlQuery query(url);
QCOMPARE(query.queryItemValue("client_id"), MsaAuthService::clientId());
QCOMPARE(query.queryItemValue("response_type"), QStringLiteral("code"));
QVERIFY(!query.queryItemValue("scope").isEmpty());
// Код возвращается на тот адрес, который потом распознаёт matchRedirect.
const QUrl redirect(query.queryItemValue("redirect_uri", QUrl::FullyDecoded));
QString code;
QVERIFY(MsaAuthService::matchRedirect(QUrl(redirect.toString() + "?code=abc"), &code, nullptr));
QCOMPARE(code, QStringLiteral("abc"));
}
void matchRedirect_data()
{
QTest::addColumn<QString>("url");
QTest::addColumn<bool>("matches");
QTest::addColumn<QString>("code");
QTest::addColumn<QString>("error");
QTest::newRow("code in query")
<< "https://login.live.com/oauth20_desktop.srf?code=M.C123-abc&lc=1049" << true << "M.C123-abc" << "";
QTest::newRow("code in fragment")
<< "https://login.live.com/oauth20_desktop.srf#code=FRAG&state=1" << true << "FRAG" << "";
QTest::newRow("encoded code")
<< "https://login.live.com/oauth20_desktop.srf?code=a%2Bb%3D" << true << "a+b=" << "";
QTest::newRow("error with description")
<< "https://login.live.com/oauth20_desktop.srf?error=access_denied&error_description=The%20user%20denied"
<< true << "" << "The user denied";
QTest::newRow("error only")
<< "https://login.live.com/oauth20_desktop.srf?error=server_error" << true << "" << "server_error";
QTest::newRow("host case-insensitive")
<< "https://LOGIN.LIVE.COM/oauth20_desktop.srf?code=x" << true << "x" << "";
QTest::newRow("redirect without params")
<< "https://login.live.com/oauth20_desktop.srf" << true << "" << "";
QTest::newRow("authorize page") << "https://login.live.com/oauth20_authorize.srf?code=x" << false << "" << "";
QTest::newRow("foreign host") << "https://evil.example/oauth20_desktop.srf?code=x" << false << "" << "";
QTest::newRow("empty") << "" << false << "" << "";
}
void matchRedirect()
{
QFETCH(QString, url);
QFETCH(bool, matches);
QFETCH(QString, code);
QFETCH(QString, error);
QString actualCode = QStringLiteral("untouched");
QString actualError = QStringLiteral("untouched");
QCOMPARE(MsaAuthService::matchRedirect(QUrl(url), &actualCode, &actualError), matches);
if (matches) {
QCOMPARE(actualCode, code);
QCOMPARE(actualError, error);
} else {
QCOMPARE(actualCode, QStringLiteral("untouched"));
}
}
};
BILAUNCHER_TEST_MAIN(TestMsaRedirect)
#include "tst_msaredirect.moc"
+65
View File
@@ -0,0 +1,65 @@
#include "testenv.h"
#include "localization.h"
#include "zlibreference.h"
#include <QDir>
#include <QFileInfo>
#include <QProcessEnvironment>
class TestZlibReference : public QObject
{
Q_OBJECT
private slots:
void nonLinuxIsNoop()
{
#if defined(Q_OS_LINUX)
QSKIP("только для Windows и macOS");
#else
QVERIFY(!ZlibReference::systemIsZlibNg());
QVERIFY(ZlibReference::bundledPath().isEmpty());
QProcessEnvironment env;
env.insert(QStringLiteral("LD_PRELOAD"), QStringLiteral("/x.so"));
QString note = QStringLiteral("stale");
QVERIFY(ZlibReference::applyTo(env, &note));
QVERIFY(note.isEmpty());
QCOMPARE(env.value(QStringLiteral("LD_PRELOAD")), QStringLiteral("/x.so"));
#endif
}
void linuxPreloadFollowsSystemZlib()
{
#if !defined(Q_OS_LINUX)
QSKIP("только для Linux");
#else
QProcessEnvironment env;
env.insert(QStringLiteral("LD_PRELOAD"), QStringLiteral("/other.so"));
QString note;
const bool ok = ZlibReference::applyTo(env, &note);
if (!ZlibReference::systemIsZlibNg()) {
// Обычный zlib: окружение не трогаем.
QVERIFY(ok);
QVERIFY(note.isEmpty());
QCOMPARE(env.value(QStringLiteral("LD_PRELOAD")), QStringLiteral("/other.so"));
return;
}
const QString bundled = ZlibReference::bundledPath();
if (bundled.isEmpty()) {
QVERIFY(!ok);
QCOMPARE(note, Loc::text("zlib.warning.referenceMissing"));
QCOMPARE(env.value(QStringLiteral("LD_PRELOAD")), QStringLiteral("/other.so"));
} else {
QVERIFY(ok);
// Наш zlib первым, чужой LD_PRELOAD сохраняется.
QCOMPARE(env.value(QStringLiteral("LD_PRELOAD")), bundled + QStringLiteral(":/other.so"));
QVERIFY(QFileInfo(bundled).isFile());
}
#endif
}
};
BILAUNCHER_TEST_MAIN(TestZlibReference)
#include "tst_zlibreference.moc"