diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..eac2605 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -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' + 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' + 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 diff --git a/.gitignore b/.gitignore index eaa5594..b7e02fc 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ build/ CMakeLists.txt.user* stage/ dist/ +build-tests/ +build-*/ diff --git a/CMakeLists.txt b/CMakeLists.txt index d0fdea7..ca7ab5f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) \ No newline at end of file +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() diff --git a/ci/setup-runner-linux.sh b/ci/setup-runner-linux.sh index 43c2133..0f00e7e 100755 --- a/ci/setup-runner-linux.sh +++ b/ci/setup-runner-linux.sh @@ -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 \ diff --git a/doc/build-and-release.md b/doc/build-and-release.md index 2b2762d..8d11fa8 100644 --- a/doc/build-and-release.md +++ b/doc/build-and-release.md @@ -98,6 +98,9 @@ QML-модуль собирается в `/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). + --- При создании этого документа использовался ИИ. diff --git a/doc/index.md b/doc/index.md index f2683e2..50bf00b 100644 --- a/doc/index.md +++ b/doc/index.md @@ -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 | --- diff --git a/doc/testing.md b/doc/testing.md new file mode 100644 index 0000000..a55aa3c --- /dev/null +++ b/doc/testing.md @@ -0,0 +1,169 @@ +# Автотесты + +Тесты лежат в `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` строит пути от `QDir::homePath()`. Общий `main` тестов +(`tests/common/testenv.h`) до создания приложения подменяет `HOME` на временную папку, и каждый +процесс работает в своей. Если путь лаунчера всё же оказался вне песочницы, процесс сразу +завершается. На Windows `QDir::homePath()` переменные окружения не читает, поэтому там тесты идут +в настоящей `%APPDATA%\BiLauncher` учётной записи раннера, строго по одному, а за собой папку +стирают. Если папка уже есть и создана не тестами (нет метки `.bilauncher-test-sandbox`), тесты +отказываются запускаться. + +**Сеть.** Адреса серверов в сервисах зашиты константами, а свои `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`). Такие данные выносите в фикстуры или собирайте по шагам. + +--- + +При создании этого документа использовался ИИ. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..1bca796 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,130 @@ +# Автотесты 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_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) + # На Windows все тесты делят настоящую %APPDATA%\BiLauncher (см. common/testenv.h). + set_tests_properties(${name} PROPERTIES RESOURCE_LOCK launcher_profile) + # DLL Qt — из установки Qt, без windeployqt для каждого теста. + set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() +endfunction() + +add_subdirectory(unit) +add_subdirectory(net) +add_subdirectory(backend) +add_subdirectory(qml) diff --git a/tests/backend/CMakeLists.txt b/tests/backend/CMakeLists.txt new file mode 100644 index 0000000..b329569 --- /dev/null +++ b/tests/backend/CMakeLists.txt @@ -0,0 +1,2 @@ +# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним. +bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp) diff --git a/tests/backend/tst_launcherbackend.cpp b/tests/backend/tst_launcherbackend.cpp new file mode 100644 index 0000000..a149ddc --- /dev/null +++ b/tests/backend/tst_launcherbackend.cpp @@ -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 +#include +#include +#include +#include + +#include + +namespace { + +const QString kSeasonalIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json"); +const QString kElyAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate"); +const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json"); +const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector.jar"); + +bool waitFor(const std::function &condition, int ms = 30000) +{ + return QTest::qWaitFor(condition, ms); +} + +QStringList strings(const QSignalSpy &spy, int arg = 0) +{ + QStringList out; + for (const QList &call : spy) + out << call.value(arg).toString(); + return out; +} + +} // namespace + +class TestLauncherBackend : public QObject +{ + Q_OBJECT + + FakeInternet m_net; + QString m_game; + + void writeSettings(const QJsonObject &extra = {}) + { + QJsonObject settings{{"gameDir", m_game}, {"language", "en"}}; + for (auto it = extra.begin(); it != extra.end(); ++it) + settings.insert(it.key(), it.value()); + Fixtures::writeJson(LauncherPaths::settingsFile(), settings); + } + + static std::unique_ptr make() { return std::make_unique(); } + + // Ждём, пока backend освободится от фоновых операций. + static bool waitIdle(LauncherBackend &backend, int ms = 30000) + { + return waitFor([&] { return !backend.busy() && !backend.downloading() && !backend.switching(); }, ms); + } + + // Установить версию через backend и дождаться результата. + static bool installVersion(LauncherBackend &backend, const QString &id) + { + backend.installVersion(id); + return waitFor([&] { return backend.isVersionInstalled(id) && !backend.downloading(); }); + } + + QString fakeJava(const QString &name, const QStringList &config = {}) + { + return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config); + } + +private slots: + void initTestCase() + { + BILAUNCHER_START_FAKE_INTERNET(m_net); + } + + void init() + { + TestEnv::resetLauncherRoot(); + m_net.reset(); + m_net.setFallback(FakeResponse::error(503)); + m_game = TestEnv::freshDir(QStringLiteral("game")); + writeSettings(); + Localization::instance().setLanguage(QStringLiteral("en")); + } + + // --- данные и настройки ---------------------------------------------------- + + void freshStartCreatesDataFiles() + { + QFile::remove(LauncherPaths::settingsFile()); + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + QTest::qWait(50); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QVERIFY(QFile::exists(LauncherPaths::settingsFile())); + QVERIFY(QFile::exists(LauncherPaths::profilesFile())); + QVERIFY(QFile::exists(LauncherPaths::customBuildsFile())); + const QVariantMap s = backend->settings(); + QCOMPARE(s.value("maxMemoryMb").toInt(), 4096); + QCOMPARE(s.value("minMemoryMb").toInt(), 512); + QCOMPARE(s.value("language").toString(), QStringLiteral("system")); + QCOMPARE(s.value("resolvedGameDir").toString(), QDir::toNativeSeparators(LauncherPaths::defaultMinecraftDir())); + QVERIFY(backend->profileNames().isEmpty()); + QCOMPARE(backend->activeBuildIndex(), -1); + QVERIFY(!backend->busy()); + QVERIFY(!backend->microsoftAvailable()); // тестовая сборка — без WebEngine + Localization::instance().setLanguage(QStringLiteral("en")); + } + + void profilesCrudAndPersistence() + { + { + auto backend = make(); + QSignalSpy changed(backend.get(), &LauncherBackend::profilesChanged); + backend->addProfile("Steve", "steve", QString()); + backend->addProfile("Ely", "ely@example.com", "pw", "elyby"); + backend->addProfile("Odd", "odd", QString(), "something-unknown"); + QCOMPARE(changed.count(), 3); + QCOMPARE(backend->profileNames(), QStringList({"Steve", "Ely", "Odd"})); + QCOMPARE(backend->profileAt(1).value("authType").toString(), QStringLiteral("elyby")); + QCOMPARE(backend->profileAt(2).value("authType").toString(), QStringLiteral("offline")); + QVERIFY(backend->profileAt(9).isEmpty()); + + backend->updateProfile(0, "Steve2", "steve2", QString(), "offline"); + backend->removeProfile(2); + backend->removeProfile(42); // мимо — без последствий + QCOMPARE(backend->profileNames(), QStringList({"Steve2", "Ely"})); + } + const QJsonArray stored = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array(); + QCOMPARE(stored.size(), 2); + QVERIFY(!stored.at(0).toObject().value("clientToken").toString().isEmpty()); + QVERIFY(stored.at(0).toObject().value("id").toInt() != stored.at(1).toObject().value("id").toInt()); + + auto reloaded = make(); + QCOMPARE(reloaded->profileNames(), QStringList({"Steve2", "Ely"})); + QCOMPARE(reloaded->profileAt(1).value("login").toString(), QStringLiteral("ely@example.com")); + } + + void elyTokenDroppedWhenCredentialsChange() + { + Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ + {"id", 1}, {"name", "Ely"}, {"login", "a"}, {"password", "p"}, {"authType", "elyby"}, + {"clientToken", "c"}, {"accessToken", "tok"}, {"uuid", "u"}, {"resolvedName", "Nick"}}}).toJson()); + auto backend = make(); + backend->updateProfile(0, "Ely renamed", "a", "p", "elyby"); // только имя — токен живёт + QCOMPARE(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())) + .array().at(0).toObject().value("accessToken").toString(), QStringLiteral("tok")); + backend->updateProfile(0, "Ely renamed", "a", "new-password", "elyby"); + const QJsonObject p = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject(); + QVERIFY(p.value("accessToken").toString().isEmpty()); + QVERIFY(p.value("resolvedName").toString().isEmpty()); + QCOMPARE(p.value("clientToken").toString(), QStringLiteral("c")); + } + + void customBuildsAndActiveSwitch() + { + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->addCustomBuild("Alpha", "play.example.com"); + backend->addCustomBuild("Beta", QString()); + QCOMPARE(backend->customBuildNames(), QStringList({"Alpha", "Beta"})); + QCOMPARE(backend->activeBuildIndex(), 0); // настройка пуста — активна первая + QCOMPARE(backend->activeBuildName(), QStringLiteral("Alpha")); + QVERIFY(!backend->downloading()); // без версии игры ставить нечего + + // Правка версии сбрасывает выбранную под старую версию сборку модлоадера. + backend->updateCustomBuild(0, {{"minecraftVersion", "1.20.1"}, {"loader", "fabric"}, + {"loaderVersion", "0.16.10"}, {"resolvedVersionId", "fabric-loader-0.16.10-1.20.1"}}); + QCOMPARE(backend->customBuildAt(0).value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1")); + backend->updateCustomBuild(0, {{"minecraftVersion", "1.21.1"}}); + QVariantMap alpha = backend->customBuildAt(0); + QCOMPARE(alpha.value("loader").toString(), QStringLiteral("fabric")); + QVERIFY(alpha.value("loaderVersion").toString().isEmpty()); + QVERIFY(alpha.value("resolvedVersionId").toString().isEmpty()); + QCOMPARE(alpha.value("launchVersionId").toString(), QStringLiteral("1.21.1")); + QCOMPARE(backend->customBuildAt(1).value("launchVersionId").toString(), QStringLiteral("Beta")); // по имени + QCOMPARE(backend->requiredJavaMajor(0), 21); + QCOMPARE(backend->requiredJavaMajor(5), 0); + + // Переключение активной сборки уносит содержимое .minecraft в архив. + Fixtures::write(m_game + "/mods/alpha.jar", "a"); + QSignalSpy active(backend.get(), &LauncherBackend::activeBuildChanged); + backend->setActiveBuildIndex(1); + QVERIFY(waitIdle(*backend)); + QCOMPARE(backend->activeBuildIndex(), 1); + QVERIFY(active.count() > 0); + QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar")); + QCOMPARE(backend->settings().value("activeBuildId").toInt(), Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt()); + + const QVariantMap info = backend->customBuildRemovalInfo(0); + QCOMPARE(info.value("name").toString(), QStringLiteral("Alpha")); + QVERIFY(info.value("hasArchive").toBool()); + QVERIFY(!info.value("active").toBool()); + + // Удаление активной — активной становится первая из оставшихся, её архив разворачивается. + backend->removeCustomBuild(1); + QVERIFY(waitIdle(*backend)); + QCOMPARE(backend->customBuildNames(), QStringList{"Alpha"}); + QCOMPARE(backend->activeBuildIndex(), 0); + QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("a")); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + } + + void settingsPersistAndGameDirChange() + { + Fixtures::writeJson(LauncherPaths::settingsFile(), + QJsonObject{{"gameDir", m_game}, {"language", "en"}, + {"launcherName", "old"}, {"launcherVersion", "1.0"}}); + const QString other = TestEnv::freshDir(QStringLiteral("other-game")); + { + auto backend = make(); + QSignalSpy installedChanged(backend.get(), &LauncherBackend::installedVersionsChanged); + QSignalSpy settingsChanged(backend.get(), &LauncherBackend::settingsChanged); + backend->updateSettings({{"maxMemoryMb", 2048}, {"jvmArgs", "-XX:+UseZGC"}}); + QCOMPARE(installedChanged.count(), 0); + backend->updateSettings({{"gameDir", other}}); + QCOMPARE(installedChanged.count(), 1); + QCOMPARE(settingsChanged.count(), 2); + QCOMPARE(backend->settings().value("resolvedGameDir").toString(), QDir::toNativeSeparators(other)); + } + const QJsonObject stored = Fixtures::readJsonFile(LauncherPaths::settingsFile()); + QCOMPARE(stored.value("maxMemoryMb").toInt(), 2048); + QVERIFY(!stored.contains("launcherName")); // устаревшие ключи вычищены + QVERIFY(!stored.contains("launcherVersion")); + auto reloaded = make(); + QCOMPARE(reloaded->settings().value("jvmArgs").toString(), QStringLiteral("-XX:+UseZGC")); + } + + void languageSettingSwitchesLocalization() + { + auto backend = make(); + backend->updateSettings({{"language", "ru"}}); + QCOMPARE(Localization::instance().language(), QStringLiteral("ru")); + backend->updateSettings({{"language", "en"}}); + QCOMPARE(Localization::instance().language(), QStringLiteral("en")); + } + + void legacyBuildsFileIsMigrated() + { + Fixtures::write(LauncherPaths::legacyCustomBuildsFile(), QJsonDocument(QJsonArray{ + QJsonObject{{"id", 3}, {"name", "Old build"}, {"serverUrl", "mc.example.com"}, {"versionId", "1.12.2"}}}).toJson()); + auto backend = make(); + QCOMPARE(backend->customBuildNames(), QStringList{"Old build"}); + QCOMPARE(backend->customBuildAt(0).value("minecraftVersion").toString(), QStringLiteral("1.12.2")); + QVERIFY(QFile::exists(LauncherPaths::customBuildsFile())); + QVERIFY(!QFile::exists(LauncherPaths::legacyCustomBuildsFile())); + QVERIFY(QFile::exists(LauncherPaths::legacyCustomBuildsFile() + ".migrated")); + // Новые сборки получают id после старых. + backend->addCustomBuild("New", QString()); + const QJsonArray builds = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).array(); + QCOMPARE(builds.at(1).toObject().value("id").toInt(), 4); + } + + void corruptFilesAreSetAsideAndReported() + { + Fixtures::write(LauncherPaths::profilesFile(), "{ this is not json"); + Fixtures::write(LauncherPaths::customBuildsFile(), "{\"object\": \"not an array\"}"); + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + QVERIFY(errors.wait(2000)); // сообщение откладывается до подключения интерфейса + const QString message = errors.constFirst().constFirst().toString(); + QVERIFY(message.contains(QDir::toNativeSeparators(LauncherPaths::profilesFile() + ".bak"))); + QVERIFY(QFile::exists(LauncherPaths::profilesFile() + ".bak")); + QVERIFY(QFile::exists(LauncherPaths::customBuildsFile() + ".bak")); + // Свежие файлы созданы заново и валидны. + QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).isArray()); + QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).isArray()); + QVERIFY(backend->profileNames().isEmpty()); + } + + void unusableBuildRecordsAreSkipped() + { + Fixtures::write(LauncherPaths::customBuildsFile(), QJsonDocument(QJsonArray{ + QJsonObject{{"id", 1}, {"name", " "}}, QJsonObject{{"id", 2}, {"name", "Good"}}}).toJson()); + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + QVERIFY(errors.wait(2000)); + QCOMPARE(backend->customBuildNames(), QStringList{"Good"}); + } + + // --- версии ------------------------------------------------------------------ + + void versionCatalogInstallAndRemove() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"}); + Fixtures::write(m_game + "/versions/my-pack/my-pack.json", "{\"mainClass\":\"x\"}"); + + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->refreshVersionCatalog(); + QVERIFY(waitFor([&] { return backend->versionCatalog().size() == 3; })); + const QVariantList catalog = backend->versionCatalog(); + QCOMPARE(catalog.at(0).toMap().value("id").toString(), QStringLiteral("1.20.1")); + QCOMPARE(catalog.at(0).toMap().value("category").toString(), QStringLiteral("release")); + QVERIFY(!catalog.at(0).toMap().value("installed").toBool()); + QCOMPARE(catalog.at(2).toMap().value("id").toString(), QStringLiteral("my-pack")); // неизвестная манифесту + QCOMPARE(catalog.at(2).toMap().value("category").toString(), QStringLiteral("other")); + + QSignalSpy installed(backend.get(), &LauncherBackend::installedVersionsChanged); + QVERIFY(installVersion(*backend, "1.20.1")); + QVERIFY(installed.count() > 0); + QVERIFY(backend->installedVersions().contains("1.20.1")); + QVERIFY(backend->versionCatalog().at(0).toMap().value("installed").toBool()); + + backend->addCustomBuild("Uses 1.20.1", QString(), "1.20.1"); + const QVariantMap info = backend->versionRemovalInfo("1.20.1"); + QVERIFY(info.value("installed").toBool()); + QVERIFY(info.value("sizeMb").toInt() >= 1); + QCOMPARE(info.value("builds").toStringList(), QStringList{"Uses 1.20.1"}); + + backend->removeVersion("1.20.1"); + QVERIFY(!backend->isVersionInstalled("1.20.1")); + QVERIFY(!QDir(m_game + "/versions/1.20.1").exists()); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + } + + void addingBuildWithVersionStartsDownload() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + auto backend = make(); + backend->addCustomBuild("Auto", QString(), "1.20.1"); + QVERIFY(backend->downloading()); + QCOMPARE(backend->downloadVersion(), QStringLiteral("1.20.1")); + QVERIFY(waitFor([&] { return backend->isVersionInstalled("1.20.1") && !backend->downloading(); })); + QVERIFY(backend->checkInstallation(0).isEmpty()); + } + + void checkInstallationMessages() + { + auto backend = make(); + QCOMPARE(backend->checkInstallation(0), QStringList{Loc::text("common.status.noBuildSelected")}); + backend->addCustomBuild("Missing", QString()); + QStringList issues = backend->checkInstallation(0); + QCOMPARE(issues.size(), 1); + QVERIFY(!issues.constFirst().isEmpty()); + } + + void downloadFailureIsReported() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + mojang.overrideFile("versions/1.20.1/1.20.1.jar", FakeResponse::error(404)); + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->installVersion("1.20.1"); + QVERIFY(errors.wait(30000)); + QVERIFY(errors.constFirst().constFirst().toString().startsWith( + Loc::text("version.error.downloadFailed").arg("1.20.1", QString()).section(u'%', 0, 0))); + QVERIFY(waitIdle(*backend)); + } + + // --- запуск -------------------------------------------------------------- + + void launchValidationErrors() + { + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->launchGame(0, 0); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noProfile")); + backend->addProfile("Steve", "steve", QString()); + backend->launchGame(0, 0); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noBuild")); + backend->addCustomBuild("B", QString()); + backend->launchGame(0, 0); + QCOMPARE(errors.constLast().constFirst().toString(), + Loc::text("launch.error.noVersionsInstalled").arg(QDir::toNativeSeparators(m_game + "/versions"))); + QVERIFY(!backend->busy()); + } + + void offlineLaunchRunsJava() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + const QString argsFile = m_game + "/../game-args.txt"; + const QString java = fakeJava("jdk-launch", {"args_file=" + argsFile, "stdout=[Render thread/INFO]: Setting user: Steve"}); + writeSettings({{"javaPath", java}, {"maxMemoryMb", 3072}, {"jvmArgs", "-Dtest.flag=1"}}); + + auto backend = make(); + QVERIFY(installVersion(*backend, "1.20.1")); + backend->addProfile("Steve", "steve", QString()); + backend->addCustomBuild("Server build", "play.example.com:25570", "1.20.1"); + QVERIFY(waitIdle(*backend)); + + QSignalSpy launched(backend.get(), &LauncherBackend::launched); + QSignalSpy finished(backend.get(), &LauncherBackend::gameFinished); + QSignalSpy output(backend.get(), &LauncherBackend::gameOutput); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->launchGame(0, 0); + QVERIFY2(waitFor([&] { return launched.count() + errors.count() > 0; }), "нет ни launched, ни launchError"); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QCOMPARE(launched.constFirst().at(0).toString(), QStringLiteral("Steve")); + QCOMPARE(launched.constFirst().at(1).toString(), QStringLiteral("Server build")); + QVERIFY(waitFor([&] { return finished.count() == 1; })); + QVERIFY(!backend->gameRunning()); + QVERIFY(!backend->busy()); + QVERIFY(strings(output).contains("[Render thread/INFO]: Setting user: Steve")); + + const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); + QVERIFY(args.contains("-Xmx3072M")); + QVERIFY(args.contains("-Dtest.flag=1")); + QVERIFY(args.contains("net.minecraft.client.main.Main")); + const qsizetype server = args.indexOf("--server"); + QVERIFY(server > 0); + QCOMPARE(args.at(server + 1), QStringLiteral("play.example.com")); + QCOMPARE(args.at(server + 3), QStringLiteral("25570")); + } + + void elyLaunchWithTwoFactor() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}})); + m_net.route(kInjectorJar, FakeResponse::bytes("injector")); + m_net.route(kElyAuth, [](const FakeRequest &request) { + const QJsonObject in = QJsonDocument::fromJson(request.body).object(); + if (in.value("password").toString() == QLatin1String("pw:654321")) + return FakeResponse::json(QJsonObject{{"accessToken", "ely-token"}, + {"selectedProfile", QJsonObject{{"id", "aaaa-bbbb"}, {"name", "ElyNick"}}}}); + return FakeResponse::json(QJsonObject{{"errorMessage", "Account protected with two factor auth."}}, 401); + }); + const QString argsFile = m_game + "/../ely-args.txt"; + writeSettings({{"javaPath", fakeJava("jdk-ely", {"args_file=" + argsFile})}}); + + auto backend = make(); + QVERIFY(installVersion(*backend, "1.20.1")); + backend->addProfile("Ely", "ely@example.com", "pw", "elyby"); + backend->addCustomBuild("B", QString(), "1.20.1"); + QVERIFY(waitIdle(*backend)); + + QSignalSpy twoFactor(backend.get(), &LauncherBackend::twoFactorRequired); + QSignalSpy launched(backend.get(), &LauncherBackend::launched); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->launchGame(0, 0); + QVERIFY(waitFor([&] { return twoFactor.count() + errors.count() > 0; })); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QCOMPARE(twoFactor.constFirst().constFirst().toString(), QStringLiteral("Ely")); + QVERIFY(!backend->busy()); // ждём ввода кода + + backend->submitTwoFactorCode(" "); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.ely.error.enterCode")); + errors.clear(); + backend->submitTwoFactorCode("654321"); + QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; })); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + + QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000); + QTRY_VERIFY_WITH_TIMEOUT(!backend->gameRunning(), 15000); + const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); + QVERIFY(args.constFirst().startsWith("-javaagent:")); + QVERIFY(args.constFirst().endsWith("=ely.by")); + QVERIFY(args.contains("ElyNick")); + QVERIFY(args.contains("ely-token")); + // Токен и ник запомнены — в следующий раз пароль не понадобится. + const QJsonObject profile = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject(); + QCOMPARE(profile.value("accessToken").toString(), QStringLiteral("ely-token")); + QCOMPARE(profile.value("resolvedName").toString(), QStringLiteral("ElyNick")); + QCOMPARE(profile.value("uuid").toString(), QStringLiteral("aaaabbbb")); + } + + void microsoftProfileWithFreshTokenLaunchesOffline() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + const QString argsFile = m_game + "/../ms-args.txt"; + writeSettings({{"javaPath", fakeJava("jdk-ms", {"args_file=" + argsFile})}}); + Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ + {"id", 1}, {"name", "MS"}, {"authType", "microsoft"}, {"clientToken", "c"}, + {"accessToken", "mc-token"}, {"refreshToken", "r"}, {"uuid", "069a79f444e94726a5befca90e38aaf5"}, + {"resolvedName", "Notch"}, {"xuid", "253"}, {"userType", "msa"}, + {"tokenExpiry", QDateTime::currentDateTimeUtc().addSecs(3600).toString(Qt::ISODate)}}}).toJson()); + + auto backend = make(); + QVERIFY(installVersion(*backend, "1.20.1")); + backend->addCustomBuild("B", QString(), "1.20.1"); + QVERIFY(waitIdle(*backend)); + const int requestsBefore = m_net.requests().size(); + + QSignalSpy launched(backend.get(), &LauncherBackend::launched); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->launchGame(0, 0); + QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; })); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QCOMPARE(m_net.requests().size(), requestsBefore); // токен жив — в Microsoft не ходили + QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000); + const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts); + QVERIFY(args.contains("Notch")); + QVERIFY(args.contains("mc-token")); + } + + void microsoftProfileWithoutSessionAsksRelogin() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{ + {"id", 1}, {"name", "MS"}, {"authType", "microsoft"}}}).toJson()); + auto backend = make(); + QVERIFY(installVersion(*backend, "1.20.1")); + backend->addCustomBuild("B", QString(), "1.20.1"); + QVERIFY(waitIdle(*backend)); + + QSignalSpy relogin(backend.get(), &LauncherBackend::microsoftReloginRequired); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->launchGame(0, 0); + QCOMPARE(relogin.count(), 1); + QCOMPARE(relogin.constFirst().constFirst().toInt(), 0); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.msa.error.profileNeedsLogin").arg("MS")); + QVERIFY(!backend->busy()); + } + + void microsoftLoginNeedsWebEngine() + { + auto backend = make(); + QSignalSpy failed(backend.get(), &LauncherBackend::microsoftLoginFailed); + backend->startMicrosoftLogin(-1); + QCOMPARE(failed.count(), 1); + QCOMPARE(failed.constFirst().constFirst().toString(), Loc::text("auth.msa.error.noWebEngine")); + + const QVariantMap redirect = backend->inspectMicrosoftRedirect( + "https://login.live.com/oauth20_desktop.srf?code=abc"); + QVERIFY(redirect.value("matched").toBool()); + QCOMPARE(redirect.value("code").toString(), QStringLiteral("abc")); + QVERIFY(!backend->inspectMicrosoftRedirect("https://example.com/").value("matched").toBool()); + } + + // --- Java ---------------------------------------------------------------- + + void javaCatalogInstallSelectRemove() + { + const QByteArray zip = Fixtures::zipBytes({{QStringLiteral("jdk-21/bin/") + Fixtures::javaExecutableName(), "bin"}}); + m_net.route("https://api.adoptium.net/v3/info/available_releases", + FakeResponse::json(QJsonObject{{"available_lts_releases", QJsonArray{21}}, {"available_releases", QJsonArray{21}}})); + m_net.route("https://api.adoptium.net/v3/assets/feature_releases/*", [zip](const FakeRequest &request) { + const QString arch = request.queryItems().queryItemValue("architecture"); + const QJsonObject binary{ + {"architecture", arch}, {"image_type", "jdk"}, + {"package", QJsonObject{{"name", "jdk.zip"}, {"link", "https://github.com/adoptium/x/jdk.zip"}, + {"checksum", Fixtures::sha256(zip)}, {"size", zip.size()}}}}; + const QJsonArray releases{QJsonObject{ + {"release_name", "jdk-21.0.2+13"}, {"timestamp", "2025-01-20T00:00:00Z"}, + {"binaries", QJsonArray{binary}}, + {"version_data", QJsonObject{{"major", 21}, {"openjdk_version", "21.0.2+13-LTS"}}}}}; + return FakeResponse::json(QJsonDocument(releases).toJson()); + }); + m_net.route("https://github.com/adoptium/x/jdk.zip", FakeResponse::bytes(zip)); + + auto backend = make(); + QSignalSpy installedSpy(backend.get(), &LauncherBackend::javaRuntimeInstalled); + backend->refreshJavaCatalog(); + QVERIFY(waitFor([&] { return !backend->javaCatalog().isEmpty() && !backend->javaCatalogLoading(); })); + const QVariantMap row = backend->javaCatalog().constFirst().toMap(); + QCOMPARE(row.value("major").toInt(), 21); + QVERIFY(row.value("lts").toBool()); + QVERIFY(!row.value("installed").toBool()); + const QString id = row.value("id").toString(); + QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool()); + + backend->installJavaRuntime(id); + QVERIFY(waitFor([&] { return installedSpy.count() == 1; })); + QCOMPARE(installedSpy.constFirst().constFirst().toString(), id); + QVERIFY(backend->javaRuntimeInfo(id).value("installed").toBool()); + QVERIFY(backend->javaCatalog().constFirst().toMap().value("installed").toBool()); + + // Выбранная сборка Java попадает в настройки, а при удалении настройка очищается. + backend->updateSettings({{"javaRuntime", id}}); + const QString javaPath = backend->javaRuntimeInfo(id).value("javaPath").toString(); + QVERIFY(backend->detectedJava().join('\n').contains(javaPath)); + backend->removeJavaRuntime(id); + QVERIFY(backend->settings().value("javaRuntime").toString().isEmpty()); + QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool()); + + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->installJavaRuntime("no-such-runtime"); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("java.error.buildNotInCatalog").arg("no-such-runtime")); + } + + // --- сезонные сборки -------------------------------------------------------- + + void seasonalBuildEndToEnd() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + + // Java 17 уже скачана лаунчером — шаг Java пропускается. + InstalledJavaRuntime runtime; + runtime.id = QStringLiteral("temurin-jdk-17-test"); + runtime.kind = QStringLiteral("jdk"); + runtime.major = 17; + runtime.version = QStringLiteral("17.0.8"); + runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8"); + QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr)); + + const auto publishSeason = [&](int revision, const QMap &files) { + const QByteArray pack = Fixtures::zipBytes(files); + const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-6-r%1.zip").arg(revision); + m_net.route(url, FakeResponse::bytes(pack)); + const QJsonObject entry{ + {"id", "season-6"}, {"name", "Season 6"}, {"revision", revision}, {"minecraftVersion", "1.20.1"}, + {"loader", ""}, {"loaderVersion", ""}, {"modCount", files.size()}, {"seasonStart", "2026-01-01"}, + {"serverUrl", "season6.example.com"}, {"javaMajor", 17}, + {"archive", QJsonObject{{"url", url}, {"size", pack.size()}, {"sha256", Fixtures::sha256(pack)}}}}; + m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}})); + }; + publishSeason(1, {{"mods/a.jar", "a1"}, {"mods/old.jar", "old"}, {"config/c.toml", "c=1"}}); + + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + QSignalSpy done(backend.get(), &LauncherBackend::seasonalInstallFinished); + backend->refreshSeasonalCatalog(); + QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; })); + QVariantMap row = backend->seasonalCatalog().constFirst().toMap(); + QCOMPARE(row.value("status").toString(), Loc::text("seasonal.status.notInstalled")); + QCOMPARE(row.value("loaderTitle").toString(), Loc::text("loader.label.vanilla")); + QCOMPARE(row.value("seasonStart").toString(), QStringLiteral("01.01.2026")); + + backend->installSeasonalBuild("season-6"); + QVERIFY(backend->seasonalInstalling()); + QVERIFY(backend->busy()); + QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000)); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QCOMPARE(done.constFirst().at(0).toString(), QStringLiteral("season-6")); + QVERIFY(!backend->seasonalInstalling()); + QVERIFY(waitIdle(*backend)); + + // Сборка заведена, активна, версия игры стоит, файлы пака разложены, ревизия записана. + QCOMPARE(backend->customBuildNames(), QStringList{"Season 6"}); + QCOMPARE(backend->activeBuildIndex(), 0); + QVERIFY(backend->checkInstallation(0).isEmpty()); + QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a1")); + QCOMPARE(Fixtures::readFile(m_game + "/config/c.toml"), QByteArray("c=1")); + const QVariantMap build = backend->customBuildAt(0); + QCOMPARE(build.value("seasonalId").toString(), QStringLiteral("season-6")); + QCOMPARE(build.value("seasonalRevision").toInt(), 1); + QCOMPARE(build.value("serverUrl").toString(), QStringLiteral("season6.example.com")); + const int buildId = Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(); + const QJsonObject state = Fixtures::readJsonFile(LauncherPaths::seasonalStateFile(buildId)); + QCOMPARE(state.value("revision").toInt(), 1); + QCOMPARE(state.value("entries").toArray().size(), 3); + QCOMPARE(backend->seasonalCatalog().constFirst().toMap().value("status").toString(), + Loc::text("seasonal.status.installed")); + + // Ревизия 2: мод old.jar из сборки убрали, a.jar обновили. Пользовательский мир не трогаем. + Fixtures::write(m_game + "/saves/MyWorld/level.dat", "mine"); + publishSeason(2, {{"mods/a.jar", "a2"}, {"config/c.toml", "c=2"}}); + backend->refreshSeasonalCatalog(true); + QVERIFY(waitFor([&] { return backend->seasonalCatalog().constFirst().toMap().value("updateAvailable").toBool(); })); + done.clear(); + backend->installSeasonalBuild("season-6"); + QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000)); + QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n'))); + QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a2")); + QVERIFY(!QFile::exists(m_game + "/mods/old.jar")); + QCOMPARE(Fixtures::readFile(m_game + "/saves/MyWorld/level.dat"), QByteArray("mine")); + QCOMPARE(backend->customBuildNames().size(), 1); // та же запись, а не вторая + QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 2); + // Служебный файл пака за собой не оставляем. + QVERIFY(QDir(LauncherPaths::buildDir(buildId)).entryList({".pack-*"}, QDir::Files | QDir::Hidden).isEmpty()); + } + + void seasonalPackChecksumFailureAborts() + { + FakeMojang mojang(m_net); + mojang.publish({.id = "1.20.1"}); + InstalledJavaRuntime runtime; + runtime.id = QStringLiteral("temurin-jdk-17-test2"); + runtime.kind = QStringLiteral("jdk"); + runtime.major = 17; + runtime.version = QStringLiteral("17.0.8"); + runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8"); + QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr)); + + const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/bad.zip"); + m_net.route(url, FakeResponse::bytes("not what was promised")); + const QJsonObject entry{ + {"id", "bad-season"}, {"name", "Bad"}, {"revision", 1}, {"minecraftVersion", "1.20.1"}, + {"loader", ""}, {"javaMajor", 17}, + {"archive", QJsonObject{{"url", url}, {"size", 21}, {"sha256", QString(64, u'0')}}}}; + m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}})); + + auto backend = make(); + QSignalSpy errors(backend.get(), &LauncherBackend::launchError); + backend->refreshSeasonalCatalog(); + QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; })); + backend->installSeasonalBuild("bad-season"); + QVERIFY(waitFor([&] { return !backend->seasonalInstalling(); }, 60000)); + const QString all = strings(errors).join('\n'); + QVERIFY2(all.contains(Loc::text("seasonal.error.checksumMismatch")), qPrintable(all)); + QVERIFY(!backend->busy()); + QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 0); // не отмечена установленной + + backend->installSeasonalBuild("unknown-id"); + QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("seasonal.error.notInCatalog").arg("unknown-id")); + } +}; + +BILAUNCHER_GUI_TEST_MAIN(TestLauncherBackend) +#include "tst_launcherbackend.moc" diff --git a/tests/common/fakeinternet.cpp b/tests/common/fakeinternet.cpp new file mode 100644 index 0000000..a429039 --- /dev/null +++ b/tests/common/fakeinternet.cpp @@ -0,0 +1,410 @@ +#include "fakeinternet.h" + +#include "fixtures.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 &) { + 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 lines = connection->buffer.left(headerEnd).split('\n'); + const QList 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 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 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(); +} diff --git a/tests/common/fakeinternet.h b/tests/common/fakeinternet.h new file mode 100644 index 0000000..6e7f401 --- /dev/null +++ b/tests/common/fakeinternet.h @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class QSslSocket; +class QTcpSocket; + +struct FakeRequest +{ + QByteArray method; + QString scheme; // https | http + QString host; + QString path; + QString query; // без «?» + QHash 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> 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; + + 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 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 socket, const FakeResponse &response, bool keepAlive); + void startTls(Connection *connection); + const Route *match(const FakeRequest &request) const; + + QList m_routes; + FakeResponse m_fallback; + QList m_requests; + QStringList m_unmatched; + QByteArray m_certificate; + QByteArray m_key; + bool m_started = false; +}; diff --git a/tests/common/fakemojang.cpp b/tests/common/fakemojang.cpp new file mode 100644 index 0000000..0d0993b --- /dev/null +++ b/tests/common/fakemojang.cpp @@ -0,0 +1,194 @@ +#include "fakemojang.h" + +#include "fixtures.h" + +#include "minecraftversion.h" + +#include +#include + +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 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 = ""; + 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 FakeMojang::expectedFiles(const QString &id) const +{ + return m_expected.value(id); +} diff --git a/tests/common/fakemojang.h b/tests/common/fakemojang.h new file mode 100644 index 0000000..fb644f1 --- /dev/null +++ b/tests/common/fakemojang.h @@ -0,0 +1,64 @@ +#pragma once + +// Миниатюрный Mojang для FakeInternet: манифест версий, json версий, client.jar, +// библиотеки (в т.ч. натив под текущую ОС), индекс ассетов, сами ассеты и +// конфиг логирования. Содержимое крошечное, но устроено как настоящее — +// VersionInstaller и GameLauncher работают с ним без поблажек. + +#include "fakeinternet.h" + +#include +#include +#include +#include +#include + +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 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 m_specs; + QHash m_versionJson; // id → json + QHash> m_expected; // id → (путь → данные) + QHash m_files; // относительный путь → файл +}; diff --git a/tests/common/fixtures.cpp b/tests/common/fixtures.cpp new file mode 100644 index 0000000..97d5132 --- /dev/null +++ b/tests/common/fixtures.cpp @@ -0,0 +1,204 @@ +#include "fixtures.h" + +#include "minecraftversion.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 &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 &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 &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; +} diff --git a/tests/common/fixtures.h b/tests/common/fixtures.h new file mode 100644 index 0000000..056fc03 --- /dev/null +++ b/tests/common/fixtures.h @@ -0,0 +1,60 @@ +#pragma once + +// Фикстуры и мелкие помощники для подготовки файлов в песочнице. + +#include +#include +#include +#include +#include +#include + +namespace Fixtures { + +// Абсолютный путь к tests/fixtures/. +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 &entries); +QByteArray zipBytes(const QMap &entries); +// Список файлов в zip — как его видит QZipReader (ведущие «.» и «/» он срезает). +QStringList zipEntries(const QString &zipPath); + +// tar.gz через системный tar (есть и на Windows 10+, и на Unix). +bool makeTarGz(const QString &archivePath, const QMap &entries); + +QString sha1(const QByteArray &data); +QString sha256(const QByteArray &data); + +// Все файлы под папкой (относительные пути, «/» в качестве разделителя), отсортированы. +QStringList listTree(const QString &dir); + +// Подставная java (см. tests/fake_java). Копирует бинарник в /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 diff --git a/tests/common/nettest.h b/tests/common/nettest.h new file mode 100644 index 0000000..63b2918 --- /dev/null +++ b/tests/common/nettest.h @@ -0,0 +1,39 @@ +#pragma once + +// Помощники для тестов сетевых сервисов. + +#include "fakeinternet.h" + +#include +#include + +// В 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; + }; + } +}; diff --git a/tests/common/testenv.cpp b/tests/common/testenv.cpp new file mode 100644 index 0000000..cc1929b --- /dev/null +++ b/tests/common/testenv.cpp @@ -0,0 +1,97 @@ +#include "testenv.h" + +#include "launcherpaths.h" +#include "localization.h" + +#include +#include +#include + +#include +#include + +namespace { + +const char kMarker[] = ".bilauncher-test-sandbox"; + +QTemporaryDir *g_sandbox = nullptr; +bool g_realProfile = false; // Windows: работаем в настоящем профиле под меткой + +[[noreturn]] void abortSetup(const QString &message) +{ + std::fprintf(stderr, "TestEnv: %s\n", qPrintable(message)); + std::exit(2); +} + +} // namespace + +void TestEnv::prepareSandbox() +{ + g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX")); + if (!g_sandbox->isValid()) + abortSetup(QStringLiteral("не удалось создать временную папку")); + + const QByteArray home = QDir::toNativeSeparators(g_sandbox->path()).toLocal8Bit(); + qputenv("HOME", home); + qputenv("USERPROFILE", home); + // Чужая Java из окружения машины не должна влиять на выбор Java в тестах. + qunsetenv("JAVA_HOME"); + + std::atexit([] { + if (g_realProfile) + QDir(LauncherPaths::rootDir()).removeRecursively(); + 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()); + if (!root.startsWith(sandbox + u'/')) { +#if defined(Q_OS_WIN) + if (QDir(root).exists() && !QFile::exists(root + u'/' + QLatin1String(kMarker))) { + abortSetup(QStringLiteral( + "%1 уже существует и создана не тестами — отказываюсь её трогать. " + "Запускайте тесты под отдельной учётной записью.") + .arg(QDir::toNativeSeparators(root))); + } + g_realProfile = true; + resetLauncherRoot(); +#else + abortSetup(QStringLiteral("папка лаунчера %1 вне песочницы %2").arg(root, sandbox)); +#endif + } + + if (!Localization::instance().load()) + abortSetup(QStringLiteral("не загрузился каталог переводов")); + Localization::instance().setLanguage(QStringLiteral("en")); +} + +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() +{ + const QString root = LauncherPaths::rootDir(); + QDir(root).removeRecursively(); + if (g_realProfile) { + QDir().mkpath(root); + QFile marker(root + u'/' + QLatin1String(kMarker)); + marker.open(QIODevice::WriteOnly); + } +} diff --git a/tests/common/testenv.h b/tests/common/testenv.h new file mode 100644 index 0000000..c107ca7 --- /dev/null +++ b/tests/common/testenv.h @@ -0,0 +1,54 @@ +#pragma once + +// Общая обвязка тестов: песочница для домашней папки и main для QtTest. +// +// LauncherPaths строит все пути от QDir::homePath(). На Linux и macOS он берёт +// HOME, поэтому каждый тестовый процесс до создания QCoreApplication +// подменяет HOME на свою временную папку — реальные ~/BiLauncher и .minecraft +// машины, на которой гоняются тесты, не трогаются. +// +// На Windows QDir::homePath() спрашивает профиль у системы и переменные +// окружения игнорирует. Там тесты работают в настоящей +// %APPDATA%\BiLauncher, но только если её ещё нет либо она создана тестами +// (в ней лежит метка .bilauncher-test-sandbox). Иначе процесс отказывается +// запускаться, чтобы не испортить данные живого лаунчера. + +#include +#include +#include + +namespace TestEnv { + +// До QCoreApplication: временная папка и подмена HOME/USERPROFILE. +void prepareSandbox(); + +// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу, +// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины). +void finishSetup(); + +// Корень песочницы — то, что сейчас возвращает QDir::homePath() в тестах. +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 нужно самому подключить . +#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject) diff --git a/tests/fake_java/main.cpp b/tests/fake_java/main.cpp new file mode 100644 index 0000000..71fe82d --- /dev/null +++ b/tests/fake_java/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#elif defined(__APPLE__) +#include +#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 dumpEnv; + std::vector stdoutLines; + std::vector> 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; +} diff --git a/tests/fixtures/loaders/fabric-1.20.1.json b/tests/fixtures/loaders/fabric-1.20.1.json new file mode 100644 index 0000000..6db9cc2 --- /dev/null +++ b/tests/fixtures/loaders/fabric-1.20.1.json @@ -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": ""}} +] diff --git a/tests/fixtures/loaders/forge-maven-metadata.xml b/tests/fixtures/loaders/forge-maven-metadata.xml new file mode 100644 index 0000000..9ddf556 --- /dev/null +++ b/tests/fixtures/loaders/forge-maven-metadata.xml @@ -0,0 +1,18 @@ + + + net.minecraftforge + forge + + 1.21.1-52.0.10 + 1.21.1-52.0.10 + + 1.7.10-10.13.4.1614-1.7.10 + 1.20.1-47.3.0 + 1.20.1-47.4.0 + 1.20.1-47.4.1 + 1.21.1-52.0.10 + 1.21.1-52.0.2-beta + broken- + + + diff --git a/tests/fixtures/loaders/forge-promotions_slim.json b/tests/fixtures/loaders/forge-promotions_slim.json new file mode 100644 index 0000000..b099e9d --- /dev/null +++ b/tests/fixtures/loaders/forge-promotions_slim.json @@ -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"}} diff --git a/tests/fixtures/loaders/neoforge-legacy-maven-metadata.xml b/tests/fixtures/loaders/neoforge-legacy-maven-metadata.xml new file mode 100644 index 0000000..cc4dee9 --- /dev/null +++ b/tests/fixtures/loaders/neoforge-legacy-maven-metadata.xml @@ -0,0 +1,11 @@ + + + net.neoforged + forge + + + 1.20.1-47.1.106 + 1.20.1-47.1.79 + + + diff --git a/tests/fixtures/loaders/neoforge-maven-metadata.xml b/tests/fixtures/loaders/neoforge-maven-metadata.xml new file mode 100644 index 0000000..d71151a --- /dev/null +++ b/tests/fixtures/loaders/neoforge-maven-metadata.xml @@ -0,0 +1,16 @@ + + + net.neoforged + neoforge + + + 20.4.80-beta + 20.4.237 + 21.0.10 + 21.1.200 + 21.1.248 + 26.1.0.5 + 26.1.1.3 + + + diff --git a/tests/fixtures/loaders/quilt-1.20.1.json b/tests/fixtures/loaders/quilt-1.20.1.json new file mode 100644 index 0000000..e80aa12 --- /dev/null +++ b/tests/fixtures/loaders/quilt-1.20.1.json @@ -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"}} +] diff --git a/tests/fixtures/mojang/version_manifest_v2.json b/tests/fixtures/mojang/version_manifest_v2.json new file mode 100644 index 0000000..60f8db4 --- /dev/null +++ b/tests/fixtures/mojang/version_manifest_v2.json @@ -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"} + ] +} diff --git a/tests/fixtures/seasonal/index.json b/tests/fixtures/seasonal/index.json new file mode 100644 index 0000000..5c6f454 --- /dev/null +++ b/tests/fixtures/seasonal/index.json @@ -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" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/tls/cert.pem b/tests/fixtures/tls/cert.pem new file mode 100644 index 0000000..337a183 --- /dev/null +++ b/tests/fixtures/tls/cert.pem @@ -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----- diff --git a/tests/fixtures/tls/key.pem b/tests/fixtures/tls/key.pem new file mode 100644 index 0000000..09d5696 --- /dev/null +++ b/tests/fixtures/tls/key.pem @@ -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----- diff --git a/tests/fixtures/versions/fabric-loader-0.15.0-1.20.1.json b/tests/fixtures/versions/fabric-loader-0.15.0-1.20.1.json new file mode 100644 index 0000000..64c6c83 --- /dev/null +++ b/tests/fixtures/versions/fabric-loader-0.15.0-1.20.1.json @@ -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/"} + ] +} diff --git a/tests/fixtures/versions/legacy-1.7.10.json b/tests/fixtures/versions/legacy-1.7.10.json new file mode 100644 index 0000000..7657890 --- /dev/null +++ b/tests/fixtures/versions/legacy-1.7.10.json @@ -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"} + ] +} diff --git a/tests/fixtures/versions/modern-1.20.1.json b/tests/fixtures/versions/modern-1.20.1.json new file mode 100644 index 0000000..dd83b10 --- /dev/null +++ b/tests/fixtures/versions/modern-1.20.1.json @@ -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%"}}]} + ] +} diff --git a/tests/net/CMakeLists.txt b/tests/net/CMakeLists.txt new file mode 100644 index 0000000..119680b --- /dev/null +++ b/tests/net/CMakeLists.txt @@ -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() diff --git a/tests/net/tst_authservice.cpp b/tests/net/tst_authservice.cpp new file mode 100644 index 0000000..86ae9bd --- /dev/null +++ b/tests/net/tst_authservice.cpp @@ -0,0 +1,298 @@ +#include "testenv.h" +#include "fixtures.h" +#include "nettest.h" + +#include "authservice.h" +#include "localization.h" + +#include +#include + +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" diff --git a/tests/net/tst_fakeinternet.cpp b/tests/net/tst_fakeinternet.cpp new file mode 100644 index 0000000..7e7e395 --- /dev/null +++ b/tests/net/tst_fakeinternet.cpp @@ -0,0 +1,135 @@ +// Самопроверка FakeInternet: если она красная, остальные сетевые тесты +// бессмысленны — значит, перехват трафика на этой платформе не работает. + +#include "testenv.h" +#include "fakeinternet.h" + +#include +#include +#include + +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 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 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 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 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 r500(get(manager, QStringLiteral("https://a.test/500"))); + QVERIFY(waitFinished(r500.data())); + QCOMPARE(r500->error(), QNetworkReply::InternalServerError); + + QScopedPointer drop(get(manager, QStringLiteral("https://a.test/drop"))); + QVERIFY(waitFinished(drop.data())); + QVERIFY(drop->error() != QNetworkReply::NoError); + + QScopedPointer 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 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" diff --git a/tests/net/tst_javainstaller.cpp b/tests/net/tst_javainstaller.cpp new file mode 100644 index 0000000..a569573 --- /dev/null +++ b/tests/net/tst_javainstaller.cpp @@ -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 +#include +#include +#include + +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 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" diff --git a/tests/net/tst_javaruntimeservice.cpp b/tests/net/tst_javaruntimeservice.cpp new file mode 100644 index 0000000..669fb60 --- /dev/null +++ b/tests/net/tst_javaruntimeservice.cpp @@ -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 +#include +#include + +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 ×tamp, 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 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 jdks = service.entries(JavaRuntimeKind::Jdk); + const QList 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 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 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" diff --git a/tests/net/tst_modloaderinstaller.cpp b/tests/net/tst_modloaderinstaller.cpp new file mode 100644 index 0000000..550445d --- /dev/null +++ b/tests/net/tst_modloaderinstaller.cpp @@ -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 +#include +#include +#include + +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 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 --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" diff --git a/tests/net/tst_modloaderversionservice.cpp b/tests/net/tst_modloaderversionservice.cpp new file mode 100644 index 0000000..b6e86b2 --- /dev/null +++ b/tests/net/tst_modloaderversionservice.cpp @@ -0,0 +1,274 @@ +#include "testenv.h" +#include "fixtures.h" +#include "nettest.h" + +#include "launcherpaths.h" +#include "localization.h" +#include "modloaderversionservice.h" + +#include + +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 &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 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 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 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 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 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("")); + 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" diff --git a/tests/net/tst_msaauthservice.cpp b/tests/net/tst_msaauthservice.cpp new file mode 100644 index 0000000..d0b7acc --- /dev/null +++ b/tests/net/tst_msaauthservice.cpp @@ -0,0 +1,262 @@ +#include "testenv.h" +#include "nettest.h" + +#include "authservice.h" +#include "localization.h" +#include "msaauthservice.h" + +#include +#include +#include + +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("xerr"); + QTest::addColumn("message"); + QTest::addColumn("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("url"); + QTest::addColumn("status"); + QTest::addColumn("body"); + QTest::addColumn("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" diff --git a/tests/net/tst_seasonalbuildservice.cpp b/tests/net/tst_seasonalbuildservice.cpp new file mode 100644 index 0000000..3b5210d --- /dev/null +++ b/tests/net/tst_seasonalbuildservice.cpp @@ -0,0 +1,197 @@ +#include "testenv.h" +#include "fixtures.h" +#include "nettest.h" + +#include "launcherpaths.h" +#include "localization.h" +#include "seasonalbuildservice.h" + +#include +#include + +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("format"); + QTest::addColumn("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" diff --git a/tests/net/tst_seasonalpackdownloader.cpp b/tests/net/tst_seasonalpackdownloader.cpp new file mode 100644 index 0000000..4e973a0 --- /dev/null +++ b/tests/net/tst_seasonalpackdownloader.cpp @@ -0,0 +1,139 @@ +#include "testenv.h" +#include "fixtures.h" +#include "nettest.h" + +#include "localization.h" +#include "seasonalpackdownloader.h" + +#include + +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" diff --git a/tests/net/tst_versioninstaller.cpp b/tests/net/tst_versioninstaller.cpp new file mode 100644 index 0000000..d93a575 --- /dev/null +++ b/tests/net/tst_versioninstaller.cpp @@ -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 +#include + +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("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" diff --git a/tests/net/tst_versionmanifestservice.cpp b/tests/net/tst_versionmanifestservice.cpp new file mode 100644 index 0000000..f736752 --- /dev/null +++ b/tests/net/tst_versionmanifestservice.cpp @@ -0,0 +1,181 @@ +#include "testenv.h" +#include "fixtures.h" +#include "nettest.h" + +#include "launcherpaths.h" +#include "localization.h" +#include "versionmanifestservice.h" + +#include +#include + +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" diff --git a/tests/qml/CMakeLists.txt b/tests/qml/CMakeLists.txt new file mode 100644 index 0000000..d4fc557 --- /dev/null +++ b/tests/qml/CMakeLists.txt @@ -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) diff --git a/tests/qml/tst_components.qml b/tests/qml/tst_components.qml new file mode 100644 index 0000000..0374619 --- /dev/null +++ b/tests/qml/tst_components.qml @@ -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 {} } +} diff --git a/tests/qml/tst_dialogs.qml b/tests/qml/tst_dialogs.qml new file mode 100644 index 0000000..8bf3df6 --- /dev/null +++ b/tests/qml/tst_dialogs.qml @@ -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) + } +} diff --git a/tests/qml/tst_qml.cpp b/tests/qml/tst_qml.cpp new file mode 100644 index 0000000..908adbb --- /dev/null +++ b/tests/qml/tst_qml.cpp @@ -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 +#include +#include + +#include +#include +#include +#include + +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" diff --git a/tests/qml/tst_qml_smoke.cpp b/tests/qml/tst_qml_smoke.cpp new file mode 100644 index 0000000..ff0ba67 --- /dev/null +++ b/tests/qml/tst_qml_smoke.cpp @@ -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 +#include +#include +#include +#include + +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 &list) { + for (const QQmlError &e : list) + warnings << e.toString(); + }); + engine.loadFromModule("BiLauncher", "Main"); + QCOMPARE(engine.rootObjects().size(), 1); + auto *window = qobject_cast(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" diff --git a/tests/qmllint-baseline.txt b/tests/qmllint-baseline.txt new file mode 100644 index 0000000..0ea819f --- /dev/null +++ b/tests/qmllint-baseline.txt @@ -0,0 +1,4 @@ +53 +# Число предупреждений qmllint по модулю BiLauncher, которое считается нормой. +# tests/scripts/static_checks.py падает, если их стало больше. Исправили часть — +# опустите число, чтобы улучшение закрепилось. diff --git a/tests/scripts/smoke_installed.ps1 b/tests/scripts/smoke_installed.ps1 new file mode 100644 index 0000000..df30b05 --- /dev/null +++ b/tests/scripts/smoke_installed.ps1 @@ -0,0 +1,66 @@ +# Дымовая проверка установленного дерева (результат cmake --install) на Windows. +# +# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage +# +# Проверяет, что windeployqt положил всё нужное (DLL Qt, плагины платформ и QML), +# лаунчер запускается без окна и через 10 секунд ещё жив, а в логе нет ошибок +# загрузки модулей. +# +# На Windows лаунчер берёт папку профиля у системы, а не из переменных окружения, +# поэтому работает в настоящей %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" +} + +$profileRoot = Join-Path $env:APPDATA "BiLauncher" +$existedBefore = Test-Path $profileRoot + +$log = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force $log | Out-Null +$stdout = Join-Path $log "stdout.log" +$stderr = Join-Path $log "stderr.log" + +$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" } + Write-Host "SMOKE OK: $bin" +} +finally { + Remove-Item -Recurse -Force $log -ErrorAction SilentlyContinue + if (-not $existedBefore) { Remove-Item -Recurse -Force $profileRoot -ErrorAction SilentlyContinue } +} diff --git a/tests/scripts/smoke_installed.sh b/tests/scripts/smoke_installed.sh new file mode 100755 index 0000000..190c57c --- /dev/null +++ b/tests/scripts/smoke_installed.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Дымовая проверка установленного дерева (результат cmake --install) на Linux и macOS. +# +# tests/scripts/smoke_installed.sh +# +# Linux: /bin/BiLauncher (+ lib/BiLauncher/libz.so.1, плагины Qt рядом) +# macOS: /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" diff --git a/tests/scripts/static_checks.py b/tests/scripts/static_checks.py new file mode 100755 index 0000000..c15dd96 --- /dev/null +++ b/tests/scripts/static_checks.py @@ -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()) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt new file mode 100644 index 0000000..696250a --- /dev/null +++ b/tests/unit/CMakeLists.txt @@ -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() diff --git a/tests/unit/tst_authoffline.cpp b/tests/unit/tst_authoffline.cpp new file mode 100644 index 0000000..badbd8d --- /dev/null +++ b/tests/unit/tst_authoffline.cpp @@ -0,0 +1,67 @@ +#include "testenv.h" + +#include "authservice.h" +#include "localization.h" + +#include +#include +#include + +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("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" diff --git a/tests/unit/tst_buildarchiveworker.cpp b/tests/unit/tst_buildarchiveworker.cpp new file mode 100644 index 0000000..a588c39 --- /dev/null +++ b/tests/unit/tst_buildarchiveworker.cpp @@ -0,0 +1,236 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "buildarchiveworker.h" +#include "localization.h" + +#include +#include + +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 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" diff --git a/tests/unit/tst_buildswitcher.cpp b/tests/unit/tst_buildswitcher.cpp new file mode 100644 index 0000000..a9fac8b --- /dev/null +++ b/tests/unit/tst_buildswitcher.cpp @@ -0,0 +1,302 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "buildswitcher.h" +#include "launcherpaths.h" +#include "localization.h" + +#include +#include +#include + +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" diff --git a/tests/unit/tst_gamelauncher.cpp b/tests/unit/tst_gamelauncher.cpp new file mode 100644 index 0000000..360bdb9 --- /dev/null +++ b/tests/unit/tst_gamelauncher.cpp @@ -0,0 +1,450 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "gamelauncher.h" +#include "javalocator.h" +#include "localization.h" +#include "minecraftversion.h" + +#include +#include + +namespace { + +#if defined(Q_OS_WIN) +const QChar kSep = u';'; +const char *kNativeName = "lwjgl.dll"; +#elif defined(Q_OS_MACOS) +const QChar kSep = u':'; +const char *kNativeName = "liblwjgl.dylib"; +#else +const QChar kSep = u':'; +const char *kNativeName = "liblwjgl.so"; +#endif + +QString nat(const QString &path) +{ + return QDir::toNativeSeparators(path); +} + +MinecraftLibrary lib(const QString &name, const QString &path, bool native = false) +{ + MinecraftLibrary library; + library.name = name; + library.path = path; + library.native = native; + return library; +} + +} // namespace + +class TestGameLauncher : public QObject +{ + Q_OBJECT + + QString m_game; + + MinecraftVersion modernVersion() const + { + MinecraftVersion v; + v.id = QStringLiteral("1.20.1"); + v.mainClass = QStringLiteral("net.minecraft.client.main.Main"); + v.type = QStringLiteral("release"); + v.assetIndexId = QStringLiteral("5"); + v.assetsKind = QStringLiteral("5"); + v.clientJarPath = m_game + QStringLiteral("/versions/1.20.1/1.20.1.jar"); + v.javaMajor = 17; + v.jvmArgs = QStringList{"-Djava.library.path=${natives_directory}", + "-Dminecraft.launcher.brand=${launcher_name}", + "-Dminecraft.launcher.version=${launcher_version}", + "-Dunknown=${not_a_variable}", + "-cp", "${classpath}"}; + v.gameArgs = QStringList{"--username", "${auth_player_name}", "--version", "${version_name}", + "--gameDir", "${game_directory}", "--assetsDir", "${assets_root}", + "--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}", + "--accessToken", "${auth_access_token}", "--clientId", "${clientid}", + "--xuid", "${auth_xuid}", "--userType", "${user_type}", + "--versionType", "${version_type}", "--mystery", "${unknown_thing}"}; + v.libraries << lib("com.mojang:brigadier:1.1.8", "com/mojang/brigadier-1.1.8.jar") + << lib("org.lwjgl:lwjgl:3.3.1", "org/lwjgl/lwjgl-3.3.1.jar") + << lib("org.lwjgl:lwjgl:3.3.1:natives", "org/lwjgl/lwjgl-3.3.1-natives.jar", true); + return v; + } + + LaunchOptions options() const + { + LaunchOptions o; + o.gameDir = m_game; + o.versionId = QStringLiteral("1.20.1"); + o.playerName = QStringLiteral("Steve"); + o.uuid = QStringLiteral("0123456789abcdef0123456789abcdef"); + o.accessToken = QStringLiteral("secret-token"); + o.userType = QStringLiteral("msa"); + o.minMemoryMb = 1024; + o.maxMemoryMb = 6144; + return o; + } + + static QString valueAfter(const QStringList &args, const QString &flag) + { + const qsizetype i = args.indexOf(flag); + return (i >= 0 && i + 1 < args.size()) ? args.at(i + 1) : QString(); + } + + void createGameFiles(const MinecraftVersion &v) + { + Fixtures::write(v.clientJarPath, "client"); + Fixtures::write(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar", "lib"); + Fixtures::write(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar", "lib"); + Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar", + {{QStringLiteral("META-INF/MANIFEST.MF"), "x"}, + {QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"}, + {QStringLiteral("skip/") + kNativeName + ".sha1", "hash"}}); + Fixtures::write(m_game + "/assets/indexes/5.json", "{}"); + QDir().mkpath(m_game + "/assets/objects"); + } + +private slots: + void init() + { + TestEnv::resetLauncherRoot(); + m_game = TestEnv::freshDir(QStringLiteral("game")); + } + + // --- buildArguments --------------------------------------------------------- + + void substitutesVariables() + { + const MinecraftVersion v = modernVersion(); + const QString natives = m_game + "/versions/1.20.1/natives"; + const QStringList args = GameLauncher::buildArguments(options(), v, natives); + + QCOMPARE(args.at(0), QStringLiteral("-Xms1024M")); + QCOMPARE(args.at(1), QStringLiteral("-Xmx6144M")); + QVERIFY(args.contains("-Djava.library.path=" + nat(natives))); + QVERIFY(args.contains("-Dminecraft.launcher.brand=BiLauncher")); + QVERIFY(args.contains(QStringLiteral("-Dminecraft.launcher.version=") + QStringLiteral(LAUNCHER_VERSION))); + // Неизвестный плейсхолдер — аргумент выбрасывается целиком. + QVERIFY(!args.join(' ').contains("not_a_variable")); + QVERIFY(!args.contains("${unknown_thing}")); + QVERIFY(args.contains("--mystery")); // сам флаг остаётся, как у официального лаунчера + + // classpath: обычные библиотеки, без нативов, клиентский jar последним. + const QStringList cp = valueAfter(args, "-cp").split(kSep); + QCOMPARE(cp, QStringList({nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar"), + nat(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar"), + nat(v.clientJarPath)})); + + // mainClass — после jvm-аргументов, перед игровыми. + const qsizetype main = args.indexOf(v.mainClass); + QVERIFY(main > args.indexOf("-cp")); + QVERIFY(main < args.indexOf("--username")); + + QCOMPARE(valueAfter(args, "--username"), QStringLiteral("Steve")); + QCOMPARE(valueAfter(args, "--version"), QStringLiteral("1.20.1")); + QCOMPARE(valueAfter(args, "--gameDir"), nat(m_game)); + QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets")); + QCOMPARE(valueAfter(args, "--assetIndex"), QStringLiteral("5")); + QCOMPARE(valueAfter(args, "--uuid"), QStringLiteral("0123456789abcdef0123456789abcdef")); + QCOMPARE(valueAfter(args, "--accessToken"), QStringLiteral("secret-token")); + QCOMPARE(valueAfter(args, "--clientId"), QStringLiteral("0")); // пустой → «0» + QCOMPARE(valueAfter(args, "--xuid"), QStringLiteral("0")); + QCOMPARE(valueAfter(args, "--userType"), QStringLiteral("msa")); + QCOMPARE(valueAfter(args, "--versionType"), QStringLiteral("release")); + QVERIFY(!args.contains("--width")); + QVERIFY(!args.contains("--fullscreen")); + QVERIFY(!args.contains("--server")); + } + + void legacyAssetsAndSession() + { + MinecraftVersion v = modernVersion(); + v.assetsKind = QStringLiteral("legacy"); + v.gameArgs = QStringList{"--session", "${auth_session}", "--assetsDir", "${game_assets}"}; + const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n"); + QCOMPARE(valueAfter(args, "--session"), + QStringLiteral("token:secret-token:0123456789abcdef0123456789abcdef")); + QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets/virtual/legacy")); + + v.assetsKind = QStringLiteral("pre-1.6"); + QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"), + nat(m_game + "/assets/virtual/legacy")); + v.assetsKind = QStringLiteral("5"); + QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"), + nat(m_game + "/assets")); + } + + void memoryAndExtraJvmArgs() + { + LaunchOptions o = options(); + o.minMemoryMb = 0; + o.maxMemoryMb = 0; + o.extraJvmArgs = QStringList{"-XX:+UseG1GC", "-Dfoo=bar"}; + const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n"); + QVERIFY(!args.join(' ').contains("-Xms")); + QVERIFY(!args.join(' ').contains("-Xmx")); + QCOMPARE(args.at(0), QStringLiteral("-XX:+UseG1GC")); + QCOMPARE(args.at(1), QStringLiteral("-Dfoo=bar")); + } + + void authlibInjectorGoesFirst() + { + LaunchOptions o = options(); + o.authlibInjectorPath = m_game + "/authlib-injector.jar"; + const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n"); + QCOMPARE(args.at(0), "-javaagent:" + nat(o.authlibInjectorPath) + "=ely.by"); + QCOMPARE(args.at(1), QStringLiteral("-Dauthlibinjector.side=client")); + QCOMPARE(args.at(2), QStringLiteral("-Xms1024M")); + } + + void windowSizeAndFullscreen() + { + LaunchOptions o = options(); + o.windowWidth = 1280; + o.windowHeight = 720; + o.fullscreen = true; + MinecraftVersion v = modernVersion(); + QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n"); + QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280")); + QCOMPARE(valueAfter(args, "--height"), QStringLiteral("720")); + QCOMPARE(args.count("--fullscreen"), 1); + + // У версии свои аргументы разрешения — лаунчер свои не дублирует. + v.hasCustomResolutionArgs = true; + v.gameArgs << "--width" << "${resolution_width}" << "--height" << "${resolution_height}"; + args = GameLauncher::buildArguments(o, v, m_game + "/n"); + QCOMPARE(args.count("--width"), 1); + QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280")); + + // Только ширина — не передаём ничего. + o.windowHeight = 0; + o.fullscreen = false; + args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n"); + QVERIFY(!args.contains("--width")); + QVERIFY(!args.contains("--fullscreen")); + } + + void serverAutoJoin_data() + { + QTest::addColumn("quickPlay"); + QTest::addColumn("address"); + QTest::addColumn("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, ""); + 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 &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" diff --git a/tests/unit/tst_javalocator.cpp b/tests/unit/tst_javalocator.cpp new file mode 100644 index 0000000..171ede6 --- /dev/null +++ b/tests/unit/tst_javalocator.cpp @@ -0,0 +1,145 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "javalocator.h" +#include "javaruntime.h" +#include "localization.h" + +#include + +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("version"); + QTest::addColumn("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" diff --git a/tests/unit/tst_javaruntime.cpp b/tests/unit/tst_javaruntime.cpp new file mode 100644 index 0000000..d331d27 --- /dev/null +++ b/tests/unit/tst_javaruntime.cpp @@ -0,0 +1,233 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "javaruntime.h" +#include "launcherpaths.h" +#include "localization.h" + +#include +#include + +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("input"); + QTest::addColumn("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 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("version"); + QTest::addColumn("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" diff --git a/tests/unit/tst_launcherpaths.cpp b/tests/unit/tst_launcherpaths.cpp new file mode 100644 index 0000000..023cbd5 --- /dev/null +++ b/tests/unit/tst_launcherpaths.cpp @@ -0,0 +1,97 @@ +#include "testenv.h" + +#include "launcherpaths.h" +#include "localization.h" + +#include +#include +#include + +class TestLauncherPaths : public QObject +{ + Q_OBJECT + +private slots: + void init() { TestEnv::resetLauncherRoot(); } + + void rootIsInsideContainer() + { + QVERIFY(LauncherPaths::rootDir().startsWith(LauncherPaths::containerDir() + u'/')); + QCOMPARE(QFileInfo(LauncherPaths::rootDir()).fileName(), QStringLiteral("BiLauncher")); + } + + void containerPerPlatform() + { + const QString home = QDir::homePath(); +#if defined(Q_OS_WIN) + QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/AppData/Roaming")); + QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/.minecraft"); +#elif defined(Q_OS_MACOS) + QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/Library/Application Support")); + QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/minecraft"); +#else + QCOMPARE(LauncherPaths::containerDir(), home); + QCOMPARE(LauncherPaths::defaultMinecraftDir(), home + "/.minecraft"); +#endif + } + + void filesLayout_data() + { + QTest::addColumn("actual"); + QTest::addColumn("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" diff --git a/tests/unit/tst_localization.cpp b/tests/unit/tst_localization.cpp new file mode 100644 index 0000000..846f741 --- /dev/null +++ b/tests/unit/tst_localization.cpp @@ -0,0 +1,138 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "launcherpaths.h" +#include "localization.h" + +#include +#include + +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" diff --git a/tests/unit/tst_minecraftversion.cpp b/tests/unit/tst_minecraftversion.cpp new file mode 100644 index 0000000..5700e56 --- /dev/null +++ b/tests/unit/tst_minecraftversion.cpp @@ -0,0 +1,353 @@ +#include "testenv.h" +#include "fixtures.h" + +#include "localization.h" +#include "minecraftversion.h" + +#include +#include +#include + +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("rules"); + QTest::addColumn("features"); + QTest::addColumn("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 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", ""); + + 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 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("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" diff --git a/tests/unit/tst_modloader.cpp b/tests/unit/tst_modloader.cpp new file mode 100644 index 0000000..54ffc5b --- /dev/null +++ b/tests/unit/tst_modloader.cpp @@ -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" diff --git a/tests/unit/tst_msaredirect.cpp b/tests/unit/tst_msaredirect.cpp new file mode 100644 index 0000000..840aa8f --- /dev/null +++ b/tests/unit/tst_msaredirect.cpp @@ -0,0 +1,75 @@ +#include "testenv.h" + +#include "msaauthservice.h" + +#include + +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("url"); + QTest::addColumn("matches"); + QTest::addColumn("code"); + QTest::addColumn("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" diff --git a/tests/unit/tst_zlibreference.cpp b/tests/unit/tst_zlibreference.cpp new file mode 100644 index 0000000..b765369 --- /dev/null +++ b/tests/unit/tst_zlibreference.cpp @@ -0,0 +1,65 @@ +#include "testenv.h" + +#include "localization.h" +#include "zlibreference.h" + +#include +#include +#include + +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, ¬e)); + 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, ¬e); + + 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"