5 Commits
Author SHA1 Message Date
galeon 3ac2156ef2 traslations fix
tests / windows (push) Failing after 6m19s
tests / linux (push) Successful in 2m12s
tests / macos (push) Failing after 1m15s
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
release / create-release (push) Successful in 3s
release / build-linux (push) Successful in 2m17s
release / build-macos (push) Successful in 2m44s
release / build-windows (push) Successful in 6m27s
release / publish (push) Successful in 1s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-14 09:32:17 +03:00
galeon 5d819129d6 Обновить .gitea/workflows/tests.yml
tests / package-smoke-linux (push) Skipped
tests / package-smoke-windows (push) Skipped
tests / package-smoke-macos (push) Skipped
tests / macos (push) Failing after 1m20s
tests / linux (push) Successful in 2m13s
tests / windows (push) Failing after 12m6s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 06:01:40 +00:00
galeon 0dd6bef715 test fixes
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
tests / linux (push) Successful in 1m59s
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-13 08:51:07 +03:00
galeon 41599fa82f 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>
2026-10-12 13:14:08 +03:00
galeon ebf8ff7869 release workflow fix
Signed-off-by: galeon <dertiellis@gmail.com>
2026-10-12 09:25:48 +03:00
74 changed files with 8877 additions and 34 deletions

No files matched your search

-25
View File
@@ -1,16 +1,3 @@
# Сборка BiLauncher под Windows, Linux и macOS и публикация в Gitea Releases.
#
# push тега vX.Y.Z (или vX.Y.Z-rc1 → pre-release):
# create-release создаёт черновик релиза → три сборки прикладывают к нему файлы →
# publish добавляет SHA256SUMS.txt и снимает флаг черновика. Если какая-то сборка
# упала, релиз остаётся черновиком и пользователям не виден.
# Ручной запуск (workflow_dispatch): те же сборки, но результат — артефакты прогона,
# релиз не создаётся.
#
# Пути к Qt на раннерах задаются в их config.yaml (runner.envs): QT_ROOT — kit Qt,
# QT_TOOLS — папка Tools из Qt Online Installer (там CMake и Ninja).
# Секрет RELEASE_TOKEN — PAT с правом repository: write.
name: release
on:
@@ -56,7 +43,6 @@ jobs:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
# Описание релиза — раздел CHANGELOG.md для этой версии
base="${TAG#v}"; base="${base%%-*}"
notes=$(awk -v v="$base" '
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
@@ -85,12 +71,9 @@ jobs:
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build AppDir dist
# LIBDIR=lib: zlibreference.cpp ищет libz рядом, в ../lib/BiLauncher,
# а на Ubuntu по умолчанию был бы lib/x86_64-linux-gnu.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
# На ВМ 6 ГБ памяти: без ограничения 42 параллельных компилятора уходят в OOM.
cmake --build build -j 8
cmake --install build --prefix "$PWD/AppDir/usr"
@@ -142,7 +125,6 @@ jobs:
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Короткие пути: дерево WebEngine легко вылезает за MAX_PATH.
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
Remove-Item -Recurse -Force $build, $stage, $dist -ErrorAction SilentlyContinue
@@ -163,14 +145,10 @@ jobs:
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
if ($LASTEXITCODE) { throw "iscc" }
# Portable: то же дерево, что ставит установщик, без vc_redist
# (в portable-варианте среда выполнения MSVC должна уже стоять в системе).
Remove-Item "$stage\bin\vc_redist.x64.exe" -ErrorAction SilentlyContinue
$portable = "C:\r\BiLauncher-$env:VERSION"
Remove-Item -Recurse -Force $portable -ErrorAction SilentlyContinue
Move-Item $stage $portable
# Системный bsdtar по полному пути: в PATH раннера раньше стоит GNU tar из Git,
# который не умеет zip и принимает «C:» за имя удалённого хоста.
& "$env:SystemRoot\System32\tar.exe" -a -c -f "$dist\BiLauncher-$env:VERSION-win64-portable.zip" -C C:\r "BiLauncher-$env:VERSION"
if ($LASTEXITCODE) { throw "zip" }
Remove-Item -Recurse -Force $portable
@@ -179,7 +157,6 @@ jobs:
$sums = Get-ChildItem $dist | ForEach-Object {
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name }
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($sums -join "`n") + "`n"))
# Не Out-File: PowerShell 5.1 пишет UTF-8 с BOM, и имя первой переменной ломается.
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
- name: Загрузка в релиз
@@ -226,8 +203,6 @@ jobs:
- id: pack
name: Ad-hoc подпись и DMG
run: |
# Без Developer ID: ad-hoc подпись нужна, чтобы приложение вообще
# запускалось на Apple Silicon; Gatekeeper всё равно спросит подтверждение.
codesign --force --deep --sign - stage/BiLauncher.app
codesign --verify --deep --strict stage/BiLauncher.app
mkdir -p dmgroot dist
+183
View File
@@ -0,0 +1,183 @@
name: tests
on:
push:
branches: ["**"]
pull_request:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: linux
timeout-minutes: 90
env:
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
steps:
- uses: actions/checkout@v4
- name: Сборка лаунчера и тестов
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
launcher=""
if command -v ccache >/dev/null; then
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
fi
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT" $launcher
cmake --build build-tests -j 8
- name: Статические проверки (переводы, qmllint, CHANGELOG)
run: CMAKE="$QT_TOOLS/CMake/bin/cmake" python3 tests/scripts/static_checks.py build-tests
- name: Тесты
run: |
export PATH="$QT_TOOLS/CMake/bin:$PATH"
ctest --test-dir build-tests --output-on-failure -j 4 \
--output-junit "$PWD/build-tests/junit.xml"
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-linux
if-no-files-found: ignore
path: |
build-tests/junit.xml
build-tests/qmllint.log
build-tests/Testing/Temporary/LastTest.log
windows:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: windows
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Сборка и тесты
shell: powershell
run: |
$ErrorActionPreference = "Stop"
# Короткий путь: дерево WebEngine легко вылезает за MAX_PATH (см. release.yml).
$build = "C:\r\bl-tests"
Remove-Item -Recurse -Force $build -ErrorAction SilentlyContinue
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBILAUNCHER_BUILD_TESTS=ON "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
$code = $LASTEXITCODE
New-Item -ItemType Directory -Force test-results | Out-Null
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
if ($code) { throw "ctest" }
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-windows
if-no-files-found: ignore
path: test-results/
macos:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: macos
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Сборка и тесты
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT"
cmake --build build-tests
ctest --test-dir build-tests --output-on-failure -j 4 \
--output-junit "$PWD/build-tests/junit.xml"
- name: Отчёты
if: always()
uses: actions/upload-artifact@v4
with:
name: tests-macos
if-no-files-found: ignore
path: build-tests/junit.xml
package-smoke-linux:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: linux
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
run: |
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build-smoke smoke-stage
launcher=""
if command -v ccache >/dev/null; then
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
fi
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib $launcher
cmake --build build-smoke -j 8
cmake --install build-smoke --prefix "$PWD/smoke-stage/usr"
bash tests/scripts/smoke_installed.sh smoke-stage/usr
package-smoke-windows:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: windows
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$build = "C:\r\bl-smoke"; $stage = "C:\r\bl-smoke-stage"
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
if ($LASTEXITCODE) { throw "cmake configure" }
cmake --build $build
if ($LASTEXITCODE) { throw "cmake build" }
cmake --install $build --prefix $stage
if ($LASTEXITCODE) { throw "cmake install" }
& powershell -ExecutionPolicy Bypass -File tests\scripts\smoke_installed.ps1 -Prefix $stage
if ($LASTEXITCODE) { throw "smoke" }
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
package-smoke-macos:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Release-сборка, установка, запуск
run: |
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
rm -rf build-smoke smoke-stage
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QT_ROOT"
cmake --build build-smoke
cmake --install build-smoke --prefix "$PWD/smoke-stage"
bash tests/scripts/smoke_installed.sh smoke-stage
+2
View File
@@ -64,3 +64,5 @@ build/
CMakeLists.txt.user*
stage/
dist/
build-tests/
build-*/
+9 -1
View File
@@ -242,4 +242,12 @@ qt_generate_deploy_qml_app_script(
NO_UNSUPPORTED_PLATFORM_ERROR
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
DEPLOY_TOOL_OPTIONS ${deploy_opts})
install(SCRIPT ${deploy_script})
install(SCRIPT ${deploy_script})
# Автотесты: собираются только по запросу и в установку не попадают.
# Код лаунчера при этом не меняется — тесты компилируют те же исходники
# в отдельную статическую библиотеку, см. tests/CMakeLists.txt и doc/testing.md.
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
if(BILAUNCHER_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
+4 -2
View File
@@ -23,10 +23,12 @@ RDIR="$HOME/act_runner"
# --- Компилятор и библиотеки ---------------------------------------------------
# libgl1-mesa-dev — заголовки OpenGL для Qt6::Gui; xcb-* и прочие — то, что
# Linux-деплой Qt и плагин xcb ищут при упаковке и запуске; libfuse2 — для
# запуска самого appimagetool (он тоже AppImage).
# запуска самого appimagetool (он тоже AppImage). ccache ускоряет прогоны
# автотестов (.gitea/workflows/tests.yml), xvfb нужен их дымовой проверке
# установленного дерева, если деплой Qt не положил плагин offscreen.
sudo apt-get update -q
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -q \
build-essential patchelf file curl git xz-utils \
build-essential patchelf file curl git xz-utils ccache xvfb python3 \
libgl1-mesa-dev libfuse2 libfontconfig1 libdbus-1-3 \
libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 \
+6
View File
@@ -98,6 +98,9 @@ QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIREC
### Как выпустить версию
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
@@ -166,6 +169,9 @@ VS 2022 Build Tools (это 20–40 минут), Git, Node.js, Inno Setup, вк
**macOS** — скрипта подготовки нет, раннер настроен вручную.
Те же раннеры гоняют автотесты (`.gitea/workflows/tests.yml`): Linux — на каждый push, все три
системы — ночью и по кнопке. Подробности — [testing.md](testing.md).
---
При создании этого документа использовался ИИ.
+13
View File
@@ -21,6 +21,19 @@
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
## BILAUNCHER_HOME
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
Внутри `rootDir()`:
```
+4 -1
View File
@@ -4,7 +4,8 @@
версий, установка модлоадеров и Java, смена сборок и запуск игры, написано на C++.
Здесь описано, как устроен код. Как собрать проект и выпустить релиз, рассказано в
[build-and-release.md](build-and-release.md).
[build-and-release.md](build-and-release.md), как устроены и запускаются автотесты — в
[testing.md](testing.md).
## Общая схема
@@ -131,12 +132,14 @@ LauncherBackend ──────────────── Loc (Localizati
|------|---------|
| `i18n/translations.json` | все тексты интерфейса |
| `tools/check_translations.py` | проверка каталога переводов |
| `tests/` | автотесты, см. [testing.md](testing.md) |
| `icons/` | иконки и скрипт, который их генерирует |
| `images/` | картинки интерфейса |
| `zlib/` | исходники zlib 1.3.1 (чужой код) |
| `packaging/` | установщик Windows, лицензии сторонних компонентов |
| `ci/` | скрипты подготовки раннеров |
| `.gitea/workflows/release.yml` | сборка релизов |
| `.gitea/workflows/tests.yml` | автотесты на тех же раннерах |
| `Screenshots/` | картинки для README |
---
+170
View File
@@ -0,0 +1,170 @@
# Автотесты
Тесты лежат в `tests/` и собираются только по опции `BILAUNCHER_BUILD_TESTS`. В обычную сборку и в
релизы они не попадают, код лаунчера ради них не менялся.
## Как запустить
```bash
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH=$HOME/Qt/6.11.1/macos
```
```bash
cmake --build build-tests
```
```bash
ctest --test-dir build-tests --output-on-failure -j 6
```
Отдельный слой — по метке: `ctest --test-dir build-tests -L unit` (ещё `net`, `backend`, `qml`).
Отдельный тест — напрямую, с фильтром по функции:
```bash
build-tests/tests/net/tst_versioninstaller installsModernVersionCompletely
```
Статические проверки (переводы, qmllint, раздел CHANGELOG для текущей версии):
```bash
python3 tests/scripts/static_checks.py build-tests
```
В Qt Creator проект с тестами открывается как обычно: включите `BILAUNCHER_BUILD_TESTS` в настройках
CMake, тесты появятся в панели «Тесты».
## Что покрыто
| Метка | Тест | Что проверяет |
|-------|------|---------------|
| unit | `tst_launcherpaths` | раскладка папки лаунчера, `ensureRootExists` и её отказ, если на месте папки файл |
| unit | `tst_javaruntime` | ключи видов Java, `JavaRuntimeStore`, какая Java нужна какой версии игры |
| unit | `tst_modloader` | ключи и названия модлоадеров |
| unit | `tst_localization` | переключение языков, запасной язык, отсутствующий ключ, язык из `settings.json` |
| unit | `tst_minecraftversion` | правила Mojang, загрузка json версии, `inheritsFrom`, нативы, удаление версий |
| unit | `tst_authoffline` | офлайн-UUID как у сервера в offline-mode |
| unit | `tst_msaredirect` | разбор адреса возврата после входа Microsoft |
| unit | `tst_gamelauncher` | командная строка игры, нативы, запуск, падение и остановка процесса |
| unit | `tst_javalocator` | версия java по `-version`, выбор подходящей, поиск в папке лаунчера |
| unit | `tst_zlibreference` | `LD_PRELOAD` с эталонным zlib на Linux |
| unit | `tst_buildarchiveworker` | архив, очистка, восстановление, раскатка пака, zip-slip, отмена |
| unit | `tst_buildswitcher` | переключение сборок туда и обратно, продолжение после обрыва, пак |
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
| net | `tst_modloaderversionservice` | Fabric, Quilt, Forge (с рекомендованными), NeoForge обе схемы |
| net | `tst_javaruntimeservice` | Adoptium и Mojang, частичные отказы, кеш, запасной x64 на ARM |
| net | `tst_authservice` | Ely.by: пароль, 2FA, продление токена, authlib-injector |
| net | `tst_msaauthservice` | вся цепочка Microsoft → Xbox → XSTS → Minecraft, коды ошибок XSTS |
| net | `tst_versioninstaller` | установка версии целиком, повторы, контрольные суммы, отмена, очередь |
| net | `tst_seasonalpackdownloader` | загрузка пака: sha256, обрыв, отмена |
| net | `tst_javainstaller` | Temurin zip и tar.gz, дерево Mojang, zip-slip, отмена |
| net | `tst_modloaderinstaller` | Fabric по сети, Forge через подставную java, откат после сбоя |
| backend | `tst_launcherbackend` | всё, что зовёт QML: профили, сборки, настройки, миграция, битые файлы, каталог версий, запуск офлайн / Ely.by с 2FA / Microsoft, Java, сезонная сборка от каталога до раскатки обновления |
| qml | `tst_qml` | Qt Quick Test: `DarkCombo`, `LabelledField`, `ProgressPanel`, `LoaderRow`, диалоги с настоящим `LauncherBackend` |
| qml | `tst_qml_smoke` | `Main.qml` поднимается, как в `main.cpp`, без единого предупреждения QML, в том числе после смены языка |
Ночью и перед релизом ещё собирается Release, ставится через `cmake --install` и запускается на
10 секунд (`tests/scripts/smoke_installed.sh` / `.ps1`). Это ловит то, чего не видят тесты:
недоложенные деплоем модули Qt и плагины, ссылки на библиотеки вне дерева, неподписанный бандл.
## Как тесты изолированы
**Файлы.** `LauncherPaths` строит пути от домашней папки. Общий `main` тестов
(`tests/common/testenv.h`) до создания приложения направляет `BILAUNCHER_HOME` (а заодно `HOME`
и `USERPROFILE`) на временную папку, и каждый процесс работает в своей, на всех ОС, параллельно.
`BILAUNCHER_HOME` нужна ради Windows: там `QDir::homePath()` берёт профиль у системы и `HOME`
с `USERPROFILE` не читает. Дочерние процессы наследуют переменную. Если папка лаунчера или папка
игры по умолчанию всё же оказалась вне песочницы, процесс сразу завершается с кодом 2 и ничего не
трогает. Дымовая проверка установленной сборки на Windows (`smoke_installed.ps1`) тоже запускает
лаунчер с `BILAUNCHER_HOME` на временную папку.
**Сеть.** Адреса серверов в сервисах зашиты константами, а свои `QNetworkAccessManager` они
создают сами. Зато прокси и SSL-настройки у Qt общие на процесс, и код лаунчера их не трогает.
`FakeInternet` (`tests/common/fakeinternet.h`) ставит прокси на локальный порт и отключает
проверку сертификата. Каждое https-соединение приходит к нему как `CONNECT host:443`, переводится
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
`FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную
как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов,
сами ресурсы, конфиг логирования.
**Java.** `fake_java` (`tests/fake_java/main.cpp`, без Qt) копируется в нужную папку как
`bin/java` (`java.exe`/`javaw.exe`). На `-version` она отвечает версией из `fake-java.cfg` рядом с
собой, а при обычном запуске пишет аргументы в файл, печатает заданные строки, создаёт файлы
(так изображается установщик Forge) и выходит с нужным кодом либо падает.
**Код лаунчера.** `tests/CMakeLists.txt` собирает все `.cpp` из корня, кроме `main.cpp`, в
статическую библиотеку-QML-модуль `bilauncher_testlib`. Новый файл попадёт в тесты сам. QML-файлы
берутся тоже все, ресурсы — из строк `RESOURCES` корневого `CMakeLists.txt`. Тестовая библиотека
собирается без Qt WebEngine, так что `backend.microsoftAvailable` в тестах всегда `false`.
## Найденные проблемы (QEXPECT_FAIL)
Тест, который описывает правильное поведение, но пока падает, помечен `QEXPECT_FAIL`. В отчёте он
выглядит как `XFAIL` и прогон не валит. Когда ошибку исправят, тест станет `XPASS` и прогон упадёт:
тогда пометку нужно снять.
| Тест | Проблема |
|------|----------|
| `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» |
| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают |
| `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` |
| `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал |
| `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся |
Ещё одна особенность, которую тесты обходят: синглтон `Localization` запоминает JS-движок, который
его создал. Второй `QQmlApplicationEngine` после уничтожения первого оставляет этот указатель
висячим, поэтому дымовой тест держит один движок на весь прогон, как и приложение.
## CI: `.gitea/workflows/tests.yml`
Тесты идут на тех же host-раннерах, что собирают релизы. Отдельные машины и Docker не нужны:
Qt, CMake и Ninja там уже есть, пути берутся из `QT_ROOT` и `QT_TOOLS` в `config.yaml` раннера.
| Событие | Что запускается |
|---------|-----------------|
| push в любую ветку, pull request | Linux: сборка с тестами, статические проверки, все тесты |
| ночью (02:00 UTC) и вручную (Run workflow) | Linux, Windows и macOS, плюс дымовая проверка установленного дерева на всех трёх |
Windows и macOS на каждый коммит не запускаются: вместимость у раннеров 1, и часовая сборка с
WebEngine задерживала бы релизы. Перед выпуском версии запустите workflow вручную и дождитесь
зелёного прогона. `release.yml` от тестов не зависит.
Новый push в ту же ветку отменяет незаконченный прогон (`concurrency`). Отчёт JUnit
(`junit.xml`), лог qmllint и полный лог ctest прикладываются к прогону как артефакты.
На Linux-раннере полезен `ccache`: с ним повторная сборка занимает минуты. `ci/setup-runner-linux.sh`
ставит его вместе с `xvfb`, который нужен дымовой проверке, если деплой Qt не положил плагин
`offscreen`.
**qmllint.** В коде уже есть 53 предупреждения, а код ради тестов не правится. Поэтому проверка
работает как храповик: она падает, только если предупреждений стало больше числа в
`tests/qmllint-baseline.txt`. Если часть исправили, опустите число.
## Как добавить тест
1. Создайте `tests/<слой>/tst_<имя>.cpp` с классом на QtTest и в конце файла напишите
`BILAUNCHER_TEST_MAIN(Класс)` (или `BILAUNCHER_GUI_TEST_MAIN`, если нужен `QGuiApplication`).
Затем `#include "tst_<имя>.moc"`.
2. Добавьте имя в список в `tests/<слой>/CMakeLists.txt`.
3. В `init()` вызывайте `TestEnv::resetLauncherRoot()`, папку игры берите из
`TestEnv::freshDir("game")`.
4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)`
и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса
поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`,
`hang`, `drop`, `truncateTo`.
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
`Fixtures::readTemplate()`.
У moc есть ограничение: сырые строки `R"(...)"` и длинные вложенные инициализаторы `{{...}}`,
разбитые на строки, внутри файла с `Q_OBJECT` сбивают его разбор («Not a signal or slot
declaration» или пустой `.moc`). Такие данные выносите в фикстуры или собирайте по шагам.
---
При создании этого документа использовался ИИ.
+6 -1
View File
@@ -8,6 +8,7 @@
#include <QFileInfo>
#include <QHash>
#include <QProcess>
#include <QRegularExpression>
#include <QtCore/private/qzipreader_p.h>
namespace {
@@ -46,7 +47,11 @@ bool isNativeLibraryFile(const QString &fileName)
for (const QString &suffix : suffixes)
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
return true;
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры,
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
QRegularExpression::CaseInsensitiveOption);
return versionedSo.match(fileName).hasMatch();
}
} // namespace
+1 -1
View File
@@ -9,7 +9,7 @@
"en": "English"
},
"_readme": [
"Единственный файл со всеми текстами интерфейса. Правится руками.",
"Файл локализации",
"Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.",
"Домены: app, common, settings, profile, build, seasonal, version, java,",
"loader, auth, launch, game, switch, storage, zlib.",
+20 -3
View File
@@ -3,21 +3,38 @@
#include <QDir>
#include <QFileInfo>
#include <QtGlobal>
namespace {
const QString kFolderName = QStringLiteral("BiLauncher");
// Домашняя папка, от которой строятся все пути (см. LauncherPaths::homeOverride()).
QString homeBase()
{
const QString overridden = LauncherPaths::homeOverride();
return overridden.isEmpty() ? QDir::homePath() : overridden;
}
} // namespace
QString LauncherPaths::homeOverride()
{
// Относительный путь зависел бы от текущей папки процесса — такой не принимаем.
const QString value = QDir::fromNativeSeparators(qEnvironmentVariable("BILAUNCHER_HOME"));
if (value.isEmpty() || !QDir::isAbsolutePath(value))
return QString();
return QDir::cleanPath(value);
}
QString LauncherPaths::containerDir()
{
#if defined(Q_OS_WIN)
return QDir::homePath() + QStringLiteral("/AppData/Roaming");
return homeBase() + QStringLiteral("/AppData/Roaming");
#elif defined(Q_OS_MACOS)
return QDir::homePath() + QStringLiteral("/Library/Application Support");
return homeBase() + QStringLiteral("/Library/Application Support");
#else // Linux и прочие Unix-системы
return QDir::homePath();
return homeBase();
#endif
}
+8
View File
@@ -4,6 +4,14 @@
namespace LauncherPaths {
// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная
// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей
// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит.
// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает
// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное
// значение не учитывается. Возвращает подменённую папку или пустую строку.
QString homeOverride();
QString containerDir();
QString rootDir();
QString defaultMinecraftDir();
+7
View File
@@ -1,5 +1,8 @@
#include "launcherpaths.h"
#include "localization.h"
#include <QDebug>
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QQmlApplicationEngine>
@@ -33,6 +36,10 @@ int main(int argc, char *argv[])
QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher"));
// Случайно оставленная BILAUNCHER_HOME увела бы данные в чужую папку — пусть это будет видно в логе.
if (!LauncherPaths::homeOverride().isEmpty())
qWarning().noquote() << "BILAUNCHER_HOME: данные лаунчера в" << QDir::toNativeSeparators(LauncherPaths::rootDir());
if (!Localization::instance().load())
return -1;
+128
View File
@@ -0,0 +1,128 @@
# Автотесты BiLauncher. Включаются опцией BILAUNCHER_BUILD_TESTS (корневой CMakeLists.txt).
#
# Код лаунчера не меняется ради тестов. Те же исходники собираются ещё раз в
# статическую библиотеку-QML-модуль bilauncher_testlib (URI BiLauncher): её
# линкуют все тесты — и C++-тесты сервисов, и Qt Quick Test для QML.
# Подробности — doc/testing.md.
find_package(Qt6 REQUIRED COMPONENTS Test QuickTest Network Qml Quick QuickControls2)
set(CMAKE_AUTOMOC ON)
# --- Исходники лаунчера -----------------------------------------------------------
# Все .cpp/.h из корня, кроме main.cpp: новый файл попадёт в тесты сам.
file(GLOB LAUNCHER_TEST_CPP CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.cpp" "${PROJECT_SOURCE_DIR}/*.h")
list(FILTER LAUNCHER_TEST_CPP EXCLUDE REGEX "/main\\.cpp$")
# QML-файлы модуля. MicrosoftLoginDialog.qml нужен только со сборкой WebEngine;
# тестовая библиотека собирается без него (как сборка без Qt WebEngine), так что
# backend.microsoftAvailable в тестах всегда false.
file(GLOB LAUNCHER_TEST_QML RELATIVE "${PROJECT_SOURCE_DIR}" CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/*.qml")
list(REMOVE_ITEM LAUNCHER_TEST_QML MicrosoftLoginDialog.qml)
# Ресурсы модуля берём прямо из корневого CMakeLists.txt (строки «RESOURCES …»),
# чтобы список не пришлось вести в двух местах.
file(READ "${PROJECT_SOURCE_DIR}/CMakeLists.txt" _root_cmake)
string(REGEX MATCHALL "\n[ \t]*RESOURCES[^\n]*" _resource_lines "${_root_cmake}")
set(LAUNCHER_TEST_RESOURCES "")
foreach(_line IN LISTS _resource_lines)
string(REGEX REPLACE "^[ \t\n]*RESOURCES[ \t]+" "" _line "${_line}")
string(REGEX REPLACE "[ \t]+" ";" _files "${_line}")
list(APPEND LAUNCHER_TEST_RESOURCES ${_files})
endforeach()
list(REMOVE_ITEM LAUNCHER_TEST_RESOURCES "")
# Файлы QML-модуля лежат в корне проекта, а не в tests/: для qt_add_qml_module
# им нужны пути относительно текущего каталога, поэтому копируем их в дерево сборки.
set(_module_dir "${CMAKE_CURRENT_BINARY_DIR}/module_src")
set(_module_qml "")
foreach(_file IN LISTS LAUNCHER_TEST_QML LAUNCHER_TEST_RESOURCES)
configure_file("${PROJECT_SOURCE_DIR}/${_file}" "${_module_dir}/${_file}" COPYONLY)
endforeach()
foreach(_file IN LISTS LAUNCHER_TEST_QML)
list(APPEND _module_qml "${_module_dir}/${_file}")
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
endforeach()
set(_module_res "")
foreach(_file IN LISTS LAUNCHER_TEST_RESOURCES)
list(APPEND _module_res "${_module_dir}/${_file}")
set_source_files_properties("${_module_dir}/${_file}" PROPERTIES QT_RESOURCE_ALIAS "${_file}")
endforeach()
qt_add_library(bilauncher_testlib STATIC)
qt_add_qml_module(bilauncher_testlib
URI BiLauncher
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
SOURCES ${LAUNCHER_TEST_CPP}
QML_FILES ${_module_qml}
RESOURCES ${_module_res}
NO_LINT
)
target_include_directories(bilauncher_testlib PUBLIC "${PROJECT_SOURCE_DIR}")
target_compile_definitions(bilauncher_testlib PUBLIC
LAUNCHER_VERSION="${PROJECT_VERSION}"
)
target_link_libraries(bilauncher_testlib PUBLIC
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
)
# --- Подставная java --------------------------------------------------------------
add_executable(fake_java fake_java/main.cpp)
set_target_properties(fake_java PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON
AUTOMOC OFF MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
# --- Общая обвязка тестов --------------------------------------------------------
add_library(bilauncher_testsupport STATIC
common/testenv.h common/testenv.cpp
common/fixtures.h common/fixtures.cpp
common/fakeinternet.h common/fakeinternet.cpp
common/fakemojang.h common/fakemojang.cpp
common/nettest.h
)
# Designated initializers (VersionSpec{.id = ...}) — C++20.
target_compile_features(bilauncher_testsupport PUBLIC cxx_std_20)
target_include_directories(bilauncher_testsupport PUBLIC common)
target_compile_definitions(bilauncher_testsupport PUBLIC
BILAUNCHER_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
BILAUNCHER_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
BILAUNCHER_FAKE_JAVA="$<TARGET_FILE:fake_java>"
)
target_link_libraries(bilauncher_testsupport PUBLIC bilauncher_testlib Qt6::Test Qt6::Network Qt6::CorePrivate)
add_dependencies(bilauncher_testsupport fake_java)
# Окружение всех тестов: без окон, без GPU, без кеша QML на диске.
set(BILAUNCHER_TEST_ENV
"QT_QPA_PLATFORM=offscreen"
"QT_QUICK_BACKEND=software"
"QML_DISABLE_DISK_CACHE=1"
"QT_LOGGING_RULES=qt.network.ssl.warning=false"
)
# bilauncher_add_test(<имя> LABEL <метка> SOURCES ... [TIMEOUT сек] [GUI])
function(bilauncher_add_test name)
cmake_parse_arguments(ARG "GUI" "LABEL;TIMEOUT" "SOURCES" ${ARGN})
add_executable(${name} ${ARG_SOURCES})
set_target_properties(${name} PROPERTIES MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
target_link_libraries(${name} PRIVATE bilauncher_testsupport)
if(ARG_GUI)
target_link_libraries(${name} PRIVATE bilauncher_testlibplugin Qt6::QuickTest)
endif()
add_test(NAME ${name} COMMAND ${name})
if(NOT ARG_TIMEOUT)
set(ARG_TIMEOUT 120)
endif()
set_tests_properties(${name} PROPERTIES
LABELS "${ARG_LABEL}"
TIMEOUT ${ARG_TIMEOUT}
ENVIRONMENT "${BILAUNCHER_TEST_ENV}")
if(WIN32)
# DLL Qt — из установки Qt, без windeployqt для каждого теста.
set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION
"PATH=path_list_prepend:$<TARGET_FILE_DIR:Qt6::Core>")
endif()
endfunction()
add_subdirectory(unit)
add_subdirectory(net)
add_subdirectory(backend)
add_subdirectory(qml)
+2
View File
@@ -0,0 +1,2 @@
# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним.
bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp)
+708
View File
@@ -0,0 +1,708 @@
// Интеграционные тесты 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"
+410
View File
@@ -0,0 +1,410 @@
#include "fakeinternet.h"
#include "fixtures.h"
#include <QEventLoop>
#include <QHostAddress>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkProxy>
#include <QSslConfiguration>
#include <QSslKey>
#include <QSslSocket>
#include <QTimer>
struct FakeInternet::Connection
{
QSslSocket *socket = nullptr;
QByteArray buffer;
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
};
namespace {
QByteArray reasonPhrase(int status)
{
switch (status) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 301: return "Moved Permanently";
case 302: return "Found";
case 304: return "Not Modified";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 410: return "Gone";
case 429: return "Too Many Requests";
case 500: return "Internal Server Error";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
default: return "Status";
}
}
} // namespace
QUrl FakeRequest::url() const
{
QUrl result;
result.setScheme(scheme);
result.setHost(host);
result.setPath(path);
if (!query.isEmpty())
result.setQuery(query);
return result;
}
FakeResponse FakeResponse::json(const QByteArray &body, int status)
{
FakeResponse response;
response.status = status;
response.body = body;
response.contentType = "application/json";
return response;
}
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
{
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
}
FakeResponse FakeResponse::text(const QByteArray &body, int status)
{
FakeResponse response;
response.status = status;
response.body = body;
response.contentType = "text/plain; charset=utf-8";
return response;
}
FakeResponse FakeResponse::bytes(const QByteArray &body)
{
FakeResponse response;
response.body = body;
return response;
}
FakeResponse FakeResponse::error(int status, const QByteArray &body)
{
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
return response;
}
FakeInternet::FakeInternet(QObject *parent)
: QTcpServer(parent)
{
m_fallback = FakeResponse::error(404, "fake internet: no route");
}
FakeInternet::~FakeInternet()
{
stop();
}
bool FakeInternet::tlsAvailable(QString *why)
{
if (!QSslSocket::supportsSsl()) {
if (why)
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
return false;
}
return true;
}
bool FakeInternet::start(QString *error)
{
if (m_started)
return true;
if (!tlsAvailable(error))
return false;
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
if (!listen(QHostAddress::LocalHost, 0)) {
if (error)
*error = errorString();
return false;
}
QNetworkProxy::setApplicationProxy(
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
QSslConfiguration::setDefaultConfiguration(ssl);
m_started = true;
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
// чем получить десятки непонятных падений в тестах сервисов.
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
route(probe, FakeResponse::text("pong"));
QNetworkAccessManager manager;
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
QEventLoop loop;
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
if (!reply->isFinished())
loop.exec();
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
&& reply->readAll() == "pong";
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
delete reply;
reset();
if (!ok) {
stop();
if (error)
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
.arg(why, QSslSocket::activeBackend());
return false;
}
return true;
}
void FakeInternet::stop()
{
if (!m_started)
return;
// Никуда наружу и после остановки: прокси на закрытый порт.
close();
m_started = false;
}
void FakeInternet::route(const QString &pattern, const Handler &handler)
{
unroute(pattern);
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
Route route;
route.pattern = pattern;
route.host = url.host().toLower();
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
route.prefix = pattern.endsWith(u'*');
route.handler = handler;
m_routes << route;
}
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
{
route(pattern, [response](const FakeRequest &) { return response; });
}
void FakeInternet::unroute(const QString &pattern)
{
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
}
void FakeInternet::reset()
{
m_routes.clear();
m_requests.clear();
m_unmatched.clear();
m_fallback = FakeResponse::error(404, "fake internet: no route");
}
QList<FakeRequest> FakeInternet::requests() const
{
return m_requests;
}
int FakeInternet::hits(const QString &url) const
{
const QUrl wanted(url);
int count = 0;
for (const FakeRequest &request : m_requests) {
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
&& request.path == wanted.path())
++count;
}
return count;
}
QStringList FakeInternet::unmatched() const
{
return m_unmatched;
}
QStringList FakeInternet::requestedUrls() const
{
QStringList result;
for (const FakeRequest &request : m_requests)
result << request.url().toString();
return result;
}
void FakeInternet::incomingConnection(qintptr descriptor)
{
auto *socket = new QSslSocket(this);
if (!socket->setSocketDescriptor(descriptor)) {
delete socket;
return;
}
auto *connection = new Connection;
connection->socket = socket;
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
socket->ignoreSslErrors();
});
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
socket->deleteLater();
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
});
}
void FakeInternet::onReadyRead(Connection *connection)
{
connection->buffer += connection->socket->readAll();
processBuffer(connection);
}
void FakeInternet::processBuffer(Connection *connection)
{
for (;;) {
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
if (headerEnd < 0)
return;
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
if (requestLine.size() < 2) {
connection->socket->abort();
return;
}
FakeRequest request;
request.method = requestLine.at(0);
for (qsizetype i = 1; i < lines.size(); ++i) {
const QByteArray line = lines.at(i).trimmed();
const qsizetype colon = line.indexOf(':');
if (colon > 0)
request.headers.insert(line.left(colon).trimmed().toLower(),
line.mid(colon + 1).trimmed());
}
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
if (connection->buffer.size() < headerEnd + 4 + bodySize)
return; // тело ещё не дошло
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
connection->buffer.remove(0, headerEnd + 4 + bodySize);
const QByteArray target = requestLine.at(1);
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
QString host = QString::fromLatin1(target);
host = host.left(host.lastIndexOf(u':')).toLower();
connection->tunnelHost = host;
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
connection->socket->flush();
startTls(connection);
continue;
}
if (!connection->tunnelHost.isEmpty()) {
request.scheme = QStringLiteral("https");
request.host = connection->tunnelHost;
const QUrl relative(QString::fromLatin1(target));
request.path = relative.path(QUrl::FullyDecoded);
request.query = relative.query(QUrl::FullyEncoded);
} else {
// Простой http через прокси — адрес в absolute-form.
const QUrl absolute(QString::fromLatin1(target));
request.scheme = absolute.scheme();
request.host = absolute.host().toLower();
request.path = absolute.path(QUrl::FullyDecoded);
request.query = absolute.query(QUrl::FullyEncoded);
}
if (request.path.isEmpty())
request.path = QStringLiteral("/");
dispatch(connection, request);
}
}
void FakeInternet::startTls(Connection *connection)
{
QSslSocket *socket = connection->socket;
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
socket->startServerEncryption();
}
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
{
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
const Route &route = m_routes.at(i);
if (route.host != request.host)
continue;
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
return &route;
}
return nullptr;
}
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
{
m_requests << request;
const QString url = request.url().toString();
FakeResponse response;
if (const Route *route = match(request)) {
response = route->handler(request);
} else {
m_unmatched << url;
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
qPrintable(url));
response = m_fallback;
}
const bool keepAlive = request.header("connection").toLower() != "close";
QPointer<QTcpSocket> socket(connection->socket);
if (response.hang)
return;
if (response.delayMs > 0) {
QTimer::singleShot(response.delayMs, this,
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
} else {
respond(socket, response, keepAlive);
}
emit requestServed(url);
}
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
{
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
return;
if (response.drop) {
socket->abort();
return;
}
QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' '
+ reasonPhrase(response.status) + "\r\n";
head += "Content-Type: " + response.contentType + "\r\n";
head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n";
for (const auto &[name, value] : response.headers)
head += name + ": " + value + "\r\n";
const bool close = !keepAlive || response.truncateTo >= 0;
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
head += "\r\n";
socket->write(head);
if (response.truncateTo >= 0) {
socket->write(response.body.left(response.truncateTo));
socket->flush();
socket->disconnectFromHost();
return;
}
socket->write(response.body);
if (close)
socket->disconnectFromHost();
}
+136
View File
@@ -0,0 +1,136 @@
#pragma once
// «Фейковый интернет» для тестов сетевых сервисов без правки их кода.
//
// Сервисы лаунчера ходят на захардкоженные https-адреса через собственные
// QNetworkAccessManager. Прокси и SSL-конфигурация по умолчанию у Qt общие
// на весь процесс, поэтому start():
// - ставит QNetworkProxy::setApplicationProxy() на локальный порт;
// - отключает проверку сертификата в QSslConfiguration::defaultConfiguration().
// Дальше каждое https-соединение приходит сюда как CONNECT host:443; сокет
// переводится в TLS с тестовым самоподписанным сертификатом и обслуживается
// как обычный HTTP/1.1 — по таблице маршрутов (host + path).
//
// Запрос на неизвестный адрес получает 404 и попадает в unmatched(): тест
// может проверить, что код не ушёл куда-то, куда не ожидали.
#include <QByteArray>
#include <QHash>
#include <QList>
#include <QMutex>
#include <QObject>
#include <QPair>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QTcpServer>
#include <QUrl>
#include <QUrlQuery>
#include <functional>
class QSslSocket;
class QTcpSocket;
struct FakeRequest
{
QByteArray method;
QString scheme; // https | http
QString host;
QString path;
QString query; // без «?»
QHash<QByteArray, QByteArray> headers; // имена в нижнем регистре
QByteArray body;
QUrl url() const;
QUrlQuery queryItems() const { return QUrlQuery(query); }
QUrlQuery formItems() const { return QUrlQuery(QString::fromUtf8(body)); }
QByteArray header(const QByteArray &name) const { return headers.value(name.toLower()); }
};
struct FakeResponse
{
int status = 200;
QByteArray body;
QByteArray contentType = "application/octet-stream";
QList<QPair<QByteArray, QByteArray>> headers;
int delayMs = 0; // ответить с задержкой
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
bool drop = false; // оборвать соединение без ответа
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
static FakeResponse json(const QByteArray &body, int status = 200);
static FakeResponse json(const class QJsonObject &object, int status = 200);
static FakeResponse text(const QByteArray &body, int status = 200);
static FakeResponse bytes(const QByteArray &body);
static FakeResponse error(int status, const QByteArray &body = {});
};
class FakeInternet : public QTcpServer
{
Q_OBJECT
public:
using Handler = std::function<FakeResponse(const FakeRequest &)>;
explicit FakeInternet(QObject *parent = nullptr);
~FakeInternet() override;
// Слушает 127.0.0.1 и перенаправляет на себя весь трафик процесса.
// При ошибке пишет причину в error (например, нет TLS-бэкенда с поддержкой сервера).
bool start(QString *error = nullptr);
void stop();
// Есть ли TLS вообще (без него сетевые тесты пропускаются).
static bool tlsAvailable(QString *why = nullptr);
// Маршрут: "https://host/path" — точное совпадение пути (query не учитывается),
// "https://host/prefix/*" — всё, что начинается с prefix/.
void route(const QString &pattern, const Handler &handler);
void route(const QString &pattern, const FakeResponse &response);
void unroute(const QString &pattern);
// Ответ на всё, что не подошло ни к одному маршруту (по умолчанию 404).
void setFallback(const FakeResponse &response) { m_fallback = response; }
// Сбросить маршруты и журнал.
void reset();
QList<FakeRequest> requests() const;
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
int hits(const QString &url) const;
QStringList unmatched() const;
QStringList requestedUrls() const;
signals:
void requestServed(const QString &url);
protected:
void incomingConnection(qintptr descriptor) override;
private:
struct Connection;
struct Route
{
QString pattern;
QString host;
QString path;
bool prefix = false;
Handler handler;
};
void onReadyRead(Connection *connection);
void processBuffer(Connection *connection);
void dispatch(Connection *connection, FakeRequest request);
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
void startTls(Connection *connection);
const Route *match(const FakeRequest &request) const;
QList<Route> m_routes;
FakeResponse m_fallback;
QList<FakeRequest> m_requests;
QStringList m_unmatched;
QByteArray m_certificate;
QByteArray m_key;
bool m_started = false;
};
+194
View File
@@ -0,0 +1,194 @@
#include "fakemojang.h"
#include "fixtures.h"
#include "minecraftversion.h"
#include <QJsonArray>
#include <QJsonDocument>
namespace {
const QString kMeta = QStringLiteral("https://piston-meta.mojang.com/v1/packages/");
const QString kData = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
const QString kLibraries = QStringLiteral("https://libraries.minecraft.net/");
const QString kResources = QStringLiteral("https://resources.download.minecraft.net/");
QJsonObject download(const QString &url, const QByteArray &data, const QString &path = {})
{
QJsonObject object{{"url", url}, {"sha1", Fixtures::sha1(data)}, {"size", data.size()}};
if (!path.isEmpty())
object.insert("path", path);
return object;
}
} // namespace
FakeMojang::FakeMojang(FakeInternet &net)
: m_net(net)
{
}
QString FakeMojang::manifestUrl()
{
return QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
}
QString FakeMojang::addObject(const QString &relativePath, const QByteArray &data)
{
QString url;
if (relativePath.startsWith(QLatin1String("libraries/")))
url = kLibraries + relativePath.mid(int(sizeof("libraries/") - 1));
else if (relativePath.startsWith(QLatin1String("assets/objects/")))
url = kResources + relativePath.mid(int(sizeof("assets/objects/") - 1));
else
url = kData + Fixtures::sha1(data) + u'/' + relativePath.section(u'/', -1);
m_files.insert(relativePath, {url, data});
m_net.route(url, FakeResponse::bytes(data));
return url;
}
void FakeMojang::overrideFile(const QString &relativePath, const FakeResponse &response)
{
m_net.route(m_files.value(relativePath).url, response);
}
QString FakeMojang::fileUrl(const QString &relativePath) const
{
return m_files.value(relativePath).url;
}
void FakeMojang::publish(const VersionSpec &spec)
{
QHash<QString, QByteArray> expected;
QJsonObject json{{"id", spec.id}, {"type", spec.type}, {"releaseTime", spec.releaseTime}};
if (!spec.inheritsFrom.isEmpty()) {
json.insert("inheritsFrom", spec.inheritsFrom);
json.insert("mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient");
const QString libPath = QStringLiteral("net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar");
const QByteArray libData = "fabric-loader-" + spec.id.toUtf8();
const QString url = addObject("libraries/" + libPath, libData);
json.insert("libraries", QJsonArray{QJsonObject{
{"name", "net.fabricmc:fabric-loader:0.16.10"},
{"downloads", QJsonObject{{"artifact", download(url, libData, libPath)}}}}});
expected.insert("libraries/" + libPath, libData);
} else {
json.insert("mainClass", "net.minecraft.client.main.Main");
json.insert("arguments", QJsonObject{
{"game", QJsonArray{"--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
"--versionType", "${version_type}"}},
{"jvm", QJsonArray{"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"}}});
json.insert("javaVersion", QJsonObject{{"component", "java-runtime-gamma"}, {"majorVersion", spec.javaMajor}});
// client.jar
const QString clientPath = "versions/" + spec.id + u'/' + spec.id + ".jar";
const QByteArray clientData = "client-jar-of-" + spec.id.toUtf8();
const QString clientUrl = addObject(clientPath, clientData);
json.insert("downloads", QJsonObject{{"client", download(clientUrl, clientData)}});
expected.insert(clientPath, clientData);
// Обычная библиотека и натив под текущую платформу.
const QString native = VersionLoader::nativeClassifier();
const QString libPath = QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QByteArray libData = "brigadier";
const QString nativePath = QStringLiteral("org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%1.jar").arg(native);
const QByteArray nativeData = Fixtures::zipBytes({{"liblwjgl.so", "so"}, {"lwjgl.dll", "dll"},
{"liblwjgl.dylib", "dylib"}});
const QString libUrl = addObject("libraries/" + libPath, libData);
const QString nativeUrl = addObject("libraries/" + nativePath, nativeData);
json.insert("libraries", QJsonArray{
QJsonObject{{"name", "com.mojang:brigadier:1.1.8"},
{"downloads", QJsonObject{{"artifact", download(libUrl, libData, libPath)}}}},
QJsonObject{{"name", "org.lwjgl:lwjgl:3.3.1:" + native},
{"downloads", QJsonObject{{"artifact", download(nativeUrl, nativeData, nativePath)}}},
{"rules", QJsonArray{QJsonObject{{"action", "allow"},
{"os", QJsonObject{{"name", VersionLoader::osName()}}}}}}}});
expected.insert("libraries/" + libPath, libData);
expected.insert("libraries/" + nativePath, nativeData);
// Ассеты.
const QString indexId = spec.legacyAssets ? QStringLiteral("legacy") : spec.id + "-assets";
QJsonObject objects;
qint64 total = 0;
for (const QString &name : {QStringLiteral("minecraft/sounds/click.ogg"), QStringLiteral("icons/icon_16x16.png")}) {
const QByteArray data = (name + "@" + spec.id).toUtf8();
const QString hash = Fixtures::sha1(data);
const QString objectPath = "assets/objects/" + hash.left(2) + u'/' + hash;
addObject(objectPath, data);
objects.insert(name, QJsonObject{{"hash", hash}, {"size", data.size()}});
expected.insert(objectPath, data);
if (spec.legacyAssets)
expected.insert("assets/virtual/legacy/" + name, data);
total += data.size();
}
QJsonObject index{{"objects", objects}};
if (spec.legacyAssets)
index.insert("virtual", true);
const QByteArray indexData = QJsonDocument(index).toJson(QJsonDocument::Compact);
const QString indexPath = "assets/indexes/" + indexId + ".json";
const QString indexUrl = addObject(indexPath, indexData);
QJsonObject assetIndex = download(indexUrl, indexData);
assetIndex.insert("id", indexId);
assetIndex.insert("totalSize", total);
json.insert("assetIndex", assetIndex);
json.insert("assets", indexId);
expected.insert(indexPath, indexData);
// Конфиг логирования.
const QByteArray logData = "<Configuration/>";
const QString logPath = QStringLiteral("assets/log_configs/client-1.12.xml");
const QString logUrl = addObject(logPath, logData);
QJsonObject logFile = download(logUrl, logData);
logFile.insert("id", "client-1.12.xml");
json.insert("logging", QJsonObject{{"client", QJsonObject{
{"argument", "-Dlog4j.configurationFile=${path}"},
{"file", logFile}, {"type", "log4j2-xml"}}}});
expected.insert(logPath, logData);
}
const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact);
m_versionJson.insert(spec.id, jsonData);
expected.insert("versions/" + spec.id + u'/' + spec.id + ".json", jsonData);
m_expected.insert(spec.id, expected);
m_net.route(versionJsonUrl(spec.id), FakeResponse::json(jsonData));
m_specs.removeIf([&](const VersionSpec &s) { return s.id == spec.id; });
m_specs << spec;
routeManifest();
}
QString FakeMojang::versionJsonUrl(const QString &id) const
{
return kMeta + Fixtures::sha1(m_versionJson.value(id)) + u'/' + id + ".json";
}
QByteArray FakeMojang::versionJson(const QString &id) const
{
return m_versionJson.value(id);
}
void FakeMojang::routeManifest()
{
QJsonArray versions;
for (const VersionSpec &spec : std::as_const(m_specs)) {
if (!spec.listed)
continue;
versions << QJsonObject{{"id", spec.id},
{"type", spec.type},
{"url", versionJsonUrl(spec.id)},
{"sha1", Fixtures::sha1(m_versionJson.value(spec.id))},
{"releaseTime", spec.releaseTime},
{"time", spec.releaseTime}};
}
m_net.route(manifestUrl(), FakeResponse::json(QJsonObject{{"versions", versions}}));
}
QHash<QString, QByteArray> FakeMojang::expectedFiles(const QString &id) const
{
return m_expected.value(id);
}
+64
View File
@@ -0,0 +1,64 @@
#pragma once
// Миниатюрный Mojang для FakeInternet: манифест версий, json версий, client.jar,
// библиотеки (в т.ч. натив под текущую ОС), индекс ассетов, сами ассеты и
// конфиг логирования. Содержимое крошечное, но устроено как настоящее —
// VersionInstaller и GameLauncher работают с ним без поблажек.
#include "fakeinternet.h"
#include <QByteArray>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <QStringList>
class FakeMojang
{
public:
struct VersionSpec
{
QString id;
QString type = QStringLiteral("release");
QString inheritsFrom; // непусто — «дочерняя» версия без своих файлов
bool legacyAssets = false; // индекс с virtual: true (как у 1.6 и старше)
int javaMajor = 17;
QString releaseTime = QStringLiteral("2023-06-12T13:25:51+00:00");
bool listed = true; // попадает ли в манифест
};
explicit FakeMojang(FakeInternet &net);
// Публикует версию: json + все файлы, на которые он ссылается.
void publish(const VersionSpec &spec);
// Переопубликовать манифест (после publish всех версий вызывается автоматически).
void routeManifest();
// Подменить ответ на конкретный файл (по относительному пути внутри .minecraft,
// например "libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar").
void overrideFile(const QString &relativePath, const FakeResponse &response);
// Что должно лечь на диск после установки: относительный путь → содержимое.
QHash<QString, QByteArray> expectedFiles(const QString &id) const;
// URL json версии и URL конкретного файла.
QString versionJsonUrl(const QString &id) const;
QString fileUrl(const QString &relativePath) const;
QByteArray versionJson(const QString &id) const;
static QString manifestUrl();
private:
struct File
{
QString url;
QByteArray data;
};
QString addObject(const QString &relativePath, const QByteArray &data);
FakeInternet &m_net;
QList<VersionSpec> m_specs;
QHash<QString, QByteArray> m_versionJson; // id → json
QHash<QString, QHash<QString, QByteArray>> m_expected; // id → (путь → данные)
QHash<QString, File> m_files; // относительный путь → файл
};
+204
View File
@@ -0,0 +1,204 @@
#include "fixtures.h"
#include "minecraftversion.h"
#include <QBuffer>
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QProcess>
#include <QTemporaryDir>
#include <QtCore/private/qzipreader_p.h>
#include <QtCore/private/qzipwriter_p.h>
QString Fixtures::path(const QString &relative)
{
return QStringLiteral(BILAUNCHER_FIXTURES_DIR) + u'/' + relative;
}
QByteArray Fixtures::read(const QString &relative)
{
QFile file(path(relative));
if (!file.open(QIODevice::ReadOnly))
qFatal("Нет фикстуры %s", qPrintable(file.fileName()));
return file.readAll();
}
QJsonObject Fixtures::readJson(const QString &relative)
{
return QJsonDocument::fromJson(read(relative)).object();
}
QString Fixtures::otherOsName()
{
return VersionLoader::osName() == QLatin1String("windows") ? QStringLiteral("linux")
: QStringLiteral("windows");
}
QByteArray Fixtures::applyTemplate(QByteArray text)
{
text.replace("%OS%", VersionLoader::osName().toUtf8());
text.replace("%OTHER%", otherOsName().toUtf8());
text.replace("%ARCH%", VersionLoader::osArch().toUtf8());
text.replace("%NATIVE%", VersionLoader::nativeClassifier().toUtf8());
return text;
}
QByteArray Fixtures::readTemplate(const QString &relative)
{
return applyTemplate(read(relative));
}
bool Fixtures::write(const QString &filePath, const QByteArray &data)
{
QDir().mkpath(QFileInfo(filePath).absolutePath());
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
return file.write(data) == data.size();
}
bool Fixtures::writeJson(const QString &filePath, const QJsonObject &object)
{
return write(filePath, QJsonDocument(object).toJson(QJsonDocument::Indented));
}
QByteArray Fixtures::readFile(const QString &filePath)
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly))
return {};
return file.readAll();
}
QJsonObject Fixtures::readJsonFile(const QString &filePath)
{
return QJsonDocument::fromJson(readFile(filePath)).object();
}
QByteArray Fixtures::zipBytes(const QMap<QString, QByteArray> &entries)
{
QBuffer buffer;
buffer.open(QIODevice::WriteOnly);
{
QZipWriter writer(&buffer);
writer.setCompressionPolicy(QZipWriter::AutoCompress);
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
if (it.key().endsWith(u'/'))
writer.addDirectory(it.key());
else
writer.addFile(it.key(), it.value());
}
writer.close();
}
return buffer.data();
}
bool Fixtures::makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries)
{
return write(zipPath, zipBytes(entries));
}
QStringList Fixtures::zipEntries(const QString &zipPath)
{
QZipReader reader(zipPath);
QStringList result;
for (const QZipReader::FileInfo &info : reader.fileInfoList()) {
if (info.isFile)
result << info.filePath;
}
result.sort();
return result;
}
bool Fixtures::makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries)
{
QTemporaryDir staging;
if (!staging.isValid())
return false;
QStringList tops;
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
if (!write(staging.path() + u'/' + it.key(), it.value()))
return false;
const QString top = it.key().section(u'/', 0, 0);
if (!tops.contains(top))
tops << top;
}
QDir().mkpath(QFileInfo(archivePath).absolutePath());
QProcess tar;
tar.setWorkingDirectory(staging.path());
#if defined(Q_OS_WIN)
// Системный bsdtar по полному пути: GNU tar из Git, который бывает раньше в PATH,
// принимает «C:» за имя удалённого хоста (та же история в release.yml).
const QString program = qEnvironmentVariable("SystemRoot") + QStringLiteral("\\System32\\tar.exe");
#else
const QString program = QStringLiteral("tar");
#endif
tar.start(program,
QStringList{QStringLiteral("-czf"), QDir::toNativeSeparators(archivePath)} + tops);
return tar.waitForFinished(30000) && tar.exitStatus() == QProcess::NormalExit
&& tar.exitCode() == 0;
}
QString Fixtures::sha1(const QByteArray &data)
{
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex());
}
QString Fixtures::sha256(const QByteArray &data)
{
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex());
}
QStringList Fixtures::listTree(const QString &dir)
{
QStringList result;
QDirIterator it(dir, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories);
const QDir base(dir);
while (it.hasNext())
result << base.relativeFilePath(it.next());
result.sort();
return result;
}
QString Fixtures::javaExecutableName()
{
#if defined(Q_OS_WIN)
return QStringLiteral("java.exe");
#else
return QStringLiteral("java");
#endif
}
void Fixtures::configureFakeJava(const QString &javaPath, const QString &version,
const QStringList &config)
{
QStringList lines{QStringLiteral("version=") + version};
lines += config;
write(QFileInfo(javaPath).absolutePath() + QStringLiteral("/fake-java.cfg"),
lines.join(u'\n').toUtf8() + '\n');
}
QString Fixtures::installFakeJava(const QString &dir, const QString &version,
const QStringList &config)
{
const QString binDir = dir + QStringLiteral("/bin");
QDir().mkpath(binDir);
const QString java = binDir + u'/' + javaExecutableName();
QFile::remove(java);
if (!QFile::copy(QStringLiteral(BILAUNCHER_FAKE_JAVA), java))
qFatal("Не удалось скопировать fake_java в %s", qPrintable(java));
QFile::setPermissions(java, QFile::permissions(java) | QFileDevice::ExeOwner
| QFileDevice::ExeGroup | QFileDevice::ExeOther);
#if defined(Q_OS_WIN)
const QString javaw = binDir + QStringLiteral("/javaw.exe");
QFile::remove(javaw);
QFile::copy(java, javaw);
#endif
configureFakeJava(java, version, config);
return java;
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
// Фикстуры и мелкие помощники для подготовки файлов в песочнице.
#include <QByteArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QString>
#include <QStringList>
namespace Fixtures {
// Абсолютный путь к tests/fixtures/<relative>.
QString path(const QString &relative);
// Содержимое фикстуры; при отсутствии файла тест падает через qFatal.
QByteArray read(const QString &relative);
QJsonObject readJson(const QString &relative);
// Фикстура-шаблон: %OS%, %OTHER%, %ARCH%, %NATIVE% заменяются на значения
// текущей платформы (имя ОС в терминах Mojang, «чужая» ОС, архитектура,
// классификатор нативов LWJGL) — один json годится для всех трёх ОС.
QByteArray readTemplate(const QString &relative);
QByteArray applyTemplate(QByteArray text);
QString otherOsName();
// Пишет файл, создавая недостающие папки.
bool write(const QString &path, const QByteArray &data);
bool writeJson(const QString &path, const QJsonObject &object);
QByteArray readFile(const QString &path);
QJsonObject readJsonFile(const QString &path);
// Zip из пар «путь внутри архива → содержимое» (через QZipWriter, как в коде лаунчера).
bool makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries);
QByteArray zipBytes(const QMap<QString, QByteArray> &entries);
// Список файлов в zip — как его видит QZipReader (ведущие «.» и «/» он срезает).
QStringList zipEntries(const QString &zipPath);
// tar.gz через системный tar (есть и на Windows 10+, и на Unix).
bool makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries);
QString sha1(const QByteArray &data);
QString sha256(const QByteArray &data);
// Все файлы под папкой (относительные пути, «/» в качестве разделителя), отсортированы.
QStringList listTree(const QString &dir);
// Подставная java (см. tests/fake_java). Копирует бинарник в <dir>/bin/java[.exe]
// (на Windows ещё и javaw.exe) и пишет рядом fake-java.cfg из строк config.
// Возвращает путь к java.
QString installFakeJava(const QString &dir, const QString &version,
const QStringList &config = {});
// Переписать fake-java.cfg у уже установленной подставной java.
void configureFakeJava(const QString &javaPath, const QString &version,
const QStringList &config);
QString javaExecutableName();
} // namespace Fixtures
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// Помощники для тестов сетевых сервисов.
#include "fakeinternet.h"
#include <QString>
#include <QtTest/QTest>
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность —
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
#define BILAUNCHER_START_FAKE_INTERNET(net) \
do { \
QString startError_; \
if (!(net).start(&startError_)) { \
const QString message_ = QStringLiteral("FakeInternet недоступен: ") + startError_; \
if (qEnvironmentVariableIntValue("BILAUNCHER_REQUIRE_FAKE_INTERNET")) \
QFAIL(qPrintable(message_)); \
QSKIP(qPrintable(message_)); \
} \
} while (false)
// Результат колбэка вида (bool ok, QString warning).
struct CallResult
{
int calls = 0;
bool ok = false;
QString warning;
auto callback()
{
return [this](bool okValue, const QString &warningValue) {
++calls;
ok = okValue;
warning = warningValue;
};
}
};
+115
View File
@@ -0,0 +1,115 @@
#include "testenv.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFile>
#include <QTemporaryDir>
#include <cstdio>
#include <cstdlib>
#if defined(Q_OS_WIN)
#include <qt_windows.h>
#include <stdlib.h> // _wputenv_s
#endif
namespace {
QTemporaryDir *g_sandbox = nullptr;
[[noreturn]] void abortSetup(const QString &message)
{
const QString line = QStringLiteral("TestEnv: ") + message + u'\n';
#if defined(Q_OS_WIN)
// В живую консоль пишем UTF-16 напрямую: кодовая страница консоли тогда не важна.
const HANDLE handle = GetStdHandle(STD_ERROR_HANDLE);
DWORD mode = 0;
if (handle != INVALID_HANDLE_VALUE && GetConsoleMode(handle, &mode)) {
DWORD written = 0;
WriteConsoleW(handle, reinterpret_cast<const wchar_t *>(line.utf16()),
DWORD(line.size()), &written, nullptr);
std::exit(2);
}
#endif
// Вывод в трубу (ctest, CI) — UTF-8, как его и читает ctest. Локальная 8-битная
// кодировка Windows (cp1252/cp1251) превращала кириллицу в «???».
std::fputs(line.toUtf8().constData(), stderr);
std::fflush(stderr);
std::exit(2);
}
} // namespace
void TestEnv::prepareSandbox()
{
g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX"));
if (!g_sandbox->isValid())
abortSetup(QStringLiteral("не удалось создать временную папку"));
const QString home = QDir::toNativeSeparators(g_sandbox->path());
setEnvPath("HOME", home);
setEnvPath("USERPROFILE", home);
// LauncherPaths: на Windows QDir::homePath() HOME/USERPROFILE не читает, а эту
// переменную лаунчер читает везде. Дочерние процессы получают её по наследству.
setEnvPath("BILAUNCHER_HOME", home);
// Чужая Java из окружения машины не должна влиять на выбор Java в тестах.
qunsetenv("JAVA_HOME");
std::atexit([] {
delete g_sandbox;
g_sandbox = nullptr;
});
}
void TestEnv::finishSetup()
{
QCoreApplication::setApplicationName(QStringLiteral("BiLauncher"));
QCoreApplication::setOrganizationName(QStringLiteral("GaleonDev"));
const QString root = QDir::cleanPath(LauncherPaths::rootDir());
const QString sandbox = QDir::cleanPath(g_sandbox->path());
const QString game = QDir::cleanPath(LauncherPaths::defaultMinecraftDir());
// Ни одна папка лаунчера не должна указывать на настоящий профиль: resetLauncherRoot()
// стирает rootDir() целиком.
for (const QString &path : {root, game}) {
if (!path.startsWith(sandbox + u'/'))
abortSetup(QStringLiteral("путь лаунчера %1 вне песочницы %2 — отказываюсь запускаться")
.arg(QDir::toNativeSeparators(path), QDir::toNativeSeparators(sandbox)));
}
if (!Localization::instance().load())
abortSetup(QStringLiteral("не загрузился каталог переводов"));
Localization::instance().setLanguage(QStringLiteral("en"));
}
void TestEnv::setEnvPath(const char *name, const QString &path)
{
// На Windows qputenv() пишет в ANSI-кодировке и испортил бы, например,
// кириллическое имя пользователя во временном пути.
#if defined(Q_OS_WIN)
_wputenv_s(reinterpret_cast<const wchar_t *>(QString::fromLatin1(name).utf16()),
reinterpret_cast<const wchar_t *>(path.utf16()));
#else
qputenv(name, QFile::encodeName(path));
#endif
}
QString TestEnv::homeDir()
{
return g_sandbox->path();
}
QString TestEnv::freshDir(const QString &name)
{
const QString path = g_sandbox->path() + QStringLiteral("/work/") + name;
QDir(path).removeRecursively();
QDir().mkpath(path);
return path;
}
void TestEnv::resetLauncherRoot()
{
QDir(LauncherPaths::rootDir()).removeRecursively();
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// Общая обвязка тестов: песочница для домашней папки и main для QtTest.
//
// LauncherPaths строит все пути от домашней папки. Каждый тестовый процесс до
// создания QCoreApplication заводит свою временную папку и направляет туда
// BILAUNCHER_HOME (её LauncherPaths читает на всех ОС), а заодно HOME и
// USERPROFILE (их читает QDir::homePath() на Linux и macOS). На Windows
// QDir::homePath() переменные окружения игнорирует, поэтому без BILAUNCHER_HOME
// там не обойтись. Реальные папка лаунчера и .minecraft машины, на которой гоняются
// тесты, не трогаются: если путь лаунчера всё же оказался вне песочницы,
// процесс сразу завершается с кодом 2.
#include <QCoreApplication>
#include <QString>
#include <QtTest/QTest>
namespace TestEnv {
// До QCoreApplication: временная папка и подмена BILAUNCHER_HOME/HOME/USERPROFILE.
void prepareSandbox();
// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу,
// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины).
void finishSetup();
// Переменная окружения с путём, без потерь символов вне ANSI-кодировки на Windows.
void setEnvPath(const char *name, const QString &path);
// Корень песочницы — домашняя папка, от которой LauncherPaths строит пути в тестах.
QString homeDir();
// Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.).
QString freshDir(const QString &name);
// Стирает папку лаунчера (LauncherPaths::rootDir()) — между тестами.
void resetLauncherRoot();
} // namespace TestEnv
#define BILAUNCHER_TEST_MAIN_IMPL(AppType, TestObject) \
int main(int argc, char *argv[]) \
{ \
TestEnv::prepareSandbox(); \
AppType app(argc, argv); \
TestEnv::finishSetup(); \
TestObject tc; \
QTEST_SET_MAIN_SOURCE_PATH \
return QTest::qExec(&tc, argc, argv); \
}
#define BILAUNCHER_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QCoreApplication, TestObject)
// Тесту с GUI нужно самому подключить <QGuiApplication>.
#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject)
+137
View File
@@ -0,0 +1,137 @@
// Подставная «java» для тестов. Без Qt, чтобы её копию можно было положить
// в любую папку и запустить без поиска библиотек Qt.
//
// Поведение задаётся файлом fake-java.cfg рядом с исполняемым файлом,
// по строке «ключ=значение»:
// version=21.0.2 что печатать на «-version» (в stderr, как настоящая java)
// exit=0 код выхода обычного запуска
// sleep_ms=0 сколько «работать» перед выходом
// args_file=<путь> куда записать аргументы, по одному на строку
// dump_env=<ИМЯ> дописать в args_file строку «env:ИМЯ=значение» (можно несколько)
// stdout=<строка> что напечатать в stdout (можно несколько)
// create=<путь>\t<текст> создать файл с текстом; относительный путь — от рабочей папки
// crash=1 завершиться аварийно (abort)
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <thread>
#include <vector>
#if defined(_WIN32)
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#endif
namespace fs = std::filesystem;
namespace {
fs::path executablePath(const char *argv0)
{
#if defined(_WIN32)
wchar_t buffer[MAX_PATH * 4];
const DWORD size = GetModuleFileNameW(nullptr, buffer, DWORD(std::size(buffer)));
return fs::path(std::wstring(buffer, size));
#elif defined(__APPLE__)
char buffer[4096];
uint32_t size = sizeof(buffer);
if (_NSGetExecutablePath(buffer, &size) == 0)
return fs::canonical(buffer);
return fs::absolute(argv0);
#else
std::error_code ec;
const fs::path self = fs::read_symlink("/proc/self/exe", ec);
return ec ? fs::absolute(argv0) : self;
#endif
}
} // namespace
int main(int argc, char *argv[])
{
std::string version = "17.0.8";
int exitCode = 0;
int sleepMs = 0;
bool crash = false;
std::string argsFile;
std::vector<std::string> dumpEnv;
std::vector<std::string> stdoutLines;
std::vector<std::pair<std::string, std::string>> creates;
std::ifstream config(executablePath(argv[0]).parent_path() / "fake-java.cfg");
for (std::string line; std::getline(config, line);) {
if (!line.empty() && line.back() == '\r')
line.pop_back();
const auto eq = line.find('=');
if (eq == std::string::npos)
continue;
const std::string key = line.substr(0, eq);
const std::string value = line.substr(eq + 1);
if (key == "version")
version = value;
else if (key == "exit")
exitCode = std::atoi(value.c_str());
else if (key == "sleep_ms")
sleepMs = std::atoi(value.c_str());
else if (key == "crash")
crash = value == "1";
else if (key == "args_file")
argsFile = value;
else if (key == "dump_env")
dumpEnv.push_back(value);
else if (key == "stdout")
stdoutLines.push_back(value);
else if (key == "create") {
const auto tab = value.find('\t');
creates.emplace_back(value.substr(0, tab),
tab == std::string::npos ? std::string() : value.substr(tab + 1));
}
}
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "-version") {
std::cerr << "openjdk version \"" << version << "\" 2024-01-16\n"
<< "OpenJDK Runtime Environment (fake)\n";
return 0;
}
}
if (!argsFile.empty()) {
std::ofstream out(argsFile, std::ios::trunc);
for (int i = 1; i < argc; ++i)
out << argv[i] << '\n';
for (const std::string &name : dumpEnv) {
const char *value = std::getenv(name.c_str());
out << "env:" << name << '=' << (value ? value : "") << '\n';
}
}
for (const auto &[path, text] : creates) {
const fs::path target(path);
std::error_code ec;
if (target.has_parent_path())
fs::create_directories(target.parent_path(), ec);
std::ofstream(target, std::ios::trunc | std::ios::binary) << text;
}
for (const std::string &line : stdoutLines)
std::cout << line << '\n';
std::cout.flush();
if (sleepMs > 0)
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
if (crash) {
#if defined(_WIN32)
// QProcess считает аварией коды 0x8xxxxxxx–0xCxxxxxxx; abort() дал бы код 3.
TerminateProcess(GetCurrentProcess(), 0xC0000005);
#endif
std::abort();
}
return exitCode;
}
+9
View File
@@ -0,0 +1,9 @@
[
{"loader": {"separator": ".", "build": 9, "maven": "net.fabricmc:fabric-loader:0.16.9", "version": "0.16.9", "stable": true},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"separator": ".", "build": 10, "maven": "net.fabricmc:fabric-loader:0.16.10", "version": "0.16.10", "stable": true},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"separator": "+", "build": 1, "maven": "net.fabricmc:fabric-loader:0.17.0-beta.1", "version": "0.17.0-beta.1", "stable": false},
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
{"loader": {"version": ""}}
]
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.minecraftforge</groupId>
<artifactId>forge</artifactId>
<versioning>
<latest>1.21.1-52.0.10</latest>
<release>1.21.1-52.0.10</release>
<versions>
<version>1.7.10-10.13.4.1614-1.7.10</version>
<version>1.20.1-47.3.0</version>
<version>1.20.1-47.4.0</version>
<version>1.20.1-47.4.1</version>
<version>1.21.1-52.0.10</version>
<version>1.21.1-52.0.2-beta</version>
<version>broken-</version>
</versions>
</versioning>
</metadata>
+2
View File
@@ -0,0 +1,2 @@
{"homepage": "https://files.minecraftforge.net/net/minecraftforge/forge/",
"promos": {"1.20.1-latest": "47.4.1", "1.20.1-recommended": "47.4.0", "1.21.1-latest": "52.0.10"}}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.neoforged</groupId>
<artifactId>forge</artifactId>
<versioning>
<versions>
<version>1.20.1-47.1.106</version>
<version>1.20.1-47.1.79</version>
</versions>
</versioning>
</metadata>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>net.neoforged</groupId>
<artifactId>neoforge</artifactId>
<versioning>
<versions>
<version>20.4.80-beta</version>
<version>20.4.237</version>
<version>21.0.10</version>
<version>21.1.200</version>
<version>21.1.248</version>
<version>26.1.0.5</version>
<version>26.1.1.3</version>
</versions>
</versioning>
</metadata>
+4
View File
@@ -0,0 +1,4 @@
[
{"loader": {"separator": ".", "build": 21, "maven": "org.quiltmc:quilt-loader:0.26.4-beta.1", "version": "0.26.4-beta.1"}},
{"loader": {"separator": ".", "build": 20, "maven": "org.quiltmc:quilt-loader:0.26.3", "version": "0.26.3"}}
]
+16
View File
@@ -0,0 +1,16 @@
{
"latest": {"release": "1.21.8", "snapshot": "25w33a"},
"versions": [
{"id": "1.20.1", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json",
"time": "2023-06-12T13:25:51+00:00", "releaseTime": "2023-06-12T13:25:51+00:00", "sha1": "aaaa", "complianceLevel": 1},
{"id": "25w33a", "type": "snapshot", "url": "https://piston-meta.mojang.com/v1/packages/cccc/25w33a.json",
"time": "2025-08-13T12:00:00+00:00", "releaseTime": "2025-08-13T12:00:00+00:00", "sha1": "cccc", "complianceLevel": 1},
{"id": "1.21.8", "type": "release", "url": "https://piston-meta.mojang.com/v1/packages/bbbb/1.21.8.json",
"time": "2025-07-17T12:00:00+00:00", "releaseTime": "2025-07-17T12:00:00+00:00", "sha1": "bbbb", "complianceLevel": 1},
{"id": "b1.7.3", "type": "old_beta", "url": "https://piston-meta.mojang.com/v1/packages/dddd/b1.7.3.json",
"time": "2011-07-08T00:00:00+00:00", "releaseTime": "2011-07-08T00:00:00+00:00", "sha1": "dddd", "complianceLevel": 0},
{"id": "insecure", "type": "release", "url": "http://piston-meta.mojang.com/insecure.json",
"releaseTime": "2024-01-01T00:00:00+00:00", "sha1": "eeee"},
{"type": "release", "url": "https://piston-meta.mojang.com/noid.json", "releaseTime": "2024-01-01T00:00:00+00:00"}
]
}
+240
View File
@@ -0,0 +1,240 @@
{
"formatVersion": 1,
"builds": [
{
"id": "season-4",
"name": "Сезон 4",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-03-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/s4.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"seasonEnd": "2025-06-01"
},
{
"id": "season-5",
"name": "Сезон 5: Пустоши",
"revision": 3,
"minecraftVersion": "1.21.1",
"loader": "fabric",
"loaderVersion": "0.16.10",
"modCount": 120,
"seasonStart": "2025-07-01",
"serverUrl": "play.example.com",
"javaMajor": 21,
"description": " Пустоши ",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/s5.zip",
"size": 123456,
"sha256": "ABCDEF0000000000000000000000000000000000000000000000000000000000"
}
},
{
"id": "vanilla-plus",
"name": "Vanilla+",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "",
"loaderVersion": "",
"modCount": 10,
"seasonStart": "2025-05-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/v.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "Bad-ID",
"name": "caps",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "no-name",
"name": " ",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "zero-rev",
"name": "X",
"revision": 0,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-mc",
"name": "X",
"revision": 1,
"minecraftVersion": "../1.20",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-loader",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "liteloader",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "no-loader-version",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "bad-sha",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "xyz"
}
},
{
"id": "neg-size",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://launcher-builds.galeonworx.com/packs/x.zip",
"size": -1,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "foreign-host",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "https://evil.example/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"id": "http-scheme",
"name": "X",
"revision": 1,
"minecraftVersion": "1.20.1",
"loader": "forge",
"loaderVersion": "47.4.0",
"modCount": 10,
"seasonStart": "2025-01-01",
"serverUrl": "play.example.com",
"javaMajor": 17,
"description": "d",
"archive": {
"url": "http://launcher-builds.galeonworx.com/packs/x.zip",
"size": 100,
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC6zCCAdOgAwIBAgIJALwPNtuB/leeMA0GCSqGSIb3DQEBCwUAMB0xGzAZBgNV
BAMMEmZha2UtaW50ZXJuZXQudGVzdDAgFw0yNjEwMTEyMDQ4NTZaGA8yMTI2MDkx
NzIwNDg1NlowHTEbMBkGA1UEAwwSZmFrZS1pbnRlcm5ldC50ZXN0MIIBIjANBgkq
hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvFAbXruU1WU9utLa/SSBIX8XUQs8rjum
KoK13RVPKlkSGln5umz0MZJas95QwHDf8s0qKR2wOyFZgDl5S4YD6sotHiqcF6zV
9FmP14So0dAL+B/RsxnLFc8seCDtW6GB1fS0G36EJBRFpt2RizGGx4zdj5thzlrf
0msXdzEY8gFriFhuejS4DbX7BlPjnuaXb2iFpTQnNw+MpIDcdfnUuUm56x29wZRb
trMnOqSP7YqVi06XM2w5NbDUlXXCSWR57nPlrleJlMX1YbDdERf7Nngl2ECBCOAY
J3pkKkQ2I8Ne059bC9ZRoF/daggGclp1JTi2PgDewefSkHPiTr7eBQIDAQABoyww
KjAoBgNVHREEITAfghJmYWtlLWludGVybmV0LnRlc3SCCWxvY2FsaG9zdDANBgkq
hkiG9w0BAQsFAAOCAQEAQQFCHNvJrn/fC8H6ntJktLyzseOQ8q6B8SSXGUd0l5/n
TQDufm/+49TpPrHu2+eoHMeeb4gijlMK8PME685x154E2erdjtilE/YBUemeLrrn
NLRIaqafArhd7qXE5pFzNc6QNAcrLF9Ao0SsI3xCxtK5duWMhQGFKQr6f7NqrSit
wNB9uYCf5pAZfL+bfRBtC6Xc6qnZy5LBUQcEkpu/HweE6DZKV1Z4XH4TGxWiO0y7
9YFYH9P/I0jsHDT/VK+6PF2JTdErqWfCxryDuUgheLw1/W8T+5CVVCvb4Hr0fsP3
0o/cCfPR9wdsqGalv6dl3cuAWCRqEq1lNVLQVbY1cg==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8UBteu5TVZT26
0tr9JIEhfxdRCzyuO6YqgrXdFU8qWRIaWfm6bPQxklqz3lDAcN/yzSopHbA7IVmA
OXlLhgPqyi0eKpwXrNX0WY/XhKjR0Av4H9GzGcsVzyx4IO1boYHV9LQbfoQkFEWm
3ZGLMYbHjN2Pm2HOWt/Saxd3MRjyAWuIWG56NLgNtfsGU+Oe5pdvaIWlNCc3D4yk
gNx1+dS5SbnrHb3BlFu2syc6pI/tipWLTpczbDk1sNSVdcJJZHnuc+WuV4mUxfVh
sN0RF/s2eCXYQIEI4BgnemQqRDYjw17Tn1sL1lGgX91qCAZyWnUlOLY+AN7B59KQ
c+JOvt4FAgMBAAECggEABOS0AV8we+cYlo06IY/cRymNSo7XMdPc+ofRAAC4wOn1
nXevFJgWNF+QOdJSGN4WA7IvIUhjNvYj5EAaFctSuLwuNfV3fun/2Uymfzw0Qtd8
KCNziIroCxYKGskIQfRNcmjccUZ1WpaoRLDHQm20CeVwMHYiMYGNBWPBBVjiRA+X
+T+iQq1f4GX2ErPw1dlcCSW/HVzyRqRwxB12VfQpW71Jq9h7oeOtcrVAEZj+rPaa
io1u/Z4kmRxuyqgE9tcDUmdYhvijnZRhPSqWJhmQyVzzCpUX8Vtwy/Bow1EKUBaa
jKtwu75ap4oTvaSJsHbX3+69eXJjIvXAQTKC7HgUFQKBgQDendqYimm1G/GDCBCj
vXLYhHkP4MRI78jcggc4rJIv+rZGto7QZZRIEi5eVAdqlqoKL/xBIi7bDc6AH73p
/M+xTWDJ4p09AMOY0k8WwsThzNOsA+R3futbKaM4NSTqwMzqaZs+jcyl/sIJUr8S
tq7ndQtMSSf0O3mCg8nnvQuKLwKBgQDYjVmugVxKYchOZ3yXjPD7a/H54n4NiqnW
/BvFPwhxMtQtb3YzzgyAuBskK3PEFjI+hKoLDGjHV/uZS1sAm1qVm2mN/lyPSeaU
P2+1EOKsvWl9DoOkwTeb3QoGkXzZ35fORCLd7iphicFptFSgmqtaKT7KSQyKHaw2
pxSwnPlyCwKBgCraqGIFAyRiJLPcsZdTuPAFUGjGFAMWZcxNG6bFUb76vGwQaJ2l
cE4Pce2mr2bNah5b7mt7cRmqJr5zXdPVXwX6p0812J8NrpmUorT2+X+eNoE+npSC
UjxSX6FKWbj3NHtLqR75nRO3XxL8YtvvXuwfk/UzgAV8DYAabpu1wKrxAoGAMUep
ev7RQ9QQw1blkY9vX6fm/xi92tMNZ3Ej2LVJdIk+o8BTw+JGEhzTgF2rEJqEgJms
IKuIRKUPComNggvYh2dAlCGoaqtEn8P35dqb2veXw+/DoYCe6QdaOrhtEx/A6h2z
WZHTbzLsIuP4xjusdu4f7eTFx8CM6jsHiFGQqpUCgYBZlBn76C+3ctH4sgLfU/Zd
Kz8cP+K/QpvA2R6K7DBn57drVG6wRIhibv3YO4YwDntLyOwZ2r5VA5uTvLqU2gGO
AYDMTZ+g9csla0QjpoMDz3ThUgeYMb5nJ3maMx/kq3aNTGMKjncTLhiC7CNSWagC
Mv+y9lT/Hf6ZSiiti8EQGQ==
-----END PRIVATE KEY-----
@@ -0,0 +1,9 @@
{
"id": "fabric-loader-0.15.0-1.20.1", "inheritsFrom": "1.20.1", "type": "release",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "net.fabricmc:fabric-loader:0.15.0", "url": "https://maven.fabricmc.net/"},
{"name": "com.mojang:brigadier:9.9.9", "url": "https://maven.fabricmc.net/"}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"id": "1.7.10", "mainClass": "net.minecraft.client.main.Main",
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --gameDir ${game_directory}",
"assets": "1.7.10",
"libraries": [
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.1",
"natives": {"linux": "natives-linux", "windows": "natives-windows-${arch}", "osx": "natives-osx"},
"extract": {"exclude": ["META-INF/"]},
"downloads": {"classifiers": {
"natives-linux": {"path": "lw/natives-linux.jar", "url": "https://l/nl.jar", "sha1": "1", "size": 1},
"natives-windows-64": {"path": "lw/natives-windows-64.jar", "url": "https://l/nw.jar", "sha1": "2", "size": 2},
"natives-windows-32": {"path": "lw/natives-windows-32.jar", "url": "https://l/nw32.jar", "sha1": "3", "size": 3},
"natives-osx": {"path": "lw/natives-osx.jar", "url": "https://l/no.jar", "sha1": "4", "size": 4}}}},
{"name": "net.minecraft:launchwrapper:1.12", "url": "https://libraries.example/maven"}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": {"id": "5", "url": "https://piston-meta.mojang.com/5.json", "sha1": "aa", "size": 1},
"javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17},
"logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}",
"file": {"id": "client-1.12.xml", "url": "https://x/client-1.12.xml"}}},
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
"--versionType", "${version_type}",
{"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"},
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}],
"value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}],
"value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "%OS%"}}], "value": "-Dthis.os=yes"},
{"rules": [{"action": "allow", "os": {"name": "%OTHER%"}}], "value": "-Dother.os=yes"},
"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [
{"name": "com.mojang:brigadier:1.1.8",
"downloads": {"artifact": {"path": "com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar",
"url": "https://libraries.minecraft.net/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar",
"sha1": "5244ce82c3337bba4a196a3ce858bfaecc74404a", "size": 77392}}},
{"name": "org.lwjgl:lwjgl:3.3.1",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", "url": "https://l/lwjgl.jar", "sha1": "s1", "size": 1}}},
{"name": "org.lwjgl:lwjgl:3.3.1:%NATIVE%",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%NATIVE%.jar", "url": "https://l/native.jar", "sha1": "s2", "size": 2}},
"rules": [{"action": "allow", "os": {"name": "%OS%"}}]},
{"name": "org.lwjgl:lwjgl:3.3.1:natives-other",
"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-other.jar", "url": "https://l/o.jar", "sha1": "s3", "size": 3}},
"rules": [{"action": "allow", "os": {"name": "%OTHER%"}}]},
{"name": "only.other:lib:1.0",
"downloads": {"artifact": {"path": "only/other/lib/1.0/lib-1.0.jar", "url": "https://l/x.jar", "sha1": "s4", "size": 4}},
"rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "%OS%"}}]}
]
}
+15
View File
@@ -0,0 +1,15 @@
# Сетевые сервисы против FakeInternet (локальный прокси + TLS, без настоящей сети).
foreach(test IN ITEMS
tst_fakeinternet
tst_versionmanifestservice
tst_seasonalbuildservice
tst_modloaderversionservice
tst_javaruntimeservice
tst_authservice
tst_msaauthservice
tst_versioninstaller
tst_seasonalpackdownloader
tst_javainstaller
tst_modloaderinstaller)
bilauncher_add_test(${test} LABEL net SOURCES ${test}.cpp)
endforeach()
+298
View File
@@ -0,0 +1,298 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "authservice.h"
#include "localization.h"
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
const QString kValidate = QStringLiteral("https://authserver.ely.by/auth/validate");
const QString kRefresh = QStringLiteral("https://authserver.ely.by/auth/refresh");
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-1.2.5.jar");
QJsonObject body(const FakeRequest &request)
{
return QJsonDocument::fromJson(request.body).object();
}
QJsonObject profileResponse(const QString &token, const QString &clientToken)
{
return QJsonObject{{"accessToken", token},
{"clientToken", clientToken},
{"selectedProfile", QJsonObject{{"id", "ffffffff-ffff-ffff-ffff-ffffffffffff"},
{"name", "ElyPlayer"}}}};
}
// Сервер Ely.by в миниатюре: пароль «secret», у пользователя «2fa» — ещё код 123456.
FakeResponse elyAuthenticate(const FakeRequest &request)
{
const QJsonObject in = body(request);
const QString user = in.value("username").toString();
const QString password = in.value("password").toString();
if (user == QLatin1String("2fa")) {
if (password == QLatin1String("secret:123456"))
return FakeResponse::json(profileResponse("token-2fa", in.value("clientToken").toString()));
if (password == QLatin1String("secret"))
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
{"errorMessage", "Account protected with two factor auth."}}, 401);
}
if (password == QLatin1String("secret"))
return FakeResponse::json(profileResponse("token-" + user, in.value("clientToken").toString()));
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
{"errorMessage", "Invalid credentials. Invalid username or password."}}, 401);
}
struct Captured
{
int calls = 0;
AuthResult result;
auto callback()
{
return [this](const AuthResult &r) { ++calls; result = r; };
}
};
} // namespace
class TestAuthService : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_dir;
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
m_net.route(kAuth, elyAuthenticate);
m_dir = TestEnv::freshDir(QStringLiteral("injector"));
}
void passwordLogin()
{
AuthService service;
QSignalSpy progress(&service, &AuthService::progress);
Captured c;
service.loginElyBy("steve", "secret", "client-1", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
QCOMPARE(c.result.uuid, QStringLiteral("ffffffffffffffffffffffffffffffff")); // без дефисов
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
QCOMPARE(c.result.clientToken, QStringLiteral("client-1"));
QCOMPARE(c.result.userType, QStringLiteral("ELYBY"));
QCOMPARE(progress.count(), 1);
const QJsonObject sent = body(m_net.requests().constFirst());
QCOMPARE(sent.value("username").toString(), QStringLiteral("steve"));
QCOMPARE(sent.value("clientToken").toString(), QStringLiteral("client-1"));
QCOMPARE(sent.value("requestUser").toBool(), true);
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArray("application/json"));
}
void emptyClientTokenIsGenerated()
{
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", QString(), QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QVERIFY(!c.result.clientToken.isEmpty());
QCOMPARE(body(m_net.requests().constFirst()).value("clientToken").toString(), c.result.clientToken);
}
void wrongPasswordAndEmptyPassword()
{
AuthService service;
Captured wrong;
service.loginElyBy("steve", "nope", "c", QString(), wrong.callback());
QVERIFY(wait(wrong));
QVERIFY(!wrong.result.ok);
QVERIFY(!wrong.result.twoFactorRequired);
QCOMPARE(wrong.result.error, QStringLiteral("Invalid credentials. Invalid username or password."));
Captured empty;
service.loginElyBy("steve", QString(), "c", QString(), empty.callback());
QCOMPARE(empty.calls, 1); // сразу, без сети
QCOMPARE(empty.result.error, Loc::text("auth.ely.error.noPassword"));
QCOMPARE(m_net.requests().size(), 1);
}
void twoFactorFlow()
{
AuthService service;
Captured first;
service.loginElyBy("2fa", "secret", "c", QString(), first.callback());
QVERIFY(wait(first));
QVERIFY(!first.result.ok);
QVERIFY(first.result.twoFactorRequired);
QCOMPARE(first.result.error, Loc::text("auth.ely.error.twoFactor"));
Captured second;
service.loginElyByWithTotp("2fa", "secret", "123456", "c", second.callback());
QVERIFY(wait(second));
QVERIFY2(second.result.ok, qPrintable(second.result.error));
QCOMPARE(second.result.accessToken, QStringLiteral("token-2fa"));
// Код уходит через двоеточие после пароля — так требует Ely.by.
QCOMPARE(body(m_net.requests().constLast()).value("password").toString(), QStringLiteral("secret:123456"));
}
void validTokenSkipsPassword()
{
m_net.route(kValidate, FakeResponse::json("{}"));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "stored-token", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("stored-token"));
QCOMPARE(m_net.hits(kAuth), 0);
QCOMPARE(body(m_net.requests().constFirst()).value("accessToken").toString(), QStringLiteral("stored-token"));
}
void expiredTokenIsRefreshed()
{
m_net.route(kValidate, FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"}}, 401));
m_net.route(kRefresh, [](const FakeRequest &request) {
const QJsonObject in = body(request);
return FakeResponse::json(profileResponse("refreshed-" + in.value("accessToken").toString(),
in.value("clientToken").toString()));
});
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "old", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("refreshed-old"));
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
QCOMPARE(m_net.hits(kAuth), 0);
}
void failedRefreshFallsBackToPassword()
{
m_net.route(kValidate, FakeResponse::json("{}", 401));
m_net.route(kRefresh, FakeResponse::json("{}", 401));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", "old", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.ok);
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
QCOMPARE(m_net.hits(kAuth), 1);
}
void transportErrorIsReported()
{
FakeResponse drop;
drop.drop = true;
m_net.route(kAuth, drop);
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QVERIFY(c.result.error.startsWith(Loc::text("auth.ely.error.unreachable").section(u'%', 0, 0)));
}
void responseWithoutProfileIsError()
{
m_net.route(kAuth, FakeResponse::json(QJsonObject{{"accessToken", "t"}}));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, Loc::text("auth.ely.error.noGameProfile"));
}
void serverErrorWithoutMessageShowsCode()
{
m_net.route(kAuth, FakeResponse::json("{}", 500));
AuthService service;
Captured c;
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
QVERIFY(wait(c));
QCOMPARE(c.result.error, Loc::text("auth.error.serverCode").arg(500));
}
// --- authlib-injector -------------------------------------------------------
void injectorDownloadVerifyAndCache()
{
const QByteArray jar = "PK\x03\x04 fake authlib-injector";
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
{"build_number", 53}, {"version", "1.2.5"},
{"download_url", kInjectorJar},
{"checksums", QJsonObject{{"sha256", Fixtures::sha256(jar)}}}}));
m_net.route(kInjectorJar, FakeResponse::bytes(jar));
AuthService service;
QString path, error;
int calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QVERIFY2(error.isEmpty(), qPrintable(error));
QCOMPARE(path, m_dir + "/authlib-injector.jar");
QCOMPARE(Fixtures::readFile(path), jar);
// Второй раз — сразу из файла.
calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QCOMPARE(calls, 1);
QCOMPARE(m_net.hits(kInjectorJar), 1);
}
void injectorChecksumMismatch()
{
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
{"download_url", kInjectorJar},
{"checksums", QJsonObject{{"sha256", QString(64, u'0')}}}}));
m_net.route(kInjectorJar, FakeResponse::bytes("tampered"));
AuthService service;
QString path, error;
int calls = 0;
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QCOMPARE(error, Loc::text("auth.ely.error.injectorChecksum"));
QVERIFY(path.isEmpty());
QVERIFY(!QFile::exists(m_dir + "/authlib-injector.jar"));
}
void injectorManifestProblems()
{
AuthService service;
QString error;
int calls = 0;
const auto cb = [&](const QString &, const QString &e) { ++calls; error = e; };
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"version", "1.2.5"}}));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
QCOMPARE(error, Loc::text("auth.ely.error.injectorNoUrl"));
m_net.route(kInjectorManifest, FakeResponse::error(503));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 2, 15000);
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorInfoFailed").section(u'%', 0, 0)));
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
m_net.route(kInjectorJar, FakeResponse::error(404));
service.ensureAuthlibInjector(m_dir, cb);
QTRY_COMPARE_WITH_TIMEOUT(calls, 3, 15000);
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorDownloadFailed").section(u'%', 0, 0)));
}
};
BILAUNCHER_TEST_MAIN(TestAuthService)
#include "tst_authservice.moc"
+135
View File
@@ -0,0 +1,135 @@
// Самопроверка FakeInternet: если она красная, остальные сетевые тесты
// бессмысленны — значит, перехват трафика на этой платформе не работает.
#include "testenv.h"
#include "fakeinternet.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QSignalSpy>
class TestFakeInternet : public QObject
{
Q_OBJECT
FakeInternet m_net;
QNetworkReply *get(QNetworkAccessManager &manager, const QString &url)
{
return manager.get(QNetworkRequest(QUrl(url)));
}
static bool waitFinished(QNetworkReply *reply)
{
if (reply->isFinished())
return true;
QSignalSpy spy(reply, &QNetworkReply::finished);
return spy.wait(15000);
}
private slots:
void initTestCase()
{
QString error;
if (!m_net.start(&error))
QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error));
}
void init() { m_net.reset(); }
void httpsGetIsIntercepted()
{
m_net.route(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"),
FakeResponse::json(QByteArrayLiteral("{\"ok\":true}")));
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> reply(
get(manager, QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json?x=1")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->error(), QNetworkReply::NoError);
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"ok\":true}"));
QCOMPARE(m_net.hits(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")), 1);
QCOMPARE(m_net.requests().constFirst().query, QStringLiteral("x=1"));
}
void postBodyAndHeaders()
{
m_net.route(QStringLiteral("https://authserver.ely.by/auth/authenticate"),
[](const FakeRequest &request) {
return FakeResponse::json(request.body, request.method == "POST" ? 200 : 405);
});
QNetworkAccessManager manager;
QNetworkRequest request(QUrl(QStringLiteral("https://authserver.ely.by/auth/authenticate")));
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
QScopedPointer<QNetworkReply> reply(manager.post(request, QByteArrayLiteral("{\"a\":1}")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"a\":1}"));
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArrayLiteral("application/json"));
}
void prefixRoutesAndKeepAlive()
{
m_net.route(QStringLiteral("https://resources.download.minecraft.net/*"),
[](const FakeRequest &request) { return FakeResponse::text(request.path.toUtf8()); });
QNetworkAccessManager manager;
for (int i = 0; i < 5; ++i) {
const QString path = QStringLiteral("/ab/ab%1").arg(i);
QScopedPointer<QNetworkReply> reply(
get(manager, QStringLiteral("https://resources.download.minecraft.net") + path));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), path.toUtf8());
}
QCOMPARE(m_net.requests().size(), 5);
}
void unknownRouteIs404AndRecorded()
{
QNetworkAccessManager manager;
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("FakeInternet: нет маршрута")));
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("https://example.invalid/x")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 404);
QCOMPARE(m_net.unmatched(), QStringList{QStringLiteral("https://example.invalid/x")});
}
void errorStatusesAndDrop()
{
m_net.route(QStringLiteral("https://a.test/500"), FakeResponse::error(500));
m_net.route(QStringLiteral("https://a.test/drop"), [] {
FakeResponse response;
response.drop = true;
return response;
}());
m_net.route(QStringLiteral("https://a.test/short"), [] {
FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x'));
response.truncateTo = 10;
return response;
}());
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
QVERIFY(waitFinished(r500.data()));
QCOMPARE(r500->error(), QNetworkReply::InternalServerError);
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
QVERIFY(waitFinished(drop.data()));
QVERIFY(drop->error() != QNetworkReply::NoError);
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
QVERIFY(waitFinished(shortReply.data()));
QVERIFY(shortReply->error() != QNetworkReply::NoError);
}
void plainHttpThroughProxy()
{
m_net.route(QStringLiteral("http://plain.test/hello"), FakeResponse::text("hi"));
QNetworkAccessManager manager;
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("http://plain.test/hello")));
QVERIFY(waitFinished(reply.data()));
QCOMPARE(reply->readAll(), QByteArrayLiteral("hi"));
QCOMPARE(m_net.requests().constFirst().scheme, QStringLiteral("http"));
}
};
BILAUNCHER_TEST_MAIN(TestFakeInternet)
#include "tst_fakeinternet.moc"
+292
View File
@@ -0,0 +1,292 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "javainstaller.h"
#include "javalocator.h"
#include "javaruntime.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kTemurin = QStringLiteral("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21/");
const QString kMojangManifest = QStringLiteral("https://piston-meta.mojang.com/v1/packages/x/manifest.json");
const QString kMojangFiles = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
JavaRuntimeEntry temurinEntry(const QString &archive, const QByteArray &data)
{
JavaRuntimeEntry entry;
entry.id = QStringLiteral("temurin-jdk-21.0.2_13-test-") + QString(archive).replace(u'.', u'-');
entry.kind = JavaRuntimeKind::Jdk;
entry.major = 21;
entry.version = QStringLiteral("21.0.2+13");
entry.archive = archive;
entry.url = QUrl(kTemurin + "OpenJDK21U-jdk." + archive);
entry.checksum = Fixtures::sha256(data);
entry.size = data.size();
return entry;
}
// Раскладка архива Temurin: один корневой каталог с bin/java внутри.
QMap<QString, QByteArray> jdkTree()
{
return {{QStringLiteral("jdk-21.0.2+13/bin/") + Fixtures::javaExecutableName(), "#!/bin/sh\necho java\n"},
{QStringLiteral("jdk-21.0.2+13/lib/modules"), QByteArray(2048, 'm')},
{QStringLiteral("jdk-21.0.2+13/release"), "JAVA_VERSION=\"21.0.2\""}};
}
} // namespace
class TestJavaInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_work;
struct Run
{
explicit Run(JavaInstaller &installer)
: started(&installer, &JavaInstaller::started)
, finished(&installer, &JavaInstaller::finished)
, failed(&installer, &JavaInstaller::failed)
, canceled(&installer, &JavaInstaller::canceled)
{}
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
QSignalSpy started, finished, failed, canceled;
};
void verifyRegistered(const JavaRuntimeEntry &entry, const Run &run)
{
QCOMPARE(run.finished.count(), 1);
QCOMPARE(run.finished.constFirst().at(0).toString(), entry.id);
const QString javaPath = run.finished.constFirst().at(1).toString();
QVERIFY(QFileInfo(javaPath).isFile());
QVERIFY(javaPath.startsWith(JavaRuntimeStore::dirFor(entry.id) + u'/'));
#if !defined(Q_OS_WIN)
QVERIFY(QFileInfo(javaPath).isExecutable());
#endif
const auto installed = JavaRuntimeStore::find(entry.id);
QVERIFY(installed.has_value());
QCOMPARE(installed->major, entry.major);
QCOMPARE(installed->version, entry.version);
QCOMPARE(installed->kind, javaKindKey(entry.kind));
// Лаунчер видит её при поиске Java.
QStringList canonical;
for (const QString &path : JavaLocator::findAll(m_work))
canonical << QFileInfo(path).canonicalFilePath();
QVERIFY(canonical.contains(QFileInfo(javaPath).canonicalFilePath()));
// Скачанный архив за собой не оставляем.
QVERIFY(QDir(LauncherPaths::javaDownloadDir()).entryList(QDir::Files).isEmpty());
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_work = TestEnv::freshDir(QStringLiteral("game"));
}
void installsZipArchive()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(installer.isRunning());
QCOMPARE(run.started.count(), 1);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
QVERIFY(!installer.isRunning());
}
void installsTarGzArchive()
{
#if defined(Q_OS_WIN)
QSKIP("Temurin под Windows — только zip; tar.gz распаковывается системным tar из PATH");
#endif
const QString archive = m_work + "/jdk.tar.gz";
if (!Fixtures::makeTarGz(archive, jdkTree()))
QSKIP("системный tar недоступен");
const QByteArray data = Fixtures::readFile(archive);
const JavaRuntimeEntry entry = temurinEntry("tar.gz", data);
m_net.route(entry.url.toString(), FakeResponse::bytes(data));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
}
void archiveChecksumMismatch()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
JavaRuntimeEntry entry = temurinEntry("zip", zip);
entry.checksum = QString(64, u'0');
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.archiveChecksum"));
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
}
void archiveWithoutJavaFails()
{
const QByteArray zip = Fixtures::zipBytes({{"jdk/README", "no binaries here"}});
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.noExecutable"));
QVERIFY(!JavaRuntimeStore::find(entry.id).has_value());
}
void zipSlipIsRejected()
{
const QByteArray zip = Fixtures::zipBytes({{"jdk/../../../escaped", "x"}});
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.archiveBadPath").arg("jdk/../../../escaped"));
QVERIFY(!QFile::exists(LauncherPaths::rootDir() + "/escaped"));
}
void unknownFormatAndInvalidEntry()
{
JavaInstaller installer;
Run run(installer);
installer.install(JavaRuntimeEntry{});
QCOMPARE(run.failure(), Loc::text("java.error.buildUnavailable"));
JavaRuntimeEntry entry = temurinEntry("7z", "x");
Run run2(installer);
installer.install(entry);
QCOMPARE(run2.failure(), Loc::text("java.error.unknownArchiveFormat").arg("7z"));
}
void mojangComponent()
{
const QByteArray java = "#!/bin/sh\necho mojang-java\n";
const QByteArray lib = QByteArray(1024, 'l');
const QString binary = QStringLiteral("bin/") + Fixtures::javaExecutableName();
QJsonObject files{
{"bin", QJsonObject{{"type", "directory"}}},
{binary, QJsonObject{{"type", "file"}, {"executable", true},
{"downloads", QJsonObject{{"raw", QJsonObject{
{"url", kMojangFiles + Fixtures::sha1(java) + "/java"},
{"sha1", Fixtures::sha1(java)}, {"size", java.size()}}}}}}},
{"lib/modules", QJsonObject{{"type", "file"},
{"downloads", QJsonObject{{"raw", QJsonObject{
{"url", kMojangFiles + Fixtures::sha1(lib) + "/modules"},
{"sha1", Fixtures::sha1(lib)}, {"size", lib.size()}}}}}}},
{"lib/modules-link", QJsonObject{{"type", "link"}, {"target", "modules"}}}};
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{{"files", files}}));
m_net.route(kMojangFiles + Fixtures::sha1(java) + "/java", FakeResponse::bytes(java));
int libAttempts = 0;
m_net.route(kMojangFiles + Fixtures::sha1(lib) + "/modules", [&](const FakeRequest &) {
return ++libAttempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes(lib); // временный сбой
});
JavaRuntimeEntry entry;
entry.id = QStringLiteral("mojang-java-runtime-delta");
entry.kind = JavaRuntimeKind::Mojang;
entry.major = 21;
entry.version = QStringLiteral("21.0.7");
entry.component = QStringLiteral("java-runtime-delta");
entry.archive = QStringLiteral("mojang");
entry.url = QUrl(kMojangManifest);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyRegistered(entry, run);
QCOMPARE(libAttempts, 2);
const QString dir = JavaRuntimeStore::dirFor(entry.id);
QCOMPARE(Fixtures::readFile(dir + "/lib/modules"), lib);
QVERIFY(QFileInfo::exists(dir + "/lib/modules-link"));
}
void mojangManifestEscapingIsRejected()
{
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{
{"files", QJsonObject{{"../../evil", QJsonObject{{"type", "directory"}}}}}}));
JavaRuntimeEntry entry;
entry.id = QStringLiteral("mojang-evil");
entry.kind = JavaRuntimeKind::Mojang;
entry.major = 17;
entry.version = QStringLiteral("17");
entry.archive = QStringLiteral("mojang");
entry.url = QUrl(kMojangManifest);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("java.error.manifestBadPath").arg("../../evil"));
}
void cancelDuringDownload()
{
FakeResponse hang;
hang.hang = true;
const JavaRuntimeEntry entry = temurinEntry("zip", "whatever");
m_net.route(entry.url.toString(), hang);
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(entry.url.toString()), 1, 15000);
installer.cancel();
QVERIFY(run.waitEnd());
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
QVERIFY(!installer.isRunning());
QVERIFY(run.failed.isEmpty());
// abort() синхронно доводит ответ до handleArchiveFinished(), который уже
// шлёт canceled; затем cancel() видит m_reply == nullptr и шлёт его ещё раз.
QEXPECT_FAIL("", "canceled приходит дважды: из handleArchiveFinished() и из cancel()", Continue);
QCOMPARE(run.canceled.count(), 1);
}
void reinstallReplacesOld()
{
const QByteArray zip = Fixtures::zipBytes(jdkTree());
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
Fixtures::write(JavaRuntimeStore::dirFor(entry.id) + "/stale/file", "old");
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
JavaInstaller installer;
Run run(installer);
installer.install(entry);
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QVERIFY(!QFile::exists(JavaRuntimeStore::dirFor(entry.id) + "/stale/file"));
}
};
BILAUNCHER_TEST_MAIN(TestJavaInstaller)
#include "tst_javainstaller.moc"
+319
View File
@@ -0,0 +1,319 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "javaruntime.h"
#include "javaruntimeservice.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QJsonArray>
#include <QSignalSpy>
#include <QSysInfo>
namespace {
const QString kReleases = QStringLiteral("https://api.adoptium.net/v3/info/available_releases");
const QString kFeature = QStringLiteral("https://api.adoptium.net/v3/assets/feature_releases/*");
const QString kMojang = QStringLiteral(
"https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json");
bool VersionLoaderOsIsWindows()
{
#if defined(Q_OS_WIN)
return true;
#else
return false;
#endif
}
QString hostAdoptiumArch()
{
const QString arch = QSysInfo::currentCpuArchitecture();
if (arch == QLatin1String("arm64"))
return QStringLiteral("aarch64");
if (arch == QLatin1String("x86_64"))
return QStringLiteral("x64");
return arch;
}
// Один релиз Temurin в формате Adoptium API.
QJsonObject release(int major, const QString &arch, const QString &openjdk, int security,
const QString &timestamp, const QString &os)
{
const QString ext = os == QLatin1String("windows") ? QStringLiteral("zip") : QStringLiteral("tar.gz");
QJsonArray binaries;
for (const QString type : {QStringLiteral("jdk"), QStringLiteral("jre"), QStringLiteral("testimage")}) {
const QString name = QStringLiteral("OpenJDK%1U-%2_%3_%4_hotspot.%5").arg(major).arg(type, arch, os, ext);
binaries << QJsonObject{
{"architecture", arch}, {"image_type", type}, {"os", os},
{"package", QJsonObject{{"name", name},
{"link", "https://github.com/adoptium/temurin/releases/download/x/" + name},
{"checksum", QString(64, u'c')},
{"size", 190000000}}}};
}
return QJsonObject{
{"release_name", QStringLiteral("jdk-%1").arg(openjdk)},
{"timestamp", timestamp},
{"binaries", binaries},
{"version_data", QJsonObject{{"major", major}, {"security", security},
{"openjdk_version", openjdk + "-LTS"}, {"semver", openjdk}}}};
}
QByteArray mojangAll()
{
const QJsonObject components{
{"java-runtime-delta", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/d/manifest.json"},
{"sha1", "dddd"}, {"size", 1000}}},
{"version", QJsonObject{{"name", "21.0.7"}, {"released", "2025-04-15T00:00:00+00:00"}}}}}},
{"jre-legacy", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/l/manifest.json"},
{"sha1", "llll"}, {"size", 1000}}},
{"version", QJsonObject{{"name", "8u51"}, {"released", "2015-07-15T00:00:00+00:00"}}}}}},
{"java-runtime-alpha", QJsonArray{}},
{"minecraft-java-exe", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "https://piston-meta.mojang.com/v1/packages/e/manifest.json"}}},
{"version", QJsonObject{{"name", "7.0"}}}}}},
{"insecure", QJsonArray{QJsonObject{
{"manifest", QJsonObject{{"url", "http://piston-meta.mojang.com/x.json"}}},
{"version", QJsonObject{{"name", "17.0.1"}}}}}}};
QJsonObject root;
for (const char *platform : {"gamecore", "linux", "linux-i386", "mac-os", "mac-os-arm64",
"windows-arm64", "windows-x64", "windows-x86"})
root.insert(QLatin1String(platform), components);
return QJsonDocument(root).toJson(QJsonDocument::Compact);
}
} // namespace
class TestJavaRuntimeService : public QObject
{
Q_OBJECT
FakeInternet m_net;
// Отвечает релизами на запрошенную архитектуру/ОС; без «missingForArch» — для всех.
void routeAdoptium(const QString &missingForArch = QString())
{
m_net.route(kReleases, FakeResponse::json(QJsonObject{
{"available_lts_releases", QJsonArray{8, 17, 21}},
{"available_releases", QJsonArray{8, 17, 21, 24}}}));
m_net.route(kFeature, [missingForArch](const FakeRequest &request) {
const int major = request.path.section(u'/', -2, -2).toInt();
const QString arch = request.queryItems().queryItemValue("architecture");
const QString os = request.queryItems().queryItemValue("os");
if (arch == missingForArch)
return FakeResponse::json("[]");
QJsonArray list;
if (major == 8) {
list << release(8, arch, "1.8.0_452-b09", 452, "2025-04-20T00:00:00Z", os);
} else {
list << release(major, arch, QStringLiteral("%1.0.2+13").arg(major), 2, "2025-01-20T00:00:00Z", os)
<< release(major, arch, QStringLiteral("%1.0.1+12").arg(major), 1, "2024-10-20T00:00:00Z", os);
}
return FakeResponse::json(QJsonDocument(list).toJson());
});
}
static bool load(JavaRuntimeService &service, CallResult &r, bool force = false)
{
service.ensureLoaded(r.callback(), force);
return QTest::qWaitFor([&] { return r.calls > 0; }, 20000);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void mergesAdoptiumAndMojang()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
QSignalSpy changed(&service, &JavaRuntimeService::catalogChanged);
CallResult r;
QVERIFY(load(service, r));
QVERIFY2(r.ok, qPrintable(r.warning));
QVERIFY(r.warning.isEmpty());
QCOMPARE(changed.count(), 1);
QVERIFY(!service.isRefreshing());
// Запросы к Adoptium — с параметрами текущей машины.
const QList<FakeRequest> requests = m_net.requests();
int featureRequests = 0;
for (const FakeRequest &request : requests) {
if (!request.path.startsWith("/v3/assets/feature_releases/"))
continue;
++featureRequests;
QCOMPARE(request.queryItems().queryItemValue("architecture"), hostAdoptiumArch());
QCOMPARE(request.queryItems().queryItemValue("image_type"), QString());
QCOMPARE(request.queryItems().queryItemValue("vendor"), QStringLiteral("eclipse"));
}
QCOMPARE(featureRequests, 4);
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
const QList<JavaRuntimeEntry> jres = service.entries(JavaRuntimeKind::Jre);
QCOMPARE(jdks.size(), 7); // 8 — один релиз, 17/21/24 — по два
QCOMPARE(jres.size(), 7);
// Свежие major первыми, внутри — по дате.
QCOMPARE(jdks.first().major, 24);
QCOMPARE(jdks.first().version, QStringLiteral("24.0.2+13"));
QCOMPARE(jdks.last().major, 8);
QCOMPARE(jdks.last().version, QStringLiteral("8u452"));
const JavaRuntimeEntry jdk21 = jdks.at(2);
QCOMPARE(jdk21.major, 21);
QCOMPARE(jdk21.version, QStringLiteral("21.0.2+13")); // «-LTS» отрезан
QVERIFY(jdk21.lts);
QVERIFY(!jdks.first().lts); // 24 — не LTS
QCOMPARE(jdk21.architecture, hostAdoptiumArch());
QCOMPARE(jdk21.size, qint64(190000000));
QCOMPARE(jdk21.archive, VersionLoaderOsIsWindows() ? QStringLiteral("zip") : QStringLiteral("tar.gz"));
QCOMPARE(jdk21.id, JavaRuntimeStore::sanitizeId("temurin-jdk-21.0.2+13-" + hostAdoptiumArch()));
QVERIFY(service.find(jdk21.id).has_value());
// Mojang: java-runtime-delta (21) и jre-legacy (8); пустые, exe и http — выброшены.
const QList<JavaRuntimeEntry> mojang = service.entries(JavaRuntimeKind::Mojang);
QCOMPARE(mojang.size(), 2);
const auto delta = service.find("mojang-java-runtime-delta");
QVERIFY(delta.has_value());
QCOMPARE(delta->major, 21);
QCOMPARE(delta->component, QStringLiteral("java-runtime-delta"));
QCOMPARE(delta->archive, QStringLiteral("mojang"));
QCOMPARE(delta->checksum, QStringLiteral("dddd"));
QCOMPARE(service.find("mojang-jre-legacy")->major, 8);
QVERIFY(QFile::exists(LauncherPaths::javaCatalogFile()));
}
void bestForPrefersSmallestSuitableMajorThenJdk()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!service.bestFor(0).has_value());
QVERIFY(!service.bestFor(99).has_value());
const auto for17 = service.bestFor(17);
QVERIFY(for17.has_value());
QCOMPARE(for17->major, 17);
QCOMPARE(for17->kind, JavaRuntimeKind::Jdk);
QCOMPARE(for17->version, QStringLiteral("17.0.2+13")); // самый свежий релиз
QCOMPARE(service.bestFor(18)->major, 21);
QCOMPARE(service.bestFor(8)->major, 8);
}
void partialFailureStillProducesCatalog()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::error(500));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
QVERIFY(r.warning.startsWith(Loc::text("java.error.mojangRuntimesFailed").section(u'%', 0, 0)));
QVERIFY(service.entries(JavaRuntimeKind::Mojang).isEmpty());
QVERIFY(!service.entries(JavaRuntimeKind::Jdk).isEmpty());
}
void adoptiumDownMojangUp()
{
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.entries(JavaRuntimeKind::Mojang).size(), 2);
QVERIFY(service.entries(JavaRuntimeKind::Jdk).isEmpty());
}
void everythingDownWithoutCacheFails()
{
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!r.ok);
QVERIFY(r.warning.contains("; ")); // оба предупреждения склеены
QVERIFY(!service.hasData());
}
void everythingDownWithCacheUsesCache()
{
routeAdoptium();
m_net.route(kMojang, FakeResponse::json(mojangAll()));
{
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
}
m_net.reset();
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult cached;
QVERIFY(load(service, cached));
QVERIFY(cached.ok);
QVERIFY(m_net.requests().isEmpty()); // кеш свежий — сети нет
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
CallResult forced;
QVERIFY(load(service, forced, true));
QVERIFY(forced.ok);
QVERIFY(!forced.warning.isEmpty());
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
}
void cacheForOtherPlatformIsIgnored()
{
QJsonObject entry;
entry["id"] = "x";
entry["kind"] = "jdk";
entry["major"] = 17;
entry["url"] = "https://x/y.zip";
QJsonObject cache;
cache["platform"] = "amiga-os";
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
cache["entries"] = QJsonArray{entry};
Fixtures::writeJson(LauncherPaths::javaCatalogFile(), cache);
m_net.route(kReleases, FakeResponse::error(503));
m_net.route(kMojang, FakeResponse::error(503));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(!service.hasData());
QVERIFY(!r.ok);
}
void aarch64FallsBackToX64()
{
if (hostAdoptiumArch() != QLatin1String("aarch64"))
QSKIP("запасной x64 запрашивается только на ARM-машинах");
routeAdoptium(QStringLiteral("aarch64"));
m_net.route(kMojang, FakeResponse::json(mojangAll()));
JavaRuntimeService service;
CallResult r;
QVERIFY(load(service, r));
QVERIFY(r.ok);
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
QVERIFY(!jdks.isEmpty());
for (const JavaRuntimeEntry &entry : jdks)
QCOMPARE(entry.architecture, QStringLiteral("x64"));
}
};
BILAUNCHER_TEST_MAIN(TestJavaRuntimeService)
#include "tst_javaruntimeservice.moc"
+273
View File
@@ -0,0 +1,273 @@
#include "testenv.h"
#include "fakemojang.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "minecraftversion.h"
#include "modloaderinstaller.h"
#include "modloaderversionservice.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include "zlibreference.h"
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kFabricList = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1/0.16.10/profile/json");
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
const QString kForgeInstaller = QStringLiteral(
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar");
QByteArray fabricProfile(const QString &inherits = QStringLiteral("1.20.1"))
{
// Профиль Fabric: дочерняя версия с собственной библиотекой из Maven Fabric.
const QJsonObject json{
{"id", "fabric-loader-0.16.10-1.20.1"},
{"inheritsFrom", inherits},
{"type", "release"},
{"mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient"},
{"arguments", QJsonObject{{"game", QJsonArray{}}, {"jvm", QJsonArray{"-DFabricMcEmu= net.minecraft.client.main.Main "}}}},
{"libraries", QJsonArray{QJsonObject{{"name", "net.fabricmc:fabric-loader:0.16.10"},
{"url", "https://maven.fabricmc.net/"}}}}};
return QJsonDocument(json).toJson(QJsonDocument::Compact);
}
} // namespace
class TestModLoaderInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_game;
struct Rig
{
VersionManifestService manifest;
ModLoaderVersionService meta;
VersionInstaller versions{&manifest};
ModLoaderInstaller installer{&meta, &versions};
QSignalSpy finished{&installer, &ModLoaderInstaller::finished};
QSignalSpy failed{&installer, &ModLoaderInstaller::failed};
QSignalSpy canceled{&installer, &ModLoaderInstaller::canceled};
QSignalSpy log{&installer, &ModLoaderInstaller::log};
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
};
void routeForge()
{
m_net.route(kForgeMeta, FakeResponse::bytes(Fixtures::read("loaders/forge-maven-metadata.xml")));
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
m_net.route(kForgeInstaller, FakeResponse::bytes("PK fake forge installer"));
}
// Подставная java, которая ведёт себя как установщик Forge: кладёт профиль версии.
QString forgeLikeJava(const QStringList &extra = {})
{
const QString id = QStringLiteral("1.20.1-forge-47.4.0");
const QString json = QStringLiteral(
"{\"id\":\"%1\",\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"cpw.mods.bootstraplauncher.BootstrapLauncher\"}").arg(id);
QStringList config{"args_file=" + m_game + "/installer-args.txt",
"dump_env=LD_PRELOAD",
"stdout=Extracting json",
"stdout=The client installed successfully"};
config << "create=" + m_game + "/versions/" + id + u'/' + id + ".json\t" + json;
config += extra;
return Fixtures::installFakeJava(m_game + "/jdk17", "17.0.8", config);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_game = TestEnv::freshDir(QStringLiteral("game"));
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
m_net.route(kFabricList, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
m_net.route("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar",
FakeResponse::bytes("fabric-loader-jar"));
}
void fabricInstallsBaseThenProfile()
{
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile()));
Rig rig;
QSignalSpy started(&rig.installer, &ModLoaderInstaller::started);
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.installer.isRunning());
QCOMPARE(started.count(), 1);
QVERIFY(rig.waitEnd());
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
const QList<QVariant> done = rig.finished.constFirst();
QCOMPARE(done.at(0).toString(), QStringLiteral("fabric"));
QCOMPARE(done.at(1).toString(), QStringLiteral("1.20.1"));
QCOMPARE(done.at(2).toString(), QStringLiteral("0.16.10"));
QCOMPARE(done.at(3).toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QVERIFY(!rig.installer.isRunning());
// Ваниль докачана, профиль сохранён, библиотека загрузчика на месте.
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
QVERIFY(QFile::exists(m_game + "/versions/fabric-loader-0.16.10-1.20.1/fabric-loader-0.16.10-1.20.1.json"));
QCOMPARE(Fixtures::readFile(m_game + "/libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"),
QByteArray("fabric-loader-jar"));
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.16.10-1.20.1", {}, nullptr);
QVERIFY(v.isValid());
}
void fabricProfileForWrongGameIsRejected()
{
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile("1.19.4")));
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionWrongMinecraft")
.arg(loaderTitle(ModLoader::Fabric), "1.19.4", "1.20.1"));
QVERIFY(!QDir(m_game + "/versions/fabric-loader-0.16.10-1.20.1").exists());
}
void fabricProfileWithUnsafeIdIsRejected()
{
m_net.route(kFabricProfile, FakeResponse::json(QJsonObject{{"id", "../evil"}, {"inheritsFrom", "1.20.1"}}));
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionBadId").arg(loaderTitle(ModLoader::Fabric)));
}
void validationErrors()
{
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", " ", QString());
QCOMPARE(rig.failure(), Loc::text("loader.error.noVersionSelected").arg(loaderTitle(ModLoader::Fabric)));
QVERIFY(!rig.installer.isRunning());
}
void secondInstallWhileBusy()
{
FakeResponse slow = FakeResponse::json(fabricProfile());
slow.delayMs = 500;
m_net.route(kFabricProfile, slow);
Rig rig;
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
rig.installer.install(m_game, ModLoader::Quilt, "1.20.1", "0.26.3", QString());
QCOMPARE(rig.failed.count(), 1);
QCOMPARE(rig.failure(), Loc::text("loader.error.installInProgress"));
QTRY_COMPARE_WITH_TIMEOUT(rig.finished.count(), 1, 30000);
}
void forgeRunsInstallerWithJava()
{
routeForge();
const QString java = forgeLikeJava();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
QCOMPARE(rig.finished.constFirst().at(3).toString(), QStringLiteral("1.20.1-forge-47.4.0"));
// Установщик запущен как java -jar <installer> --installClient <папка игры>.
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
.split(u'\n', Qt::SkipEmptyParts);
QCOMPARE(args.value(0), QStringLiteral("-jar"));
QCOMPARE(QFileInfo(args.value(1)).fileName(), QStringLiteral("forge-47.4.0-installer.jar"));
QCOMPARE(args.value(2), QStringLiteral("--installClient"));
QCOMPARE(args.value(3), QDir::toNativeSeparators(m_game));
// Заглушка launcher_profiles.json, без которой установщик Forge отказывается работать.
QVERIFY(Fixtures::readJsonFile(m_game + "/launcher_profiles.json").contains("profiles"));
// Вывод установщика идёт в журнал.
QStringList lines;
for (const auto &call : rig.log)
lines << call.constFirst().toString();
QVERIFY(lines.contains("The client installed successfully"));
QVERIFY(QFile::exists(LauncherPaths::loaderDownloadDir() + "/forge-47.4.0-install.log"));
}
void forgeInstallerFailureCleansUp()
{
routeForge();
const QString java = forgeLikeJava({"exit=1", "stdout=Processor failed, invalid outputs"});
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
const QString failure = rig.failure();
QVERIFY(failure.startsWith(Loc::text("loader.error.installerExitCode").arg(loaderTitle(ModLoader::Forge)).arg(1)));
QVERIFY(failure.contains("Processor failed")); // хвост лога в сообщении
// Версию, которую успел создать упавший установщик, убираем.
QVERIFY(!QDir(m_game + "/versions/1.20.1-forge-47.4.0").exists());
QVERIFY(QDir(m_game + "/versions/1.20.1").exists());
}
void forgeInstallerProducingNothing()
{
routeForge();
const QString java = Fixtures::installFakeJava(m_game + "/jdk17-empty", "17.0.8", {"stdout=did nothing"});
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
QVERIFY(rig.failure().startsWith(Loc::text("loader.error.noVersionProduced").section(u'%', 0, 0)));
}
void forgeUnknownVersionIsIncompatible()
{
routeForge();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "1.0.0", QString());
QVERIFY(rig.waitEnd());
QCOMPARE(rig.failure(), Loc::text("loader.error.incompatible").arg(loaderTitle(ModLoader::Forge), "1.0.0", "1.20.1"));
}
void zlibNgEnvironmentOnLinux()
{
#if !defined(Q_OS_LINUX)
QSKIP("LD_PRELOAD с эталонным zlib — только Linux");
#else
routeForge();
const QString java = forgeLikeJava();
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
QVERIFY(rig.waitEnd());
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
.split(u'\n', Qt::SkipEmptyParts);
const QString preload = args.filter("env:LD_PRELOAD=").value(0).mid(int(sizeof("env:LD_PRELOAD=") - 1));
if (ZlibReference::systemIsZlibNg() && !ZlibReference::bundledPath().isEmpty())
QVERIFY(preload.startsWith(ZlibReference::bundledPath()));
else
QCOMPARE(preload, qEnvironmentVariable("LD_PRELOAD"));
#endif
}
void cancelDuringInstallerDownload()
{
routeForge();
FakeResponse hang;
hang.hang = true;
m_net.route(kForgeInstaller, hang);
Rig rig;
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", QString());
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kForgeInstaller), 1, 30000);
rig.installer.cancel();
QVERIFY(rig.waitEnd());
QCOMPARE(rig.canceled.count(), 1);
QVERIFY(rig.failed.isEmpty());
QVERIFY(!rig.installer.isRunning());
}
};
BILAUNCHER_TEST_MAIN(TestModLoaderInstaller)
#include "tst_modloaderinstaller.moc"
+274
View File
@@ -0,0 +1,274 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "modloaderversionservice.h"
#include <QSignalSpy>
namespace {
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
const QString kQuilt = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/1.20.1");
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
const QString kNeoMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
const QString kNeoLegacyMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml");
FakeResponse xml(const QByteArray &body)
{
FakeResponse response = FakeResponse::bytes(body);
response.contentType = "application/xml";
return response;
}
QStringList loaderVersions(const QList<LoaderVersionEntry> &list)
{
QStringList out;
for (const LoaderVersionEntry &entry : list)
out << entry.loaderVersion;
return out;
}
} // namespace
class TestModLoaderVersionService : public QObject
{
Q_OBJECT
FakeInternet m_net;
void routeAll()
{
m_net.route(kFabric, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
m_net.route(kQuilt, FakeResponse::json(Fixtures::read("loaders/quilt-1.20.1.json")));
m_net.route(kForgeMeta, xml(Fixtures::read("loaders/forge-maven-metadata.xml")));
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
m_net.route(kNeoMeta, xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")));
m_net.route(kNeoLegacyMeta, xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")));
}
static bool load(ModLoaderVersionService &service, ModLoader loader, const QString &game,
CallResult &r, bool force = false)
{
service.ensureLoaded(loader, game, r.callback(), force);
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
routeAll();
}
void fabric()
{
ModLoaderVersionService service;
QSignalSpy changed(&service, &ModLoaderVersionService::versionsChanged);
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Fabric, "1.20.1");
// Новые первыми: 0.16.10 > 0.16.9 (числовое сравнение, не строковое); пустые версии выброшены.
QCOMPARE(loaderVersions(list), QStringList({"0.17.0-beta.1", "0.16.10", "0.16.9"}));
QCOMPARE(list.at(1).versionId, QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QCOMPARE(list.at(1).gameVersion, QStringLiteral("1.20.1"));
QVERIFY(list.at(1).stable);
QVERIFY(!list.at(0).stable);
QVERIFY(list.at(1).installerUrl.isEmpty());
QCOMPARE(changed.count(), 1);
QCOMPARE(changed.constFirst().at(0).toString(), QStringLiteral("fabric"));
QCOMPARE(changed.constFirst().at(1).toString(), QStringLiteral("1.20.1"));
QVERIFY(service.find(ModLoader::Fabric, "1.20.1", "0.16.9").has_value());
QVERIFY(!service.find(ModLoader::Fabric, "1.20.1", "9.9.9").has_value());
QVERIFY(service.versions(ModLoader::Fabric, "1.19.2").isEmpty());
QVERIFY(QFile::exists(LauncherPaths::loaderCacheFile("fabric")));
}
void quiltStabilityHeuristic()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Quilt, "1.20.1", r));
QVERIFY(r.ok);
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Quilt, "1.20.1");
QCOMPARE(loaderVersions(list), QStringList({"0.26.4-beta.1", "0.26.3"}));
QCOMPARE(list.at(0).versionId, QStringLiteral("quilt-loader-0.26.4-beta.1-1.20.1"));
QVERIFY(!list.at(0).stable); // поля stable нет — решает «beta» в номере
QVERIFY(list.at(1).stable);
}
void forgeMergesPromotions()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
QCOMPARE(m_net.hits(kForgeMeta), 1);
QCOMPARE(m_net.hits(kForgePromos), 1);
const QList<LoaderVersionEntry> v1201 = service.versions(ModLoader::Forge, "1.20.1");
QCOMPARE(loaderVersions(v1201), QStringList({"47.4.1", "47.4.0", "47.3.0"}));
for (const LoaderVersionEntry &entry : v1201)
QCOMPARE(entry.recommended, entry.loaderVersion == QLatin1String("47.4.0"));
QCOMPARE(v1201.at(1).installerUrl,
QUrl("https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar"));
QVERIFY(v1201.at(1).versionId.isEmpty()); // id Forge узнаём только после установки
// Весь каталог Forge — одним запросом, по всем версиям игры.
const QList<LoaderVersionEntry> v1211 = service.versions(ModLoader::Forge, "1.21.1");
QCOMPARE(loaderVersions(v1211), QStringList({"52.0.10", "52.0.2-beta"}));
QVERIFY(!v1211.at(1).stable);
QCOMPARE(loaderVersions(service.versions(ModLoader::Forge, "1.7.10")),
QStringList{"10.13.4.1614-1.7.10"});
// Другая версия игры у Forge не требует нового запроса.
CallResult again;
QVERIFY(load(service, ModLoader::Forge, "1.21.1", again));
QCOMPARE(m_net.hits(kForgeMeta), 1);
}
void forgeWithoutPromotionsStillWorks()
{
m_net.route(kForgePromos, FakeResponse::error(500));
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
QVERIFY(r.ok);
for (const LoaderVersionEntry &entry : service.versions(ModLoader::Forge, "1.20.1"))
QVERIFY(!entry.recommended);
}
void neoForgeBothSchemes()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
QVERIFY2(r.ok, qPrintable(r.warning));
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21.1")),
QStringList({"21.1.248", "21.1.200"}));
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21")), QStringList{"21.0.10"});
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.20.4")),
QStringList({"20.4.237", "20.4.80-beta"}));
// Новая нумерация (год.выпуск.патч.сборка).
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1")), QStringList{"26.1.0.5"});
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1.1")), QStringList{"26.1.1.3"});
QCOMPARE(service.find(ModLoader::NeoForge, "1.21.1", "21.1.248")->installerUrl,
QUrl("https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar"));
}
void neoForgeLegacyArtifactArrivesLast()
{
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
legacySlow.delayMs = 400;
m_net.route(kNeoLegacyMeta, legacySlow);
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
QVERIFY(r.ok);
const QList<LoaderVersionEntry> legacy = service.versions(ModLoader::NeoForge, "1.20.1");
QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"}));
QCOMPARE(legacy.at(0).installerUrl,
QUrl("https://maven.neoforged.net/releases/net/neoforged/forge/1.20.1-47.1.106/forge-1.20.1-47.1.106-installer.jar"));
}
void neoForgeLegacyArtifactArrivesFirst()
{
// Тот же каталог, но маленький legacy-ответ приходит раньше основного.
FakeResponse primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
primarySlow.delayMs = 400;
m_net.route(kNeoMeta, primarySlow);
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r));
QVERIFY(r.ok);
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
// В startRefresh() основной ответ делает state->merged = parsed и затирает
// уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают.
QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue);
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
}
void primaryFailureIsError()
{
m_net.route(kNeoMeta, FakeResponse::error(503));
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
QVERIFY(!r.ok);
QVERIFY(r.warning.contains(loaderTitle(ModLoader::NeoForge)));
m_net.route(kForgeMeta, xml("<metadata><versioning><versions></versions></versioning></metadata>"));
CallResult empty;
QVERIFY(load(service, ModLoader::Forge, "1.20.1", empty));
QVERIFY(!empty.ok);
QCOMPARE(empty.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Forge)));
m_net.route(kFabric, FakeResponse::json("{\"not\": \"an array\"}"));
CallResult notArray;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", notArray));
QVERIFY(!notArray.ok);
QCOMPARE(notArray.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Fabric)));
}
void cachePerLoaderAndOfflineFallback()
{
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
}
QCOMPARE(m_net.hits(kFabric), 1);
ModLoaderVersionService service;
CallResult cached;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", cached));
QVERIFY(cached.ok);
QCOMPARE(m_net.hits(kFabric), 1); // свежий кеш
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
// Сеть легла, принудительное обновление: данные остаются, ok с предупреждением.
m_net.route(kFabric, FakeResponse::error(502));
CallResult offline;
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", offline, true));
QVERIFY(offline.ok);
QVERIFY(!offline.warning.isEmpty());
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
}
void emptyGameVersionIsNoop()
{
ModLoaderVersionService service;
CallResult r;
QVERIFY(load(service, ModLoader::Fabric, " ", r));
QVERIFY(r.ok);
QVERIFY(m_net.requests().isEmpty());
}
void inFlightRequestsAreShared()
{
FakeResponse slow = FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json"));
slow.delayMs = 300;
m_net.route(kFabric, slow);
ModLoaderVersionService service;
CallResult a, b;
service.ensureLoaded(ModLoader::Fabric, "1.20.1", a.callback());
QVERIFY(service.isRefreshing(ModLoader::Fabric, "1.20.1"));
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.19.2"));
service.ensureLoaded(ModLoader::Fabric, "1.20.1", b.callback());
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1, 15000);
QCOMPARE(m_net.hits(kFabric), 1);
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.20.1"));
}
};
BILAUNCHER_TEST_MAIN(TestModLoaderVersionService)
#include "tst_modloaderversionservice.moc"
+262
View File
@@ -0,0 +1,262 @@
#include "testenv.h"
#include "nettest.h"
#include "authservice.h"
#include "localization.h"
#include "msaauthservice.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QSignalSpy>
namespace {
const QString kToken = QStringLiteral("https://login.live.com/oauth20_token.srf");
const QString kXbl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
const QString kXsts = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
const QString kMcLogin = QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
const QString kEntitlements = QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
const QString kProfile = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
QJsonObject jsonBody(const FakeRequest &request)
{
return QJsonDocument::fromJson(request.body).object();
}
struct Captured
{
int calls = 0;
AuthResult result;
auto callback()
{
return [this](const AuthResult &r) { ++calls; result = r; };
}
};
} // namespace
class TestMsaAuthService : public QObject
{
Q_OBJECT
FakeInternet m_net;
// Полностью рабочая цепочка Microsoft → Xbox → XSTS → Minecraft.
void routeHappyPath()
{
m_net.route(kToken, [](const FakeRequest &request) {
const QUrlQuery form = request.formItems();
const QString grant = form.queryItemValue("grant_type");
if (grant == QLatin1String("authorization_code") && form.queryItemValue("code") == QLatin1String("good-code"))
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-2"}});
if (grant == QLatin1String("refresh_token") && form.queryItemValue("refresh_token") == QLatin1String("ms-refresh-1"))
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-3"}});
return FakeResponse::json(QJsonObject{{"error", "invalid_grant"},
{"error_description", "The provided value for the 'code' parameter is not valid."}}, 400);
});
m_net.route(kXbl, [](const FakeRequest &request) {
const QJsonObject props = jsonBody(request).value("Properties").toObject();
if (props.value("RpsTicket").toString() != QLatin1String("ms-access"))
return FakeResponse::error(400);
return FakeResponse::json(QJsonObject{{"Token", "xbl-token"}});
});
m_net.route(kXsts, [](const FakeRequest &request) {
const QJsonObject props = jsonBody(request).value("Properties").toObject();
if (props.value("UserTokens").toArray().first().toString() != QLatin1String("xbl-token"))
return FakeResponse::error(400);
const QJsonObject claims{{"xui", QJsonArray{QJsonObject{{"uhs", "user-hash"}, {"xid", "2535400000000000"}}}}};
return FakeResponse::json(QJsonObject{{"Token", "xsts-token"}, {"DisplayClaims", claims}});
});
m_net.route(kMcLogin, [](const FakeRequest &request) {
if (jsonBody(request).value("identityToken").toString() != QLatin1String("XBL3.0 x=user-hash;xsts-token"))
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"access_token", "mc-token"}, {"expires_in", 3600}});
});
m_net.route(kEntitlements, [](const FakeRequest &request) {
if (request.header("authorization") != "Bearer mc-token")
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "game_minecraft"}}}}});
});
m_net.route(kProfile, [](const FakeRequest &request) {
if (request.header("authorization") != "Bearer mc-token")
return FakeResponse::error(401);
return FakeResponse::json(QJsonObject{{"id", "069a79f4-44e9-4726-a5be-fca90e38aaf5"}, {"name", "Notch"}});
});
}
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
routeHappyPath();
}
void fullChainWithCode()
{
MsaAuthService service;
QSignalSpy progress(&service, &MsaAuthService::progress);
Captured c;
const QDateTime before = QDateTime::currentDateTimeUtc();
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.playerName, QStringLiteral("Notch"));
QCOMPARE(c.result.uuid, QStringLiteral("069a79f444e94726a5befca90e38aaf5"));
QCOMPARE(c.result.accessToken, QStringLiteral("mc-token"));
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-2"));
QCOMPARE(c.result.xuid, QStringLiteral("2535400000000000"));
QCOMPARE(c.result.userType, QStringLiteral("msa"));
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
QVERIFY(!c.result.licenseMissing);
const qint64 lifetime = before.secsTo(c.result.expiresAt);
QVERIFY(lifetime > 3500 && lifetime <= 3600);
QVERIFY(progress.count() >= 5);
// Порядок шагов и формат запроса токена.
QCOMPARE(m_net.requestedUrls(), QStringList({kToken, kXbl, kXsts, kMcLogin, kEntitlements, kProfile}));
const FakeRequest tokenRequest = m_net.requests().constFirst();
QCOMPARE(tokenRequest.header("content-type"), QByteArray("application/x-www-form-urlencoded"));
QCOMPARE(tokenRequest.formItems().queryItemValue("client_id"), MsaAuthService::clientId());
QCOMPARE(jsonBody(m_net.requests().at(1)).value("RelyingParty").toString(), QStringLiteral("http://auth.xboxlive.com"));
QCOMPARE(jsonBody(m_net.requests().at(2)).value("RelyingParty").toString(), QStringLiteral("rp://api.minecraftservices.com/"));
QCOMPARE(jsonBody(m_net.requests().at(2)).value("Properties").toObject().value("SandboxId").toString(),
QStringLiteral("RETAIL"));
}
void refreshTokenLogin()
{
MsaAuthService service;
Captured c;
service.loginWithRefreshToken("ms-refresh-1", c.callback());
QVERIFY(wait(c));
QVERIFY2(c.result.ok, qPrintable(c.result.error));
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-3")); // новый refresh-токен сохраняется
QCOMPARE(m_net.requests().constFirst().formItems().queryItemValue("grant_type"), QStringLiteral("refresh_token"));
}
void emptyInputsFailImmediately()
{
MsaAuthService service;
Captured noCode;
service.loginWithCode(QString(), noCode.callback());
QCOMPARE(noCode.calls, 1);
QCOMPARE(noCode.result.error, Loc::text("auth.msa.error.noAuthCode"));
Captured noRefresh;
service.loginWithRefreshToken(QString(), noRefresh.callback());
QCOMPARE(noRefresh.calls, 1);
QCOMPARE(noRefresh.result.error, Loc::text("auth.msa.error.profileNotLinked"));
QVERIFY(m_net.requests().isEmpty());
}
void badCodeShowsMicrosoftDescription()
{
MsaAuthService service;
Captured c;
service.loginWithCode("bad-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, QStringLiteral("The provided value for the 'code' parameter is not valid."));
QCOMPARE(m_net.requests().size(), 1);
}
void xstsErrors_data()
{
QTest::addColumn<double>("xerr");
QTest::addColumn<QString>("message");
QTest::addColumn<QString>("expected");
QTest::newRow("no xbox profile") << 2148916233.0 << "" << Loc::text("auth.msa.error.noXboxProfile");
QTest::newRow("country") << 2148916235.0 << "" << Loc::text("auth.msa.error.countryBlocked");
QTest::newRow("age 36") << 2148916236.0 << "" << Loc::text("auth.msa.error.ageVerification");
QTest::newRow("age 37") << 2148916237.0 << "" << Loc::text("auth.msa.error.ageVerification");
QTest::newRow("child") << 2148916238.0 << "" << Loc::text("auth.msa.error.childAccount");
QTest::newRow("unknown with message") << 1.0 << "Something odd" << "Something odd";
QTest::newRow("unknown without message") << 7.0 << "" << Loc::text("auth.msa.error.xboxRejected").arg(7);
}
void xstsErrors()
{
QFETCH(double, xerr);
QFETCH(QString, message);
QFETCH(QString, expected);
m_net.route(kXsts, FakeResponse::json(QJsonObject{{"XErr", xerr}, {"Message", message}}, 401));
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, expected);
}
void stepFailures_data()
{
QTest::addColumn<QString>("url");
QTest::addColumn<int>("status");
QTest::addColumn<QByteArray>("body");
QTest::addColumn<QString>("expected");
QTest::newRow("token without access_token") << kToken << 200 << QByteArray("{}") << Loc::text("auth.msa.error.noAccessToken");
QTest::newRow("xbl rejected") << kXbl << 400 << QByteArray("{}") << Loc::text("auth.msa.error.xboxTokenRejected").arg(400);
QTest::newRow("xbl no token") << kXbl << 200 << QByteArray("{}") << Loc::text("auth.msa.error.xboxNoToken");
QTest::newRow("xsts 500") << kXsts << 500 << QByteArray("{}") << Loc::text("auth.msa.error.xstsRejected").arg(500);
QTest::newRow("xsts no user") << kXsts << 200 << QByteArray("{\"Token\":\"t\"}") << Loc::text("auth.msa.error.xstsNoUser");
QTest::newRow("services rejected") << kMcLogin << 429 << QByteArray("{}") << Loc::text("auth.msa.error.servicesRejected").arg(429);
QTest::newRow("services no token") << kMcLogin << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoToken");
QTest::newRow("profile 500") << kProfile << 500 << QByteArray("{}") << Loc::text("auth.msa.error.profileFetchFailed").arg(500);
QTest::newRow("profile empty") << kProfile << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoProfile");
}
void stepFailures()
{
QFETCH(QString, url);
QFETCH(int, status);
QFETCH(QByteArray, body);
QFETCH(QString, expected);
m_net.route(url, FakeResponse::json(body, status));
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(!c.result.ok);
QCOMPARE(c.result.error, expected);
}
void licenseChecks()
{
// Профиля нет, покупки тоже нет — нет лицензии.
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{}}}));
m_net.route(kProfile, FakeResponse::json("{}", 404));
MsaAuthService service;
Captured noLicense;
service.loginWithCode("good-code", noLicense.callback());
QVERIFY(wait(noLicense));
QVERIFY(!noLicense.result.ok);
QVERIFY(noLicense.result.licenseMissing);
QCOMPARE(noLicense.result.error, Loc::text("auth.msa.error.noLicence"));
// Покупка есть, но профиль (ник) ещё не создан.
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "x"}}}}}));
Captured noProfile;
service.loginWithCode("good-code", noProfile.callback());
QVERIFY(wait(noProfile));
QVERIFY(noProfile.result.licenseMissing);
QCOMPARE(noProfile.result.error, Loc::text("auth.msa.error.noProfileYet"));
}
void transportErrorAtMicrosoft()
{
FakeResponse drop;
drop.drop = true;
m_net.route(kToken, drop);
MsaAuthService service;
Captured c;
service.loginWithCode("good-code", c.callback());
QVERIFY(wait(c));
QVERIFY(c.result.error.startsWith(Loc::text("auth.msa.error.microsoftUnreachable").section(u'%', 0, 0)));
}
};
BILAUNCHER_TEST_MAIN(TestMsaAuthService)
#include "tst_msaauthservice.moc"
+197
View File
@@ -0,0 +1,197 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "seasonalbuildservice.h"
#include <QJsonArray>
#include <QSignalSpy>
namespace {
const QUrl kBase(QStringLiteral("https://launcher-builds.galeonworx.com"));
const QString kIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
}
class TestSeasonalBuildService : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void withoutBaseUrlFails()
{
SeasonalBuildService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("seasonal.error.noServerUrl"));
QCOMPARE(service.lastError(), Loc::text("seasonal.error.noServerUrl"));
QVERIFY(m_net.requests().isEmpty());
}
void parsesValidatesAndSorts()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
SeasonalBuildService service;
service.setBaseUrl(kBase);
QCOMPARE(service.baseUrl(), kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY2(r.ok, qPrintable(r.warning));
QStringList ids;
for (const SeasonalBuildEntry &entry : service.builds())
ids << entry.id;
// Открытые сезоны первыми (новые выше), закрытые — после; всё невалидное отброшено.
QCOMPARE(ids, QStringList({"season-5", "vanilla-plus", "season-4"}));
const auto s5 = service.find("season-5");
QVERIFY(s5.has_value());
QVERIFY(s5->isValid());
QCOMPARE(s5->name, QStringLiteral("Сезон 5: Пустоши"));
QCOMPARE(s5->revision, 3);
QCOMPARE(s5->minecraftVersion, QStringLiteral("1.21.1"));
QCOMPARE(s5->loader, QStringLiteral("fabric"));
QCOMPARE(s5->loaderVersion, QStringLiteral("0.16.10"));
QCOMPARE(s5->javaMajor, 21);
QCOMPARE(s5->modCount, 120);
QCOMPARE(s5->description, QStringLiteral("Пустоши")); // обрезаны пробелы
QCOMPARE(s5->seasonStart, QDate(2025, 7, 1));
QVERIFY(!s5->seasonEnd.isValid());
QCOMPARE(s5->archiveSize, qint64(123456));
QCOMPARE(s5->archiveSha256, QString("abcdef" + QString(58, u'0'))); // к нижнему регистру
QCOMPARE(s5->archiveUrl, QUrl("https://launcher-builds.galeonworx.com/packs/s5.zip"));
QCOMPARE(service.find("season-4")->seasonEnd, QDate(2025, 6, 1));
QVERIFY(service.find("vanilla-plus")->loader.isEmpty());
QVERIFY(QFile::exists(LauncherPaths::seasonalCatalogFile()));
}
void baseUrlPathIsKept()
{
m_net.route("https://launcher-builds.galeonworx.com/beta/index.json",
FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{}}}));
SeasonalBuildService service;
service.setBaseUrl(QUrl("https://launcher-builds.galeonworx.com/beta"));
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QCOMPARE(m_net.hits("https://launcher-builds.galeonworx.com/beta/index.json"), 1);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("seasonal.error.catalogEmpty"));
}
void formatVersionChecks_data()
{
QTest::addColumn<int>("format");
QTest::addColumn<QString>("error");
QTest::newRow("too new") << 2 << Loc::text("seasonal.error.formatTooNew").arg(2);
QTest::newRow("unknown") << 0 << Loc::text("seasonal.error.formatUnknown").arg(0);
}
void formatVersionChecks()
{
QFETCH(int, format);
QFETCH(QString, error);
QJsonObject catalog = Fixtures::readJson("seasonal/index.json");
catalog["formatVersion"] = format;
m_net.route(kIndex, FakeResponse::json(catalog));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, error);
QCOMPARE(service.lastError(), error);
}
void cacheIsBoundToBaseUrl()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
// Тот же адрес — свежий кеш, без сети.
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QCOMPARE(m_net.hits(kIndex), 1);
}
// Другой адрес — чужой кеш не подхватывается.
SeasonalBuildService other;
other.setBaseUrl(QUrl("https://mirror.example"));
m_net.route("https://mirror.example/index.json", FakeResponse::error(503));
CallResult r;
other.ensureLoaded(r.callback());
QVERIFY(!other.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QVERIFY(r.warning.startsWith(Loc::text("seasonal.error.catalogFetchFailed").section(u'%', 0, 0)));
}
void setBaseUrlResetsData()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(service.hasData());
QSignalSpy changed(&service, &SeasonalBuildService::buildsChanged);
service.setBaseUrl(kBase); // тот же адрес — ничего не происходит
QCOMPARE(changed.count(), 0);
service.setBaseUrl(QUrl("https://mirror.example"));
QCOMPARE(changed.count(), 1);
QVERIFY(!service.hasData());
QVERIFY(!service.find("season-5").has_value());
}
void offlineWithCacheKeepsBuilds()
{
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
{
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
m_net.route(kIndex, FakeResponse::error(502));
SeasonalBuildService service;
service.setBaseUrl(kBase);
CallResult r;
service.ensureLoaded(r.callback(), /*forceRefresh=*/true);
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok);
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.builds().size(), 3);
QCOMPARE(service.lastError(), r.warning);
}
};
BILAUNCHER_TEST_MAIN(TestSeasonalBuildService)
#include "tst_seasonalbuildservice.moc"
+139
View File
@@ -0,0 +1,139 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "localization.h"
#include "seasonalpackdownloader.h"
#include <QSignalSpy>
namespace {
const QString kPack = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-5-r3.zip");
}
class TestSeasonalPackDownloader : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_dir;
struct Run
{
explicit Run(SeasonalPackDownloader &d)
: started(&d, &SeasonalPackDownloader::started)
, finished(&d, &SeasonalPackDownloader::finished)
, failed(&d, &SeasonalPackDownloader::failed)
, canceled(&d, &SeasonalPackDownloader::canceled)
{}
bool waitEnd()
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 20000);
}
QSignalSpy started, finished, failed, canceled;
};
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
m_net.reset();
m_dir = TestEnv::freshDir(QStringLiteral("packs"));
}
void downloadsAndVerifies()
{
const QByteArray data(300 * 1024, 'p');
m_net.route(kPack, FakeResponse::bytes(data));
SeasonalPackDownloader downloader;
Run run(downloader);
const QString target = m_dir + "/sub/season-5.zip";
downloader.download(QUrl(kPack), target, Fixtures::sha256(data).toUpper(), data.size(), "Season 5");
QVERIFY(downloader.isRunning());
QCOMPARE(downloader.label(), QStringLiteral("Season 5"));
QCOMPARE(downloader.currentFile(), QStringLiteral("season-5.zip"));
QCOMPARE(downloader.bytesTotal(), qint64(data.size()));
QCOMPARE(run.started.count(), 1);
QVERIFY(run.waitEnd());
QCOMPARE(run.finished.count(), 1);
QCOMPARE(run.finished.constFirst().constFirst().toString(), target);
QCOMPARE(Fixtures::readFile(target), data);
QVERIFY(!downloader.isRunning());
QCOMPARE(downloader.fraction(), -1.0);
}
void checksumMismatchLeavesNoFile()
{
m_net.route(kPack, FakeResponse::bytes("tampered"));
SeasonalPackDownloader downloader;
Run run(downloader);
const QString target = m_dir + "/season.zip";
downloader.download(QUrl(kPack), target, QString(64, u'a'), 8, "S");
QVERIFY(run.waitEnd());
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("S"));
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.checksumMismatch"));
QVERIFY(!QFile::exists(target));
}
void httpErrorAndTruncation()
{
SeasonalPackDownloader downloader;
{
m_net.route(kPack, FakeResponse::error(404));
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/a.zip", QString(64, u'a'), 0, "A");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.constFirst().at(1).toString().startsWith(
Loc::text("seasonal.error.downloadFailed").section(u'%', 0, 0)));
}
{
FakeResponse cut = FakeResponse::bytes(QByteArray(4096, 'x'));
cut.truncateTo = 100;
m_net.route(kPack, cut);
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
QVERIFY(run.waitEnd());
QCOMPARE(run.failed.count(), 1);
QVERIFY(!QFile::exists(m_dir + "/b.zip"));
}
}
void cancelInFlight()
{
FakeResponse hang;
hang.hang = true;
m_net.route(kPack, hang);
SeasonalPackDownloader downloader;
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/c.zip", QString(64, u'a'), 10, "C");
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kPack), 1, 15000);
downloader.cancel();
QVERIFY(run.waitEnd());
QCOMPARE(run.canceled.count(), 1);
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("C"));
QVERIFY(!QFile::exists(m_dir + "/c.zip"));
QVERIFY(!downloader.isRunning());
downloader.cancel(); // повторная отмена без загрузки — ничего не делает
QCOMPARE(run.canceled.count(), 1);
}
void secondDownloadWhileBusyIsRejected()
{
FakeResponse slow = FakeResponse::bytes("x");
slow.delayMs = 500;
m_net.route(kPack, slow);
SeasonalPackDownloader downloader;
Run run(downloader);
downloader.download(QUrl(kPack), m_dir + "/d.zip", Fixtures::sha256("x"), 1, "D");
downloader.download(QUrl(kPack), m_dir + "/e.zip", Fixtures::sha256("x"), 1, "E");
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("E"));
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.downloadInProgress"));
QTRY_COMPARE_WITH_TIMEOUT(run.finished.count(), 1, 15000);
}
};
BILAUNCHER_TEST_MAIN(TestSeasonalPackDownloader)
#include "tst_seasonalpackdownloader.moc"
+383
View File
@@ -0,0 +1,383 @@
#include "testenv.h"
#include "fakemojang.h"
#include "fixtures.h"
#include "nettest.h"
#include "gamelauncher.h"
#include "localization.h"
#include "minecraftversion.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include <QDir>
#include <QSignalSpy>
class TestVersionInstaller : public QObject
{
Q_OBJECT
FakeInternet m_net;
QString m_game;
struct Run
{
explicit Run(VersionInstaller &installer)
: started(&installer, &VersionInstaller::started)
, finished(&installer, &VersionInstaller::finished)
, failed(&installer, &VersionInstaller::failed)
, canceled(&installer, &VersionInstaller::canceled)
, progress(&installer, &VersionInstaller::progressChanged)
{}
bool waitEnd(int expectedEvents = 1)
{
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() >= expectedEvents; },
30000);
}
QString failure() const { return failed.isEmpty() ? QString() : failed.constLast().at(1).toString(); }
QSignalSpy started, finished, failed, canceled, progress;
};
void verifyInstalled(const FakeMojang &mojang, const QString &id)
{
const auto expected = mojang.expectedFiles(id);
QVERIFY(!expected.isEmpty());
for (auto it = expected.cbegin(); it != expected.cend(); ++it) {
const QString path = m_game + u'/' + it.key();
QVERIFY2(QFile::exists(path), qPrintable("нет файла " + it.key()));
QCOMPARE(Fixtures::readFile(path), it.value());
}
}
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
m_game = TestEnv::freshDir(QStringLiteral("game"));
}
void installsModernVersionCompletely()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(installer.isRunning());
QCOMPARE(installer.versionId(), QStringLiteral("1.20.1"));
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(run.started.count(), 1);
QCOMPARE(run.finished.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
QVERIFY(!installer.isRunning());
QVERIFY(run.progress.count() > 0);
QVERIFY(m_net.unmatched().isEmpty());
verifyInstalled(mojang, "1.20.1");
// Результат пригоден для запуска: GameLauncher не видит недостающих файлов.
QString error;
const MinecraftVersion version = VersionLoader::load(m_game, "1.20.1", {}, &error);
QVERIFY2(version.isValid(), qPrintable(error));
LaunchOptions options;
options.gameDir = m_game;
QVERIFY(GameLauncher::missingFiles(options, version).isEmpty());
}
void secondInstallSkipsExistingFiles()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
{
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
}
const int firstRequests = m_net.requests().size();
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
// Повторно скачивается только json версии; файлы нужного размера пропускаются.
const int extra = m_net.requests().size() - firstRequests;
QVERIFY2(extra <= 1, qPrintable(QString::number(extra)));
}
void legacyAssetsAreCopiedToVirtual()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.5.2", .legacyAssets = true, .javaMajor = 8});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.5.2");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "1.5.2"); // включая assets/virtual/legacy/*
}
void inheritedVersionPullsParentFirst()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.publish({.id = "fabric-loader-0.16.10-1.20.1", .inheritsFrom = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "fabric-loader-0.16.10-1.20.1");
QVERIFY(run.waitEnd(2));
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
// started приходит на каждую версию, которую очередь берёт «с нуля».
QCOMPARE(run.started.constFirst().constFirst().toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
QStringList finishedIds;
for (const auto &call : run.finished)
finishedIds << call.constFirst().toString();
QCOMPARE(finishedIds, QStringList({"1.20.1", "fabric-loader-0.16.10-1.20.1"}));
verifyInstalled(mojang, "1.20.1");
verifyInstalled(mojang, "fabric-loader-0.16.10-1.20.1");
}
void localJsonUsedWhenNotInManifest()
{
// Версия не из манифеста (например, профиль модлоадера), но json уже лежит локально.
FakeMojang mojang(m_net);
mojang.publish({.id = "custom-1", .listed = false});
Fixtures::write(m_game + "/versions/custom-1/custom-1.json", mojang.versionJson("custom-1"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "custom-1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "custom-1");
QCOMPARE(m_net.hits(mojang.versionJsonUrl("custom-1")), 0);
}
void unknownVersionFails()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "9.9.9");
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("version.error.notInManifest").arg("9.9.9"));
}
void invalidNamesRejected_data()
{
QTest::addColumn<QString>("id");
QTest::newRow("traversal") << "../evil";
QTest::newRow("slash") << "a/b";
QTest::newRow("dot") << ".";
QTest::newRow("colon") << "c:x";
}
void invalidNamesRejected()
{
QFETCH(QString, id);
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, id);
QCOMPARE(run.failed.count(), 1);
QCOMPARE(run.failure(), Loc::text("version.error.invalidName").arg(id));
QVERIFY(m_net.requests().isEmpty());
installer.install(m_game, " "); // пустой id молча игнорируется
QCOMPARE(run.failed.count(), 1);
}
void versionJsonChecksumMismatch()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
m_net.route(mojang.versionJsonUrl("1.20.1"), FakeResponse::json("{\"tampered\":true}"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QCOMPARE(run.failure(), Loc::text("version.error.descriptionChecksum").arg("1.20.1"));
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
}
void transientErrorIsRetried()
{
// Сбой не на последнем файле: пока ждём повтора, остальные ещё качаются.
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
slowJar.delayMs = 1500;
mojang.overrideFile(jar, slowJar);
int attempts = 0;
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
return ++attempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes("brigadier");
});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(attempts, 2);
verifyInstalled(mojang, "1.20.1");
}
void retryOfLastFileIsNotLost()
{
// Сбой на последнем файле: остальные уже скачаны, повтор стоит в таймере.
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
int attempts = 0;
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
if (++attempts > 1)
return FakeResponse::bytes("brigadier");
FakeResponse late = FakeResponse::error(503);
late.delayMs = 1000; // все прочие файлы успевают докачаться
return late;
});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
// retryOrFail() откладывает повтор на 400 мс, а handleReplyFinished() тут же
// зовёт pump(): очередь и активные загрузки пусты — установка «завершается»
// и падает на проверке файлов, повтор так и не происходит.
QEXPECT_FAIL("", "повтор последнего файла теряется: pump() завершает установку раньше таймера", Abort);
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(attempts, 2);
}
void checksumMismatchRetriesThenFails()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
// client.jar держим долго, чтобы все повторы библиотеки шли при живой установке.
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
slowJar.delayMs = 4000;
mojang.overrideFile(jar, slowJar);
mojang.overrideFile(lib, FakeResponse::bytes("corrupted"));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failure().startsWith(Loc::text("common.error.downloadFailed")
.arg("com.mojang:brigadier:1.1.8", Loc::text("common.error.checksumMismatch"))),
qPrintable(run.failure()));
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 3); // kMaxAttempts
QVERIFY(!QFile::exists(m_game + u'/' + lib)); // битый файл не сохранён
}
void permanentErrorFailsImmediately()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
mojang.overrideFile(lib, FakeResponse::error(404));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(!run.failed.isEmpty());
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 1);
}
void cancelStopsDownloads()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
FakeResponse hang;
hang.hang = true;
mojang.overrideFile("versions/1.20.1/1.20.1.jar", hang);
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QTRY_VERIFY_WITH_TIMEOUT(m_net.hits(mojang.fileUrl("versions/1.20.1/1.20.1.jar")) == 1, 15000);
installer.cancel();
QVERIFY(run.waitEnd());
QCOMPARE(run.canceled.count(), 1);
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
QVERIFY(run.finished.isEmpty());
QVERIFY(!installer.isRunning());
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
}
void queueRunsSequentiallyAndDeduplicates()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
installer.install(m_game, "1.19.4");
installer.install(m_game, "1.19.4"); // дубль в очереди
installer.install(m_game, "1.20.1"); // уже ставится
QVERIFY(installer.isQueued("1.19.4"));
QVERIFY(!installer.isQueued("1.20.1"));
QVERIFY(run.waitEnd(2));
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
QCOMPARE(run.finished.count(), 2);
QCOMPARE(run.started.count(), 2);
verifyInstalled(mojang, "1.19.4");
}
void offlineWithLocalJsonStillInstalls()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", mojang.versionJson("1.20.1"));
m_net.route(FakeMojang::manifestUrl(), FakeResponse::error(503));
VersionManifestService manifest;
VersionInstaller installer(&manifest);
Run run(installer);
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
verifyInstalled(mojang, "1.20.1");
}
void fractionReportsProgress()
{
FakeMojang mojang(m_net);
mojang.publish({.id = "1.20.1"});
VersionManifestService manifest;
VersionInstaller installer(&manifest);
QCOMPARE(installer.fraction(), -1.0);
Run run(installer);
double maxFraction = -1;
connect(&installer, &VersionInstaller::progressChanged, this, [&] {
maxFraction = std::max(maxFraction, installer.fraction());
QVERIFY(installer.fraction() <= 1.0);
});
installer.install(m_game, "1.20.1");
QVERIFY(run.waitEnd());
QVERIFY(run.failed.isEmpty());
QVERIFY(maxFraction > 0.0);
}
};
BILAUNCHER_TEST_MAIN(TestVersionInstaller)
#include "tst_versioninstaller.moc"
+181
View File
@@ -0,0 +1,181 @@
#include "testenv.h"
#include "fixtures.h"
#include "nettest.h"
#include "launcherpaths.h"
#include "localization.h"
#include "versionmanifestservice.h"
#include <QJsonArray>
#include <QSignalSpy>
namespace {
const QString kManifest = QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
}
class TestVersionManifestService : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
void init()
{
TestEnv::resetLauncherRoot();
m_net.reset();
}
void loadsSortsAndFilters()
{
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
QSignalSpy changed(&service, &VersionManifestService::versionsChanged);
QSignalSpy refreshing(&service, &VersionManifestService::refreshingChanged);
CallResult result;
service.ensureLoaded(result.callback());
QVERIFY(service.isRefreshing());
QTRY_COMPARE_WITH_TIMEOUT(result.calls, 1, 15000);
QVERIFY(result.ok);
QVERIFY(result.warning.isEmpty());
QVERIFY(!service.isRefreshing());
QCOMPARE(changed.count(), 1);
QCOMPARE(refreshing.count(), 2);
// Новые первыми; без id и с http-ссылкой — выброшены.
QStringList ids;
for (const RemoteVersionEntry &entry : service.versions())
ids << entry.id;
QCOMPARE(ids, QStringList({"25w33a", "1.21.8", "1.20.1", "b1.7.3"}));
const auto entry = service.find("1.20.1");
QVERIFY(entry.has_value());
QCOMPARE(entry->type, QStringLiteral("release"));
QCOMPARE(entry->sha1, QStringLiteral("aaaa"));
QCOMPARE(entry->url, QUrl("https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json"));
QCOMPARE(entry->releaseTime, QDateTime(QDate(2023, 6, 12), QTime(13, 25, 51), QTimeZone::UTC));
QVERIFY(!service.find("insecure").has_value());
QVERIFY(service.fetchedAt().isValid());
// Кеш на диске записан.
const QJsonObject cache = Fixtures::readJsonFile(LauncherPaths::versionManifestFile());
QCOMPARE(cache.value("versions").toArray().size(), 4);
QVERIFY(!cache.value("fetchedAt").toString().isEmpty());
}
void freshCacheAvoidsNetwork()
{
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
{
VersionManifestService first;
CallResult r;
first.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
}
QCOMPARE(m_net.hits(kManifest), 1);
// Новый экземпляр (как после перезапуска лаунчера) берёт свежий кеш.
VersionManifestService second;
CallResult r;
second.ensureLoaded(r.callback());
QVERIFY(second.hasData()); // данные из кеша уже доступны синхронно
QCOMPARE(r.calls, 0); // колбэк — только через цикл событий
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
QVERIFY(r.ok);
QCOMPARE(m_net.hits(kManifest), 1);
// forceRefresh идёт в сеть даже при свежем кеше.
CallResult forced;
second.ensureLoaded(forced.callback(), true);
QTRY_COMPARE_WITH_TIMEOUT(forced.calls, 1, 15000);
QCOMPARE(m_net.hits(kManifest), 2);
}
void staleCacheIsRefreshed()
{
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QCOMPARE(m_net.hits(kManifest), 1);
}
void networkErrorWithoutCacheFails()
{
m_net.route(kManifest, FakeResponse::error(503));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QVERIFY(r.warning.startsWith(Loc::text("version.error.manifestFetchFailed").section(u'%', 0, 0)));
QVERIFY(!service.hasData());
}
void networkErrorWithCacheSucceedsWithWarning()
{
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
m_net.route(kManifest, FakeResponse::error(500));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok); // офлайн, но список есть
QVERIFY(!r.warning.isEmpty());
QCOMPARE(service.versions().size(), 4);
}
void garbageResponseIsRejected()
{
m_net.route(kManifest, FakeResponse::json("{\"versions\": []}"));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(!r.ok);
QCOMPARE(r.warning, Loc::text("version.error.manifestUnparsable"));
QVERIFY(!QFile::exists(LauncherPaths::versionManifestFile()));
}
void concurrentCallersShareOneRequest()
{
FakeResponse slow = FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json"));
slow.delayMs = 300;
m_net.route(kManifest, slow);
VersionManifestService service;
CallResult a, b, c;
service.ensureLoaded(a.callback());
service.ensureLoaded(b.callback());
service.ensureLoaded(c.callback());
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1 && c.calls == 1, 15000);
QVERIFY(a.ok && b.ok && c.ok);
QCOMPARE(m_net.hits(kManifest), 1);
}
void corruptCacheIsIgnored()
{
Fixtures::write(LauncherPaths::versionManifestFile(), "\x00\x01garbage");
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
VersionManifestService service;
CallResult r;
service.ensureLoaded(r.callback());
QVERIFY(!service.hasData());
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
QVERIFY(r.ok);
QVERIFY(service.hasData());
}
};
BILAUNCHER_TEST_MAIN(TestVersionManifestService)
#include "tst_versionmanifestservice.moc"
+8
View File
@@ -0,0 +1,8 @@
# QML: Qt Quick Test для компонентов и диалогов + дымовой тест главного окна.
bilauncher_add_test(tst_qml LABEL qml GUI TIMEOUT 180 SOURCES tst_qml.cpp)
target_compile_definitions(tst_qml PRIVATE QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}")
# Сами тесты лежат рядом, в tst_*.qml; Qt Creator покажет их в проекте.
target_sources(tst_qml PRIVATE tst_components.qml tst_dialogs.qml)
set_source_files_properties(tst_components.qml tst_dialogs.qml PROPERTIES HEADER_FILE_ONLY TRUE)
bilauncher_add_test(tst_qml_smoke LABEL qml GUI TIMEOUT 120 SOURCES tst_qml_smoke.cpp)
+155
View File
@@ -0,0 +1,155 @@
import QtQuick
import QtTest
import BiLauncher
// Маленькие переиспользуемые компоненты: DarkCombo, LabelledField,
// ProgressPanel, LoaderRow.
TestCase {
id: testCase
name: "Components"
width: 600
height: 400
visible: true
when: windowShown
Component { id: comboComponent; DarkCombo { width: 200; height: 32 } }
Component { id: fieldComponent; LabelledField { width: 220 } }
Component { id: panelComponent; ProgressPanel {} }
Component { id: loaderRowComponent; LoaderRow { width: 300 } }
// Подставной backend для LoaderRow: те же методы и сигнал, что у LauncherBackend.
Component {
id: fakeBackendComponent
QtObject {
property var versions: ({})
property bool loading: false
property var refreshCalls: []
signal loaderVersionsChanged(string loaderKey, string gameVersion)
function loaderVersions(key, game) { return versions[key + "/" + game] || [] }
function refreshLoaderVersions(key, game) { refreshCalls.push(key + "/" + game) }
function loaderVersionsLoading(key, game) { return loading }
}
}
// --- DarkCombo -------------------------------------------------------------
function test_comboSelectsAndDisplays() {
const combo = createTemporaryObject(comboComponent, testCase, { model: ["Alpha", "Beta", "Gamma"] })
verify(combo)
compare(combo.count, 3)
compare(combo.displayText, "Alpha")
const activated = signalSpy.createObject(testCase, { target: combo, signalName: "activated" })
mouseClick(combo)
tryVerify(() => combo.popup.opened)
combo.currentIndex = 2
compare(combo.displayText, "Gamma")
combo.popup.close()
tryVerify(() => !combo.popup.visible)
// Клавиатура: стрелки меняют выбор и шлют activated.
combo.forceActiveFocus()
keyClick(Qt.Key_Up)
compare(combo.currentIndex, 1)
compare(activated.count, 1)
verify(combo.indicator.source.toString().length > 0)
}
// --- LabelledField -------------------------------------------------------
function test_labelledFieldAliasesAndSignal() {
const field = createTemporaryObject(fieldComponent, testCase, { label: "Ник", placeholder: "Steve" })
verify(field)
compare(field.children[0].text, "Ник")
const input = field.children[1]
compare(input.placeholderText, "Steve")
const finished = signalSpy.createObject(testCase, { target: field, signalName: "editingFinished" })
input.forceActiveFocus()
keyClick(Qt.Key_A)
keyClick(Qt.Key_B)
compare(field.text, "ab")
keyClick(Qt.Key_Return)
compare(finished.count, 1)
field.text = "preset"
compare(input.text, "preset")
}
// --- ProgressPanel -------------------------------------------------------
function test_progressPanelPercentAndCancel() {
const panel = createTemporaryObject(panelComponent, testCase,
{ title: "1.20.1", status: "Libraries", fraction: -1 })
verify(panel)
const texts = panel.children.filter(c => c.text !== undefined)
const percent = texts.find(t => t.text === "…")
verify(percent, "без известного итога показывается многоточие")
panel.fraction = 0.426
compare(percent.text, "43%")
panel.fraction = 1
compare(percent.text, "100%")
// Подпись: detail важнее status.
const detailText = texts.find(t => t.text === "Libraries")
verify(detailText)
panel.detail = "brigadier-1.1.8.jar"
compare(detailText.text, "brigadier-1.1.8.jar")
const cancel = signalSpy.createObject(testCase, { target: panel, signalName: "cancelRequested" })
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1)
panel.cancellable = false
mouseClick(panel, panel.width - 14, panel.height - 12)
compare(cancel.count, 1, "крестик скрыт — отмены нет")
}
// --- LoaderRow -----------------------------------------------------------
function test_loaderRowFollowsBackend() {
const backend = createTemporaryObject(fakeBackendComponent, testCase)
backend.versions = {
"fabric/1.20.1": [ { version: "0.16.10", label: "0.16.10", recommended: false, stable: true },
{ version: "0.16.9", label: "0.16.9", recommended: false, stable: true } ]
}
const row = createTemporaryObject(loaderRowComponent, testCase,
{ backend: backend, loaderKey: "fabric", title: "Fabric Loader" })
verify(row)
const changed = signalSpy.createObject(testCase, { target: row, signalName: "changed" })
// Без версии игры галочка недоступна, список пуст.
compare(row.entries.length, 0)
row.gameVersion = "1.20.1"
compare(row.entries.length, 0, "пока галочка снята — версии не запрашиваются")
// Сборка уже с Fabric: выбранная версия сохраняется, запрос обновления уходит.
row.applyBuild("fabric", "0.16.9")
verify(row.checked)
compare(row.selectedVersion, "0.16.9")
compare(row.entries.length, 2)
verify(backend.refreshCalls.indexOf("fabric/1.20.1") >= 0)
compare(changed.count, 0, "применение сохранённой сборки не считается правкой")
// Каталог обновился и выбранной версии в нём больше нет — берётся первая.
backend.versions = { "fabric/1.20.1": [ { version: "0.17.0", label: "0.17.0", recommended: true, stable: true } ] }
backend.loaderVersionsChanged("fabric", "1.20.1")
compare(row.selectedVersion, "0.17.0")
compare(changed.count, 1)
// Чужой лоадер в сборке — галочка снимается.
row.applyBuild("forge", "47.4.0")
verify(!row.checked)
compare(row.selectedVersion, "")
compare(row.entries.length, 0)
// Смена версии игры сбрасывает выбор.
row.applyBuild("fabric", "0.17.0")
row.gameVersion = "1.19.4"
compare(row.selectedVersion, "")
}
Component { id: signalSpy; SignalSpy {} }
}
+119
View File
@@ -0,0 +1,119 @@
import QtQuick
import QtTest
import BiLauncher
// Диалоги с настоящим LauncherBackend. Данные для них подготавливает tst_qml.cpp:
// кеш манифеста версий (1.20.1, 25w33a, b1.7.3), установленная 1.20.1,
// кеш каталога сезонных сборок с одной сборкой. Сеть закрыта FakeInternet.
TestCase {
id: testCase
name: "Dialogs"
width: 1280
height: 720
visible: true
when: windowShown
LauncherBackend { id: launcherBackend }
Component { id: versionPickerComponent; VersionPickerDialog { backend: launcherBackend } }
Component { id: javaPickerComponent; JavaPickerDialog { backend: launcherBackend } }
Component { id: buildsComponent; BuildsDialog { backend: launcherBackend } }
Component { id: seasonalComponent; SeasonalBuildsDialog { backend: launcherBackend } }
Component { id: signalSpy; SignalSpy {} }
function openAndWait(dialog) {
tryVerify(() => dialog.opened, 3000, "диалог не открылся")
}
function test_versionPickerFiltersCatalog() {
const picker = createTemporaryObject(versionPickerComponent, testCase)
verify(picker)
tryVerify(() => launcherBackend.versionCatalog.length >= 3)
picker.openFor("1.20.1")
openAndWait(picker)
compare(picker.category, "release")
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "1.20.1")
verify(picker.visibleEntries[0].installed)
compare(picker.indexOfSelected(), 0)
picker.category = "snapshot"
compare(picker.visibleEntries.length, 1)
compare(picker.visibleEntries[0].id, "25w33a")
compare(picker.categoryOf("b1.7.3"), "old_beta")
compare(picker.categoryOf("no-such"), "release")
picker.category = "release"
picker.filterText = "1.19"
compare(picker.visibleEntries.length, 0)
picker.filterText = ""
picker.installedOnly = true
compare(picker.visibleEntries.length, 1)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "versionChosen" })
picker.selectedId = "1.20.1"
picker.acceptSelection()
compare(chosen.count, 1)
compare(chosen.signalArguments[0][0], "1.20.1")
tryVerify(() => !picker.visible)
}
function test_javaPickerOpensWithRequirement() {
const picker = createTemporaryObject(javaPickerComponent, testCase)
verify(picker)
picker.openFor("", 17)
openAndWait(picker)
compare(picker.requiredMajor, 17)
compare(picker.category, "java")
compare(picker.selectedEntry, null)
const chosen = signalSpy.createObject(testCase, { target: picker, signalName: "runtimeChosen" })
picker.acceptSelection() // ничего не выбрано — ничего не происходит
compare(chosen.count, 0)
picker.close()
}
function test_buildsDialogEditsBackend() {
const dialog = createTemporaryObject(buildsComponent, testCase)
verify(dialog)
const before = launcherBackend.customBuildNames.length
const name = dialog.newBuildName()
verify(name.length > 0)
verify(launcherBackend.customBuildNames.indexOf(name) === -1)
launcherBackend.addCustomBuild(name, "", "1.20.1")
tryVerify(() => !launcherBackend.busy, 20000)
compare(launcherBackend.customBuildNames.length, before + 1)
dialog.open()
openAndWait(dialog)
dialog.selectBuild(before)
compare(dialog.editIndex, before)
dialog.commit({ serverUrl: "play.example.com" })
compare(launcherBackend.customBuildAt(before).serverUrl, "play.example.com")
verify(dialog.newBuildName() !== name)
dialog.close()
}
function test_seasonalDialogShowsCatalog() {
const dialog = createTemporaryObject(seasonalComponent, testCase)
verify(dialog)
dialog.openCatalog()
openAndWait(dialog)
tryVerify(() => launcherBackend.seasonalCatalog.length === 1)
const entry = launcherBackend.seasonalCatalog[0]
compare(entry.id, "season-qml")
compare(dialog.cellText(entry, "loaderLabel"), entry.loaderTitle + " 0.16.10")
compare(dialog.formatMb(3 * 1024 * 1024), "3.0")
compare(dialog.formatMb(0), "")
dialog.close()
}
function test_languageSwitchUpdatesTexts() {
const en = Loc.t.app.tooltip.openMods
launcherBackend.updateSettings({ language: "ru" })
verify(Loc.t.app.tooltip.openMods !== en)
launcherBackend.updateSettings({ language: "en" })
compare(Loc.t.app.tooltip.openMods, en)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Qt Quick Test для QML-компонентов лаунчера.
//
// Модуль BiLauncher у приложения вшит в исполняемый файл и плагина не имеет,
// поэтому qmltestrunner его не импортирует. Здесь тот же модуль собран
// статической библиотекой (bilauncher_testlib) и подключён через
// Q_IMPORT_QML_PLUGIN — тесты пишут обычный «import BiLauncher».
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include <QDateTime>
#include <QDir>
#include <QJsonArray>
#include <QQmlEngine>
#include <QQuickStyle>
#include <QtQml/qqmlextensionplugin.h>
#include <QtQuickTest/quicktest.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
class Setup : public QObject
{
Q_OBJECT
public slots:
void applicationAvailable()
{
TestEnv::finishSetup();
QQuickStyle::setStyle(QStringLiteral("Basic"));
seedData();
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
}
void qmlEngineAvailable(QQmlEngine *engine)
{
engine->addImportPath(QStringLiteral("qrc:/qt/qml"));
}
private:
// Данные, на которых работают диалоги (см. tst_dialogs.qml).
static void seedData()
{
const QString game = TestEnv::freshDir(QStringLiteral("game"));
const QString now = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
const auto version = [](const QString &id, const QString &type, const QString &time) {
return QJsonObject{{"id", id}, {"type", type}, {"releaseTime", time}, {"sha1", "00"},
{"url", "https://piston-meta.mojang.com/v1/packages/x/" + id + ".json"}};
};
Fixtures::writeJson(LauncherPaths::versionManifestFile(), QJsonObject{
{"fetchedAt", now},
{"versions", QJsonArray{version("25w33a", "snapshot", "2025-08-13T12:00:00Z"),
version("1.20.1", "release", "2023-06-12T13:25:51Z"),
version("b1.7.3", "old_beta", "2011-07-08T00:00:00Z")}}});
Fixtures::write(game + "/versions/1.20.1/1.20.1.json",
"{\"id\":\"1.20.1\",\"mainClass\":\"net.minecraft.client.main.Main\"}");
const QJsonObject archive{{"url", "https://launcher-builds.galeonworx.com/packs/q.zip"},
{"size", 3 * 1024 * 1024}, {"sha256", QString(64, u'a')}};
const QJsonObject build{{"id", "season-qml"}, {"name", "QML Season"}, {"revision", 1},
{"minecraftVersion", "1.20.1"}, {"loader", "fabric"},
{"loaderVersion", "0.16.10"}, {"seasonStart", "2026-01-01"},
{"archive", archive}};
Fixtures::writeJson(LauncherPaths::seasonalCatalogFile(), QJsonObject{
{"formatVersion", 1}, {"fetchedAt", now},
{"baseUrl", "https://launcher-builds.galeonworx.com"}, {"builds", QJsonArray{build}}});
}
FakeInternet m_net;
};
int main(int argc, char **argv)
{
TestEnv::prepareSandbox();
QTEST_SET_MAIN_SOURCE_PATH
Setup setup;
return quick_test_main_with_setup(argc, argv, "bilauncher_qml", QUICK_TEST_SOURCE_DIR, &setup);
}
#include "tst_qml.moc"
+109
View File
@@ -0,0 +1,109 @@
// Дымовой тест главного окна: Main.qml поднимается так же, как в main.cpp,
// без единого предупреждения QML, и ни одного запроса в настоящую сеть.
// Ловит то, что не видно компилятору: опечатки в именах свойств и ключах Loc.t,
// сломанные биндинги, несуществующие картинки в ресурсах.
#include "testenv.h"
#include "fakeinternet.h"
#include "fixtures.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QtQml/qqmlextensionplugin.h>
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
namespace {
QStringList g_problems;
QtMessageHandler g_previous = nullptr;
void collect(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
// Предупреждения QML приходят с файлом qrc:/… или с категорией qt.qml.*.
const QString file = QString::fromUtf8(context.file ? context.file : "");
const QString category = QString::fromUtf8(context.category ? context.category : "");
const bool fromQml = file.startsWith(QLatin1String("qrc:")) || file.endsWith(QLatin1String(".qml"))
|| category.startsWith(QLatin1String("qt.qml"))
|| message.contains(QLatin1String(".qml:"));
if (type != QtDebugMsg && type != QtInfoMsg && fromQml)
g_problems << message;
if (g_previous)
g_previous(type, context, message);
}
} // namespace
class TestQmlSmoke : public QObject
{
Q_OBJECT
FakeInternet m_net;
private slots:
void initTestCase()
{
QString error;
if (m_net.start(&error))
m_net.setFallback(FakeResponse::error(503));
const QString game = TestEnv::freshDir(QStringLiteral("game"));
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
QQuickStyle::setStyle(QStringLiteral("Basic"));
g_previous = qInstallMessageHandler(collect);
}
void cleanupTestCase() { qInstallMessageHandler(g_previous); }
void init() { g_problems.clear(); }
void mainWindowLoadsCleanly()
{
// Один движок на весь тест, как в приложении: Localization держит указатель
// на JS-движок, создавший синглтон, и второй QQmlApplicationEngine после
// уничтожения первого оставил бы его висячим.
QQmlApplicationEngine engine;
QStringList warnings;
connect(&engine, &QQmlEngine::warnings, this, [&](const QList<QQmlError> &list) {
for (const QQmlError &e : list)
warnings << e.toString();
});
engine.loadFromModule("BiLauncher", "Main");
QCOMPARE(engine.rootObjects().size(), 1);
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
QVERIFY(window);
QVERIFY(QTest::qWaitForWindowExposed(window));
// Даём отработать отложенным таймерам, загрузке каталогов и анимациям.
QTest::qWait(2500);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Смена языка на лету перестраивает Loc.t — биндинги не должны ломаться.
const QString titleEn = window->title();
Localization::instance().setLanguage(QStringLiteral("ru"));
QTest::qWait(500);
Localization::instance().setLanguage(QStringLiteral("en"));
QTest::qWait(300);
QCOMPARE(window->title(), titleEn);
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
// Наружу — только через FakeInternet, и только на известные адреса лаунчера.
static const QStringList known{"mojang.com", "galeonworx.com", "adoptium.net", "fabricmc.net",
"quiltmc.org", "minecraftforge.net", "neoforged.net"};
for (const QString &url : m_net.requestedUrls()) {
const QString host = QUrl(url).host();
const bool ok = std::any_of(known.cbegin(), known.cend(),
[&](const QString &domain) { return host.endsWith(domain); });
QVERIFY2(ok, qPrintable("неожиданный запрос: " + url));
}
}
};
BILAUNCHER_GUI_TEST_MAIN(TestQmlSmoke)
#include "tst_qml_smoke.moc"
+4
View File
@@ -0,0 +1,4 @@
53
# Число предупреждений qmllint по модулю BiLauncher, которое считается нормой.
# tests/scripts/static_checks.py падает, если их стало больше. Исправили часть —
# опустите число, чтобы улучшение закрепилось.
+69
View File
@@ -0,0 +1,69 @@
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
#
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
#
# Проверяет, что windeployqt положил всё нужное (DLL Qt, плагины платформ и QML),
# лаунчер запускается без окна и через 10 секунд ещё жив, а в логе нет ошибок
# загрузки модулей.
#
# QDir::homePath() на Windows берёт профиль у системы и USERPROFILE не читает,
# поэтому лаунчер запускается с BILAUNCHER_HOME на временную папку (см.
# launcherpaths.h) — настоящая %APPDATA%\BiLauncher раннера не трогается.
param(
[Parameter(Mandatory = $true)][string]$Prefix,
[int]$RunSeconds = 10
)
$ErrorActionPreference = "Stop"
function Fail($message) { Write-Host "SMOKE FAIL: $message"; exit 1 }
$bin = Join-Path $Prefix "bin\BiLauncher.exe"
if (-not (Test-Path $bin)) { Fail "нет $bin" }
foreach ($required in @("bin\Qt6Core.dll", "bin\Qt6Quick.dll", "bin\Qt6Network.dll",
"bin\platforms\qwindows.dll", "bin\qml\QtQuick\Controls")) {
if (-not (Test-Path (Join-Path $Prefix $required))) { Fail "нет $required" }
}
if (-not (Get-ChildItem (Join-Path $Prefix "bin\tls") -Filter "*.dll" -ErrorAction SilentlyContinue)) {
Fail "нет TLS-плагина (bin\tls) — лаунчер не сможет ходить по https"
}
$realProfile = Join-Path $env:APPDATA "BiLauncher"
$realExisted = Test-Path $realProfile
$sandbox = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N"))
$log = Join-Path $sandbox "log"
New-Item -ItemType Directory -Force $log | Out-Null
$stdout = Join-Path $log "stdout.log"
$stderr = Join-Path $log "stderr.log"
$profileRoot = Join-Path $sandbox "AppData\Roaming\BiLauncher"
$env:BILAUNCHER_HOME = $sandbox
$env:QT_QPA_PLATFORM = "offscreen"
$env:QT_FORCE_STDERR_LOGGING = "1"
$env:QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu"
try {
$process = Start-Process -FilePath $bin -PassThru -NoNewWindow `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
Start-Sleep -Seconds $RunSeconds
$alive = -not $process.HasExited
if ($alive) { Stop-Process -Id $process.Id -Force; $process.WaitForExit(5000) | Out-Null }
$text = ""
if (Test-Path $stderr) { $text += Get-Content $stderr -Raw }
if (Test-Path $stdout) { $text += Get-Content $stdout -Raw }
Write-Host "----- лог запуска -----"
Write-Host $text
if (-not $alive) { Fail "лаунчер завершился раньше чем через $RunSeconds с (код $($process.ExitCode))" }
if ($text -match "is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed") {
Fail "в логе ошибки загрузки модулей"
}
if (-not (Test-Path (Join-Path $profileRoot "settings.json"))) { Fail "лаунчер не создал $profileRoot\settings.json" }
if (-not $realExisted -and (Test-Path $realProfile)) { Fail "лаунчер создал настоящую $realProfile мимо BILAUNCHER_HOME" }
Write-Host "SMOKE OK: $bin"
}
finally {
Remove-Item -Recurse -Force $sandbox -ErrorAction SilentlyContinue
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Дымовая проверка установленного дерева (результат cmake --install) на Linux и macOS.
#
# tests/scripts/smoke_installed.sh <prefix>
#
# Linux: <prefix>/bin/BiLauncher (+ lib/BiLauncher/libz.so.1, плагины Qt рядом)
# macOS: <prefix>/BiLauncher.app
#
# Проверяет, что деплой Qt положил всё нужное: бинарник не ссылается на
# библиотеки вне дерева (ldd/otool), запускается без окна и через 10 секунд
# ещё жив, а в логе нет «module is not installed», «Cannot load library» и т. п.
# Запуск идёт с подменённым HOME — настоящие данные лаунчера на машине не трогаются.
set -euo pipefail
PREFIX="$(cd "${1:?нужен путь к установленному дереву}" && pwd)"
RUN_SECONDS="${RUN_SECONDS:-10}"
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
case "$(uname -s)" in
Darwin)
APP="$PREFIX/BiLauncher.app"
BIN="$APP/Contents/MacOS/BiLauncher"
[ -x "$BIN" ] || fail "нет $BIN"
[ -f "$APP/Contents/Resources/icon.icns" ] || fail "нет иконки в бандле"
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist" >/dev/null \
|| fail "в Info.plist нет версии"
# Ссылки на Qt должны вести внутрь бандла, а не в папку Qt сборочной машины.
if otool -L "$BIN" | tail -n +2 | grep -E '/Qt/|/Users/|/home/' ; then
fail "бинарник ссылается на библиотеки вне бандла"
fi
[ -d "$APP/Contents/Frameworks/QtQuick.framework" ] || fail "нет QtQuick.framework в бандле"
[ -d "$APP/Contents/PlugIns/platforms" ] || fail "нет плагинов платформ"
# Деплой идёт с -hardened-runtime: без подписи dyld откажется грузить фреймворки
# («different Team IDs»). Подписываем ad-hoc ровно как release.yml перед упаковкой.
codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || fail "ad-hoc подпись не удалась"
codesign --verify --deep --strict "$APP" || fail "подпись бандла не проходит проверку"
;;
Linux)
BIN="$PREFIX/bin/BiLauncher"
[ -x "$BIN" ] || fail "нет $BIN"
[ -f "$PREFIX/lib/BiLauncher/libz.so.1" ] || fail "нет эталонного zlib (lib/BiLauncher/libz.so.1)"
[ -f "$PREFIX/share/applications/bilauncher.desktop" ] || fail "нет .desktop-файла"
[ -f "$PREFIX/share/icons/hicolor/256x256/apps/bilauncher.png" ] || fail "нет иконки hicolor"
[ -f "$PREFIX/share/doc/BiLauncher/LICENSE" ] || fail "нет LICENSE в share/doc"
# Все разделяемые библиотеки — бинарника, Qt и плагинов — должны находиться.
missing=0
while IFS= read -r -d '' file; do
if ldd "$file" 2>/dev/null | grep -q 'not found'; then
echo "не хватает библиотек для $file:" >&2
ldd "$file" | grep 'not found' >&2
missing=1
fi
done < <(find "$PREFIX" \( -name '*.so*' -o -path '*/bin/BiLauncher' \) -type f -print0)
[ "$missing" = 0 ] || fail "ldd: есть ненайденные зависимости"
;;
*) fail "неизвестная система $(uname -s)" ;;
esac
# Платформа Qt. Проверяем то, что реально задеплоено: macdeployqt кладёт только
# cocoa — на macOS окно на 10 секунд действительно откроется (раннеру нужна
# GUI-сессия). На Linux берём offscreen, если деплой его положил, иначе xcb
# под xvfb-run (пакет xvfb, см. ci/setup-runner-linux.sh).
LAUNCH=()
PLATFORM=""
if [ "$(uname -s)" = Linux ]; then
if find "$PREFIX" -name 'libqoffscreen.so' | grep -q .; then
PLATFORM=offscreen
elif command -v xvfb-run >/dev/null; then
LAUNCH=(xvfb-run -a)
elif [ -z "${DISPLAY:-}" ]; then
fail "нет ни плагина offscreen в деплое, ни xvfb-run, ни DISPLAY"
fi
fi
SANDBOX="$(mktemp -d)"
LOG="$SANDBOX/stderr.log"
trap 'rm -rf "$SANDBOX"' EXIT
env HOME="$SANDBOX" \
${PLATFORM:+QT_QPA_PLATFORM=$PLATFORM} \
QT_FORCE_STDERR_LOGGING=1 \
QTWEBENGINE_DISABLE_SANDBOX=1 \
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
${LAUNCH[@]+"${LAUNCH[@]}"} "$BIN" >"$LOG" 2>&1 &
PID=$!
sleep "$RUN_SECONDS"
if ! kill -0 "$PID" 2>/dev/null; then
wait "$PID" || true
echo "----- лог -----" >&2; cat "$LOG" >&2
fail "лаунчер завершился раньше чем через ${RUN_SECONDS} с"
fi
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
echo "----- лог запуска -----"; cat "$LOG"
if grep -E 'is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed|module ".*" plugin ".*" not found|Каталог переводов' "$LOG"; then
fail "в логе ошибки загрузки модулей"
fi
# Лаунчер создал свою папку в подменённом HOME, а не где-то ещё.
if [ "$(uname -s)" = Darwin ]; then ROOT="$SANDBOX/Library/Application Support/BiLauncher"; else ROOT="$SANDBOX/BiLauncher"; fi
[ -f "$ROOT/settings.json" ] || fail "лаунчер не создал $ROOT/settings.json"
echo "SMOKE OK: $BIN"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Статические проверки без запуска лаунчера. Ненулевой код возврата при ошибке.
Запуск: python3 tests/scripts/static_checks.py <папка сборки с BILAUNCHER_BUILD_TESTS=ON>
1. tools/check_translations.py — каталог переводов цел и совпадает с кодом.
2. qmllint по модулю BiLauncher (цель BiLauncher_qmllint, её создаёт qt_add_qml_module).
В коде уже есть предупреждения, а код ради тестов не правится, поэтому
проверка работает «храповиком»: падает, только если предупреждений стало
больше, чем записано в tests/qmllint-baseline.txt. Стало меньше — просит
опустить планку, чтобы достигнутое не откатилось.
3. CHANGELOG.md содержит раздел для версии из project(BiLauncher VERSION …):
без него release.yml создаст релиз с пустым описанием (только предупреждение).
"""
import os, re, subprocess, sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASELINE = os.path.join(ROOT, "tests", "qmllint-baseline.txt")
def step(title):
print(f"\n=== {title}", flush=True)
def check_translations():
step("Каталог переводов")
return subprocess.call([sys.executable, os.path.join(ROOT, "tools", "check_translations.py")]) == 0
def check_qmllint(build_dir):
step("qmllint (BiLauncher_qmllint)")
cmake = os.environ.get("CMAKE", "cmake")
proc = subprocess.run([cmake, "--build", build_dir, "--target", "BiLauncher_qmllint"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding="utf-8", errors="replace")
output = proc.stdout
with open(os.path.join(build_dir, "qmllint.log"), "w", encoding="utf-8") as log:
log.write(output)
warnings = [line for line in output.splitlines() if line.startswith("Warning:")]
errors = [line for line in output.splitlines() if line.startswith("Error:")]
for line in warnings + errors:
print(line)
baseline = int(open(BASELINE, encoding="utf-8").read().split()[0])
print(f"\nпредупреждений: {len(warnings)}, ошибок: {len(errors)}, планка: {baseline}"
f" (полный вывод — {os.path.join(build_dir, 'qmllint.log')})")
if proc.returncode != 0 and not warnings and not errors:
print("qmllint не запустился — см. лог")
return False
if errors:
print("ОШИБКА: qmllint нашёл ошибки")
return False
if len(warnings) > baseline:
print(f"ОШИБКА: предупреждений стало больше ({len(warnings)} > {baseline}). "
"Исправьте новые или, если они осознанные, поднимите tests/qmllint-baseline.txt.")
return False
if len(warnings) < baseline:
print(f"Хорошо: предупреждений меньше планки — опустите tests/qmllint-baseline.txt до {len(warnings)}.")
return True
def check_changelog():
step("CHANGELOG.md и версия проекта")
cmake_text = open(os.path.join(ROOT, "CMakeLists.txt"), encoding="utf-8").read()
match = re.search(r"^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+)", cmake_text, re.M)
if not match:
print("ОШИБКА: не найден project(BiLauncher VERSION x.y.z)")
return False
version = match.group(1)
changelog = open(os.path.join(ROOT, "CHANGELOG.md"), encoding="utf-8").read()
if re.search(r"^## \[" + re.escape(version) + r"\]", changelog, re.M):
print(f"версия {version}: раздел в CHANGELOG.md есть")
else:
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
return True
def main():
if len(sys.argv) != 2:
print(__doc__)
return 2
build_dir = os.path.abspath(sys.argv[1])
results = [check_translations(), check_qmllint(build_dir), check_changelog()]
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())
+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"
+454
View File
@@ -0,0 +1,454 @@
#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"},
// Служебные файлы LWJGL рядом с .so: не должны приниматься
// за версионированную библиотеку (libfoo.so.1) ни на одной ОС.
{QStringLiteral("skip/liblwjgl.so.sha1"), "hash"},
{QStringLiteral("skip/liblwjgl.so.git"), "git"}});
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"
+119
View File
@@ -0,0 +1,119 @@
#include "testenv.h"
#include "launcherpaths.h"
#include "localization.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QScopeGuard>
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()
{
// Не QDir::homePath(): на Windows он смотрит в настоящий профиль, а пути
// лаунчера в тестах идут от BILAUNCHER_HOME (песочницы).
const QString home = QDir::cleanPath(TestEnv::homeDir());
#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 homeOverrideFromEnvironment()
{
const QString saved = qEnvironmentVariable("BILAUNCHER_HOME");
const auto restore = qScopeGuard([&] { TestEnv::setEnvPath("BILAUNCHER_HOME", saved); });
QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(TestEnv::homeDir()));
const QString other = TestEnv::freshDir("other-home");
TestEnv::setEnvPath("BILAUNCHER_HOME", QDir::toNativeSeparators(other));
QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(other));
QVERIFY(LauncherPaths::rootDir().startsWith(QDir::cleanPath(other) + u'/'));
// Относительный путь зависел бы от текущей папки — игнорируется, как и пустой.
for (const char *ignored : {"relative/home", ""}) {
qputenv("BILAUNCHER_HOME", ignored);
QVERIFY2(LauncherPaths::homeOverride().isEmpty(), ignored);
}
}
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"