Compare commits
32
Commits
443850196d
...
v1.0.2-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a2698188f | ||
|
|
5111e90740 | ||
|
|
39b2204e67 | ||
|
|
5635dcc558 | ||
|
|
6a2b596f32 | ||
|
|
3517f1f236 | ||
|
|
b94d852d7c | ||
|
|
47d30b1594 | ||
|
|
56dd58b8d1 | ||
|
|
50e0cd2aab | ||
|
|
4ceaf000fd | ||
|
|
d82a9d0729 | ||
|
|
2575aae5c6 | ||
|
|
6442514d4f | ||
|
|
1817157e4d | ||
|
|
4463d4e5da | ||
|
|
7dd89db508 | ||
|
|
a89bf20486 | ||
|
|
ec60df859a | ||
|
|
0cacbc3ef3 | ||
|
|
5508b9a936 | ||
|
|
24115b4ce6 | ||
|
|
1d781b5324 | ||
|
|
bc205fa2e7 | ||
|
|
aa021269be | ||
|
|
92531c6e9a | ||
|
|
acd9334c16 | ||
|
|
3ac2156ef2 | ||
|
|
5d819129d6 | ||
|
|
0dd6bef715 | ||
|
|
41599fa82f | ||
|
|
ebf8ff7869 |
No files matched your search
@@ -1,23 +1,18 @@
|
||||
# Сборка BiLauncher под Windows, Linux и macOS и публикация в Gitea Releases.
|
||||
#
|
||||
# push тега vX.Y.Z (или vX.Y.Z-rc1 → pre-release):
|
||||
# create-release создаёт черновик релиза → три сборки прикладывают к нему файлы →
|
||||
# publish добавляет SHA256SUMS.txt и снимает флаг черновика. Если какая-то сборка
|
||||
# упала, релиз остаётся черновиком и пользователям не виден.
|
||||
# Ручной запуск (workflow_dispatch): те же сборки, но результат — артефакты прогона,
|
||||
# релиз не создаётся.
|
||||
#
|
||||
# Пути к Qt на раннерах задаются в их config.yaml (runner.envs): QT_ROOT — kit Qt,
|
||||
# QT_TOOLS — папка Tools из Qt Online Installer (там CMake и Ninja).
|
||||
# Секрет RELEASE_TOKEN — PAT с правом repository: write.
|
||||
|
||||
name: release
|
||||
|
||||
# master -> релиз v<версия>, если такого ещё нет
|
||||
# rc -> пре-релиз v<версия>-rcN, N — следующий свободный номер
|
||||
# тег vX.Y.Z -> релиз, тег vX.Y.Z-rcN -> пре-релиз
|
||||
# ручной запуск -> только сборки, файлы остаются артефактами прогона
|
||||
on:
|
||||
push:
|
||||
#branches: [master, rc]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
env:
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
|
||||
@@ -27,54 +22,95 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
tag: ${{ steps.meta.outputs.tag }}
|
||||
skip: ${{ steps.meta.outputs.skip }}
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- id: meta
|
||||
name: Версия из CMakeLists.txt и сверка с тегом
|
||||
name: Версия из CMakeLists.txt, тег и тип релиза
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
version=$(sed -nE 's/^project\(BiLauncher VERSION ([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' CMakeLists.txt)
|
||||
[ -n "$version" ] || { echo "Не нашёл project(BiLauncher VERSION ...)"; exit 1; }
|
||||
tag=""
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
base="${tag#v}"; base="${base%%-*}"
|
||||
if [ "$base" != "$version" ]; then
|
||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||
fi
|
||||
version="${tag#v}"
|
||||
fi
|
||||
|
||||
# Теги релизов, включая черновики (токену с правом записи API отдаёт и их)
|
||||
release_tags=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases?limit=50" \
|
||||
| python3 -c 'import json,sys; [print(r["tag_name"]) for r in json.load(sys.stdin)]')
|
||||
has_release() { printf '%s\n' "$release_tags" | grep -xF "$1" > /dev/null; }
|
||||
# У черновика тега в git ещё нет, поэтому смотрим и в git, и в релизы
|
||||
known() {
|
||||
git ls-remote --tags origin | sed -E 's#.*refs/tags/##; s/\^\{\}$//'
|
||||
printf '%s\n' "$release_tags"
|
||||
}
|
||||
|
||||
tag=""; skip=false
|
||||
case "${GITHUB_REF_TYPE}:${GITHUB_REF_NAME}" in
|
||||
tag:*)
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
base="${tag#v}"; base="${base%%-*}"
|
||||
if [ "$base" != "$version" ]; then
|
||||
echo "Тег $tag не совпадает с версией $version в CMakeLists.txt"; exit 1
|
||||
fi
|
||||
# Тег мог создать сам релиз из прогона по ветке — тогда второй раз не собираем
|
||||
if has_release "$tag"; then
|
||||
echo "Релиз $tag уже есть"; skip=true
|
||||
fi
|
||||
;;
|
||||
branch:master)
|
||||
tag="v$version"
|
||||
if known | grep -xF "$tag" > /dev/null; then
|
||||
echo "Релиз $tag уже есть, поднимите версию в CMakeLists.txt"; skip=true
|
||||
fi
|
||||
;;
|
||||
branch:rc)
|
||||
n=$(known | sed -nE "s/^v${version//./\\.}-rc([0-9]+)\$/\1/p" | sort -n | tail -1)
|
||||
tag="v$version-rc$(( ${n:-0} + 1 ))"
|
||||
;;
|
||||
esac
|
||||
[ -n "$tag" ] && version="${tag#v}"
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Версия: $version, тег: ${tag:-нет}"
|
||||
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||||
echo "Версия: $version, тег: ${tag:-нет}, пропуск: $skip"
|
||||
|
||||
- id: release
|
||||
name: Черновик релиза
|
||||
if: steps.meta.outputs.tag != ''
|
||||
if: steps.meta.outputs.tag != '' && steps.meta.outputs.skip != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
# Описание релиза — раздел CHANGELOG.md для этой версии
|
||||
base="${TAG#v}"; base="${base%%-*}"
|
||||
notes=$(awk -v v="$base" '
|
||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||
on' CHANGELOG.md)
|
||||
prerelease=false; case "$TAG" in *-*) prerelease=true;; esac
|
||||
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||
"$TAG" "$notes" "$prerelease")
|
||||
full="${TAG#v}"; base="${full%%-*}"
|
||||
section() {
|
||||
awk -v v="$1" '
|
||||
/^## \[/ { if (on) exit; if (index($0, "[" v "]")) { on=1; next } }
|
||||
on' CHANGELOG.md
|
||||
}
|
||||
# Сначала раздел ровно под тег ([1.0.1-rc1]), иначе общий под версию ([1.0.1])
|
||||
notes=$(section "$full")
|
||||
[ -n "$notes" ] || notes=$(section "$base")
|
||||
[ -n "$notes" ] || echo "::warning::В CHANGELOG.md нет раздела [$full] или [$base]"
|
||||
prerelease=false; case "$TAG" in *-rc*) prerelease=true;; esac
|
||||
# target_commitish: у новой ветки тег появится на этом коммите при публикации
|
||||
body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"target_commitish":sys.argv[4],"name":"BiLauncher "+sys.argv[1],"body":sys.argv[2],"draft":True,"prerelease":sys.argv[3]=="true"}))' \
|
||||
"$TAG" "$notes" "$prerelease" "$GITHUB_SHA")
|
||||
id=$(curl -fsS -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
echo "Создан черновик релиза $TAG (id $id)"
|
||||
echo "Создан черновик $([ "$prerelease" = true ] && echo пре-)релиза $TAG (id $id)"
|
||||
|
||||
build-linux:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: linux
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.pack.outputs.sums }}
|
||||
@@ -85,12 +121,9 @@ jobs:
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build AppDir dist
|
||||
# LIBDIR=lib: zlibreference.cpp ищет libz рядом, в ../lib/BiLauncher,
|
||||
# а на Ubuntu по умолчанию был бы lib/x86_64-linux-gnu.
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib
|
||||
# На ВМ 6 ГБ памяти: без ограничения 42 параллельных компилятора уходят в OOM.
|
||||
cmake --build build -j 8
|
||||
cmake --install build --prefix "$PWD/AppDir/usr"
|
||||
|
||||
@@ -128,9 +161,12 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: windows
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.build.outputs.sums }}
|
||||
@@ -142,7 +178,6 @@ jobs:
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Короткие пути: дерево WebEngine легко вылезает за MAX_PATH.
|
||||
$build = "C:\r\bl-build"; $stage = "C:\r\bl-stage"; $dist = "$PWD\dist"
|
||||
Remove-Item -Recurse -Force $build, $stage, $dist -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -152,7 +187,7 @@ jobs:
|
||||
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"
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT" "-DLAUNCHER_BUILD_VERSION=$env:BUILD_VERSION"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
@@ -163,14 +198,10 @@ jobs:
|
||||
& "$env:ISCC_DIR\ISCC.exe" /Q "/DAppVersion=$env:VERSION" "/DSourceDir=$stage" "/DOutputDir=$dist" packaging\windows\BiLauncher.iss
|
||||
if ($LASTEXITCODE) { throw "iscc" }
|
||||
|
||||
# Portable: то же дерево, что ставит установщик, без vc_redist
|
||||
# (в portable-варианте среда выполнения MSVC должна уже стоять в системе).
|
||||
Remove-Item "$stage\bin\vc_redist.x64.exe" -ErrorAction SilentlyContinue
|
||||
$portable = "C:\r\BiLauncher-$env:VERSION"
|
||||
Remove-Item -Recurse -Force $portable -ErrorAction SilentlyContinue
|
||||
Move-Item $stage $portable
|
||||
# Системный bsdtar по полному пути: в PATH раннера раньше стоит GNU tar из Git,
|
||||
# который не умеет zip и принимает «C:» за имя удалённого хоста.
|
||||
& "$env:SystemRoot\System32\tar.exe" -a -c -f "$dist\BiLauncher-$env:VERSION-win64-portable.zip" -C C:\r "BiLauncher-$env:VERSION"
|
||||
if ($LASTEXITCODE) { throw "zip" }
|
||||
Remove-Item -Recurse -Force $portable
|
||||
@@ -179,7 +210,6 @@ jobs:
|
||||
$sums = Get-ChildItem $dist | ForEach-Object {
|
||||
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name }
|
||||
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($sums -join "`n") + "`n"))
|
||||
# Не Out-File: PowerShell 5.1 пишет UTF-8 с BOM, и имя первой переменной ломается.
|
||||
[IO.File]::AppendAllText($env:GITHUB_OUTPUT, "sums=$b64`n")
|
||||
|
||||
- name: Загрузка в релиз
|
||||
@@ -204,9 +234,12 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
needs: create-release
|
||||
if: needs.create-release.outputs.skip != 'true'
|
||||
runs-on: macos
|
||||
env:
|
||||
VERSION: ${{ needs.create-release.outputs.version }}
|
||||
# Только для релизов/пре-релизов; при ручном запуске канал определит CMake по ветке
|
||||
BUILD_VERSION: ${{ needs.create-release.outputs.tag != '' && needs.create-release.outputs.version || '' }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
outputs:
|
||||
sums: ${{ steps.pack.outputs.sums }}
|
||||
@@ -218,7 +251,7 @@ jobs:
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build stage dist dmgroot
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" -DLAUNCHER_BUILD_VERSION="$BUILD_VERSION" \
|
||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||
cmake --build build
|
||||
cmake --install build --prefix "$PWD/stage"
|
||||
@@ -226,8 +259,6 @@ jobs:
|
||||
- id: pack
|
||||
name: Ad-hoc подпись и DMG
|
||||
run: |
|
||||
# Без Developer ID: ad-hoc подпись нужна, чтобы приложение вообще
|
||||
# запускалось на Apple Silicon; Gatekeeper всё равно спросит подтверждение.
|
||||
codesign --force --deep --sign - stage/BiLauncher.app
|
||||
codesign --verify --deep --strict stage/BiLauncher.app
|
||||
mkdir -p dmgroot dist
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
BILAUNCHER_REQUIRE_FAKE_INTERNET: "1"
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: linux
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка лаунчера и тестов
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
launcher=""
|
||||
if command -v ccache >/dev/null; then
|
||||
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||
fi
|
||||
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT" $launcher
|
||||
cmake --build build-tests -j 8
|
||||
|
||||
- name: Статические проверки (переводы, qmllint, CHANGELOG)
|
||||
run: CMAKE="$QT_TOOLS/CMake/bin/cmake" python3 tests/scripts/static_checks.py build-tests
|
||||
|
||||
- name: Тесты
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$PATH"
|
||||
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||
--output-junit "$PWD/build-tests/junit.xml"
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-linux
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
build-tests/junit.xml
|
||||
build-tests/qmllint.log
|
||||
build-tests/Testing/Temporary/LastTest.log
|
||||
|
||||
windows:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: windows
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка и тесты
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# Короткий путь: дерево WebEngine легко вылезает за MAX_PATH (см. release.yml).
|
||||
$build = "C:\r\bl-tests"
|
||||
Remove-Item -Recurse -Force $build -ErrorAction SilentlyContinue
|
||||
|
||||
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBILAUNCHER_BUILD_TESTS=ON "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
|
||||
ctest --test-dir $build --output-on-failure --output-junit "$build\junit.xml"
|
||||
$code = $LASTEXITCODE
|
||||
New-Item -ItemType Directory -Force test-results | Out-Null
|
||||
git rev-parse HEAD | Set-Content -Encoding UTF8 test-results\commit.txt
|
||||
Copy-Item "$build\junit.xml" test-results\ -ErrorAction SilentlyContinue
|
||||
Copy-Item "$build\Testing\Temporary\LastTest.log" test-results\ -ErrorAction SilentlyContinue
|
||||
if ($code) { throw "ctest" }
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-windows
|
||||
if-no-files-found: ignore
|
||||
path: test-results/
|
||||
|
||||
macos:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Сборка и тесты
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug \
|
||||
-DBILAUNCHER_BUILD_TESTS=ON -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||
cmake --build build-tests
|
||||
ctest --test-dir build-tests --output-on-failure -j 4 \
|
||||
--output-junit "$PWD/build-tests/junit.xml"
|
||||
|
||||
- name: Отчёты
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tests-macos
|
||||
if-no-files-found: ignore
|
||||
path: build-tests/junit.xml
|
||||
|
||||
package-smoke-linux:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: linux
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build-smoke smoke-stage
|
||||
launcher=""
|
||||
if command -v ccache >/dev/null; then
|
||||
launcher="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
|
||||
fi
|
||||
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_PREFIX_PATH="$QT_ROOT" \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib $launcher
|
||||
cmake --build build-smoke -j 8
|
||||
cmake --install build-smoke --prefix "$PWD/smoke-stage/usr"
|
||||
bash tests/scripts/smoke_installed.sh smoke-stage/usr
|
||||
|
||||
package-smoke-windows:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$build = "C:\r\bl-smoke"; $stage = "C:\r\bl-smoke-stage"
|
||||
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||
|
||||
$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * `
|
||||
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -host_arch=x64"
|
||||
$env:PATH = "$env:QT_TOOLS\CMake_64\bin;$env:QT_TOOLS\Ninja;$env:PATH"
|
||||
|
||||
cmake -S . -B $build -G Ninja -DCMAKE_BUILD_TYPE=Release "-DCMAKE_PREFIX_PATH=$env:QT_ROOT"
|
||||
if ($LASTEXITCODE) { throw "cmake configure" }
|
||||
cmake --build $build
|
||||
if ($LASTEXITCODE) { throw "cmake build" }
|
||||
cmake --install $build --prefix $stage
|
||||
if ($LASTEXITCODE) { throw "cmake install" }
|
||||
|
||||
& powershell -ExecutionPolicy Bypass -File tests\scripts\smoke_installed.ps1 -Prefix $stage
|
||||
if ($LASTEXITCODE) { throw "smoke" }
|
||||
Remove-Item -Recurse -Force $build, $stage -ErrorAction SilentlyContinue
|
||||
|
||||
package-smoke-macos:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Release-сборка, установка, запуск
|
||||
run: |
|
||||
export PATH="$QT_TOOLS/CMake/CMake.app/Contents/bin:$QT_TOOLS/Ninja:$PATH"
|
||||
rm -rf build-smoke smoke-stage
|
||||
cmake -S . -B build-smoke -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$QT_ROOT"
|
||||
cmake --build build-smoke
|
||||
cmake --install build-smoke --prefix "$PWD/smoke-stage"
|
||||
bash tests/scripts/smoke_installed.sh smoke-stage
|
||||
@@ -61,6 +61,9 @@ replay_pid*
|
||||
build/
|
||||
.qtcreator/
|
||||
.DS_Store
|
||||
/doc/adr/
|
||||
CMakeLists.txt.user*
|
||||
stage/
|
||||
dist/
|
||||
build-tests/
|
||||
build-*/
|
||||
+6
-3
@@ -8,6 +8,7 @@ Dialog {
|
||||
id: buildsDialog
|
||||
|
||||
required property var backend
|
||||
enabled: !buildsDialog.backend.seasonalInstalling
|
||||
property int editIndex: -1
|
||||
property bool loading: false
|
||||
|
||||
@@ -15,8 +16,9 @@ Dialog {
|
||||
padding: 0
|
||||
width: 880
|
||||
height: 600
|
||||
closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
//closePolicy: buildsDialog.backend.switching ? Popup.NoAutoClose
|
||||
// : Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
@@ -314,7 +316,8 @@ Dialog {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: buildRow.index === buildsDialog.backend.activeBuildIndex
|
||||
text: Loc.t.build.label.active
|
||||
color: "#91B315"
|
||||
color: (buildsDialog.editIndex === buildRow.index || buildRow.hovered)
|
||||
? "#ffffff" : "#91B315"
|
||||
font.pixelSize: 10
|
||||
}
|
||||
Image {
|
||||
|
||||
@@ -10,3 +10,18 @@
|
||||
- Авторизация через ely.by и\или лицензию, с возможностью работы скинов
|
||||
### Исправлено
|
||||
- UI фиксы
|
||||
|
||||
## [1.0.1-rc1] — 2026-10-23
|
||||
### Добавлено
|
||||
- Отображене текущей версии сборки лаунчера на главном окне
|
||||
### Исправлено
|
||||
- Исправлен баг когда невозможно выйти из модального окна сезонных сборок и\или отменить текущую загрузку
|
||||
- Исправлен баг отображения статуса активной сборки на зелёном фоне
|
||||
- Исправлен баг, что во время игры можно было потенциально сменить сборку
|
||||
- Исправлен баг первого запуска после установки лаунчера, если у пользователя была не пустая .minecraft
|
||||
|
||||
## [1.0.2-rc1] — 2026-10-23
|
||||
### Добавлено
|
||||
### Исправлено
|
||||
- Исправлен баг с возможностью изменять текущую сборку во время установки сезонной
|
||||
- Мелкие исправления
|
||||
+36
-24
@@ -1,6 +1,27 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(BiLauncher VERSION 1.0.0 LANGUAGES CXX C)
|
||||
project(BiLauncher VERSION 1.0.2 LANGUAGES CXX C)
|
||||
|
||||
# Канал сборки (dev / rc / релиз) не хранится в репозитории, а вычисляется
|
||||
# при сборке — см. cmake/LauncherVersion.cmake. CI передаёт полную версию:
|
||||
# -DLAUNCHER_BUILD_VERSION=1.0.1-rc2.
|
||||
set(LAUNCHER_BUILD_VERSION "" CACHE STRING "Полная версия от CI (X.Y.Z[-rcN]); пусто — по тегу и ветке git")
|
||||
set(LAUNCHER_VERSION_NUMERIC "${PROJECT_VERSION}")
|
||||
set(LAUNCHER_SOURCE_DIR "${PROJECT_SOURCE_DIR}")
|
||||
set(LAUNCHER_GENERATED_DIR "${PROJECT_BINARY_DIR}/generated")
|
||||
set(LAUNCHER_VERSION_HEADER "${LAUNCHER_GENERATED_DIR}/launcherversion.h")
|
||||
include(cmake/LauncherVersion.cmake)
|
||||
message(STATUS "Версия лаунчера: ${LAUNCHER_VERSION} (канал ${LAUNCHER_CHANNEL})")
|
||||
add_custom_target(launcher_version
|
||||
COMMAND "${CMAKE_COMMAND}"
|
||||
"-DLAUNCHER_VERSION_NUMERIC=${LAUNCHER_VERSION_NUMERIC}"
|
||||
"-DLAUNCHER_SOURCE_DIR=${LAUNCHER_SOURCE_DIR}"
|
||||
"-DLAUNCHER_VERSION_HEADER=${LAUNCHER_VERSION_HEADER}"
|
||||
"-DLAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION}"
|
||||
-P "${PROJECT_SOURCE_DIR}/cmake/LauncherVersion.cmake"
|
||||
BYPRODUCTS "${LAUNCHER_VERSION_HEADER}"
|
||||
COMMENT "Версия лаунчера из git"
|
||||
VERBATIM)
|
||||
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(QT_NO_PRIVATE_MODULE_WARNING ON)
|
||||
@@ -19,15 +40,6 @@ endif()
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.8)
|
||||
|
||||
# Иконку самого файла приложения (то, что видно до запуска) каждая система берёт
|
||||
# по-своему: Windows — из ресурса, вкомпилированного в .exe, macOS — из .icns
|
||||
# внутри бандла. На Linux такого механизма нет, там иконку даёт .desktop-файл,
|
||||
# который ставится ниже вместе с темой hicolor.
|
||||
#
|
||||
# Растры и .icns/.ico собираются из одного исходника скриптом icons/generate_icons.sh:
|
||||
# у macOS арт по сетке Apple занимает 824 из 1024 точек холста, у Windows и Linux
|
||||
# идёт без полей. Правки иконки вносятся в icons/master/icon_master.png с
|
||||
# последующим перезапуском скрипта, а не в отдельные файлы размеров.
|
||||
set(LAUNCHER_ICON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/icons")
|
||||
|
||||
if(WIN32)
|
||||
@@ -47,10 +59,6 @@ qt_add_executable(BiLauncher
|
||||
${LAUNCHER_APP_ICON}
|
||||
)
|
||||
|
||||
# Иконка окна выставляется в main.cpp через setWindowIcon() и обязана быть
|
||||
# доступна на всех платформах, поэтому лежит в отдельном ресурсе, а не в
|
||||
# ресурсах QML-модуля: путь :/icons/... не зависит от URI модуля.
|
||||
# Размеры 24, 48 и 64 нужны панели задач Windows и значкам рабочих столов Linux.
|
||||
qt_add_resources(BiLauncher "appicon"
|
||||
PREFIX "/icons"
|
||||
BASE "icons"
|
||||
@@ -70,9 +78,11 @@ qt_add_resources(BiLauncher "appicon"
|
||||
icons/icon_512x512@2x.png
|
||||
)
|
||||
|
||||
# OUTPUT_DIRECTORY: по умолчанию модуль с URI BiLauncher кладёт qmldir в
|
||||
# ${CMAKE_BINARY_DIR}/BiLauncher — на Linux это имя занято самим исполняемым
|
||||
# файлом (у него нет расширения), и линковка падает с «Is a directory».
|
||||
# Путь импорта для QML code model Qt Creator / qmlls: модуль лежит в
|
||||
# ${CMAKE_CURRENT_BINARY_DIR}/qml, а не в корне каталога сборки.
|
||||
set(QML_IMPORT_PATH "${CMAKE_CURRENT_BINARY_DIR}/qml"
|
||||
CACHE STRING "Пути импорта QML для Qt Creator" FORCE)
|
||||
|
||||
qt_add_qml_module(BiLauncher
|
||||
URI BiLauncher
|
||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/qml/BiLauncher"
|
||||
@@ -114,6 +124,8 @@ qt_add_qml_module(BiLauncher
|
||||
buildarchiveworker.cpp
|
||||
buildswitcher.h
|
||||
buildswitcher.cpp
|
||||
gamedirimport.h
|
||||
gamedirimport.cpp
|
||||
seasonalbuildservice.h
|
||||
seasonalbuildservice.cpp
|
||||
seasonalpackdownloader.h
|
||||
@@ -144,8 +156,6 @@ set_target_properties(BiLauncher PROPERTIES
|
||||
MACOSX_BUNDLE_GUI_IDENTIFIER com.galeondev.BiLauncher
|
||||
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
|
||||
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION}
|
||||
# Попадает в CFBundleIconFile сгенерированного Info.plist — только имя
|
||||
# файла, без пути: сам .icns кладётся в Contents/Resources выше.
|
||||
MACOSX_BUNDLE_ICON_FILE icon.icns
|
||||
MACOSX_BUNDLE TRUE
|
||||
WIN32_EXECUTABLE TRUE
|
||||
@@ -155,9 +165,8 @@ target_link_libraries(BiLauncher
|
||||
PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||
)
|
||||
|
||||
target_compile_definitions(BiLauncher PRIVATE
|
||||
LAUNCHER_VERSION="${PROJECT_VERSION}"
|
||||
)
|
||||
target_include_directories(BiLauncher PRIVATE "${LAUNCHER_GENERATED_DIR}")
|
||||
add_dependencies(BiLauncher launcher_version)
|
||||
|
||||
if(Qt6WebEngineQuick_FOUND)
|
||||
target_link_libraries(BiLauncher PRIVATE Qt6::WebEngineQuick)
|
||||
@@ -206,8 +215,6 @@ if(UNIX AND NOT APPLE)
|
||||
)
|
||||
install(TARGETS launcher_zlib_reference LIBRARY DESTINATION ${LAUNCHER_ZLIB_DIR})
|
||||
|
||||
# Рабочие столы ищут иконку не по имени файла, а по имени из ключа Icon=
|
||||
# .desktop-файла, перебирая размеры в теме hicolor, — отсюда переименование.
|
||||
foreach(icon_size IN ITEMS 16 24 32 48 64 128 256 512)
|
||||
install(FILES "${LAUNCHER_ICON_DIR}/icon_${icon_size}x${icon_size}.png"
|
||||
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${icon_size}x${icon_size}/apps"
|
||||
@@ -243,3 +250,8 @@ qt_generate_deploy_qml_app_script(
|
||||
DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM
|
||||
DEPLOY_TOOL_OPTIONS ${deploy_opts})
|
||||
install(SCRIPT ${deploy_script})
|
||||
option(BILAUNCHER_BUILD_TESTS "Собрать автотесты (ctest)" OFF)
|
||||
if(BILAUNCHER_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -39,6 +39,9 @@ Window {
|
||||
onMicrosoftLoginFailed: (message) => window.showToast(message, "#cc3333", 8000)
|
||||
onMicrosoftReloginRequired: (profileIndex) => backend.startMicrosoftLogin(profileIndex)
|
||||
onGameOutput: (line) => console.log(line)
|
||||
onGameDirImported: (buildName) => {
|
||||
window.showToast(Loc.t.build.status.imported.arg(buildName), "#4b7a1f", 10000)
|
||||
}
|
||||
onSeasonalInstallFinished: (seasonalId, buildName) => {
|
||||
window.showToast(Loc.t.seasonal.status.readyToLaunch.arg(buildName),
|
||||
"#4b7a1f", 8000)
|
||||
@@ -89,6 +92,38 @@ Window {
|
||||
id: image
|
||||
anchors.fill: parent
|
||||
source: "images/launcherBackground.png"
|
||||
|
||||
Text {
|
||||
id: launcherVersion
|
||||
x: 8
|
||||
y: 8
|
||||
color: "#ffffff"
|
||||
text: Loc.t.app.label.version.arg(Qt.application.version)
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
// Канал сборки из суффикса версии (cmake/LauncherVersion.cmake): у релиза бейджа нет
|
||||
Rectangle {
|
||||
id: channelBadge
|
||||
readonly property var channel: /-(dev|rc)/.exec(Qt.application.version)
|
||||
visible: channel !== null
|
||||
anchors.left: launcherVersion.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: launcherVersion.verticalCenter
|
||||
width: channelLabel.implicitWidth + 8
|
||||
height: channelLabel.implicitHeight + 2
|
||||
radius: 3
|
||||
color: channel && channel[1] === "rc" ? "#c08a1e" : "#b03a3a"
|
||||
|
||||
Text {
|
||||
id: channelLabel
|
||||
anchors.centerIn: parent
|
||||
text: channelBadge.channel ? channelBadge.channel[1].toUpperCase() : ""
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status toast ───────────────────────────────────────────────────────
|
||||
@@ -192,6 +227,10 @@ Window {
|
||||
rotation: 0
|
||||
editable: false
|
||||
hoverEnabled: true
|
||||
// Пока идёт загрузка или установка, профиль и сборку не меняем:
|
||||
// единственное доступное действие — отмена на панели загрузки.
|
||||
enabled: !backend.busy && !backend.gameRunning
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
|
||||
model: backend.profileNames
|
||||
|
||||
@@ -377,8 +416,6 @@ Window {
|
||||
}
|
||||
|
||||
// ── Активная сборка ────────────────────────────────────────────────────
|
||||
// Кнопка занимает место бывшего выпадающего списка версий: и сама сборка,
|
||||
// и её содержимое правятся теперь в модальном окне.
|
||||
Button {
|
||||
id: buildButton
|
||||
x: 624
|
||||
@@ -390,7 +427,8 @@ Window {
|
||||
anchors.rightMargin: 0
|
||||
anchors.bottomMargin: -218
|
||||
hoverEnabled: true
|
||||
enabled: !backend.gameRunning && !backend.switching
|
||||
enabled: !backend.gameRunning && !backend.switching && !backend.seasonalInstalling
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
|
||||
contentItem: Text {
|
||||
text: backend.activeBuildName !== "" ? backend.activeBuildName
|
||||
@@ -464,6 +502,7 @@ Window {
|
||||
anchors.verticalCenter: folderButton.verticalCenter
|
||||
anchors.leftMargin: 8
|
||||
hoverEnabled: true
|
||||
enabled: !backend.busy && !backend.gameRunning
|
||||
|
||||
background: Image {
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/versionsSelect.png" alt="drawing" width="65%"/>
|
||||
</p>
|
||||
|
||||
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей</h3>
|
||||
<h3>Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей</h3>
|
||||
|
||||
<p float="left">
|
||||
<img src="https://projects-live.galeonworx.com/galeon/BiLauncher/raw/branch/master/Screenshots/seasonal.png" alt="drawing" width="75%"/>
|
||||
|
||||
@@ -22,9 +22,9 @@ Dialog {
|
||||
padding: 0
|
||||
width: 960
|
||||
height: 560
|
||||
closePolicy: seasons.backend.seasonalInstalling
|
||||
? Popup.NoAutoClose
|
||||
: (Popup.CloseOnEscape | Popup.CloseOnPressOutside)
|
||||
// Закрывается и во время установки: отменить её можно только на панели
|
||||
// загрузки главного окна, а модальное окно эту панель перекрывает.
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: "#1e1e1e"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Полная версия лаунчера и канал сборки (dev / rc / release).
|
||||
#
|
||||
# В репозитории хранится только число X.Y.Z из project(), одинаковое во всех
|
||||
# ветках, — поэтому merge dev → rc → master не трогает версию. Канал
|
||||
# вычисляется при сборке (по убыванию приоритета):
|
||||
# 1. LAUNCHER_BUILD_VERSION — полная версия от CI: 1.0.1, 1.0.1-rc2;
|
||||
# 2. тег на HEAD: vX.Y.Z — релиз, vX.Y.Z-rcN — rc;
|
||||
# 3. ветка (из переменных CI или git): master — релиз, rc/beta — rc;
|
||||
# 4. всё остальное, в т.ч. сборка без git, — dev.
|
||||
#
|
||||
# Работает в двух режимах:
|
||||
# include() при configure — проверяет входные данные и пишет заголовок;
|
||||
# cmake -P на каждой сборке — чтобы после git checkout без reconfigure
|
||||
# канал и хэш коммита не устаревали.
|
||||
# Входные переменные: LAUNCHER_VERSION_NUMERIC, LAUNCHER_SOURCE_DIR,
|
||||
# LAUNCHER_VERSION_HEADER, LAUNCHER_BUILD_VERSION (необязательна).
|
||||
# Результат: LAUNCHER_VERSION, LAUNCHER_CHANNEL и файл LAUNCHER_VERSION_HEADER.
|
||||
|
||||
set(_numeric "${LAUNCHER_VERSION_NUMERIC}")
|
||||
|
||||
find_package(Git QUIET)
|
||||
function(_launcher_git out)
|
||||
set(${out} "" PARENT_SCOPE)
|
||||
if(NOT GIT_EXECUTABLE)
|
||||
return()
|
||||
endif()
|
||||
execute_process(COMMAND "${GIT_EXECUTABLE}" ${ARGN}
|
||||
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}"
|
||||
RESULT_VARIABLE _rc OUTPUT_VARIABLE _out ERROR_QUIET
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE)
|
||||
if(_rc EQUAL 0)
|
||||
set(${out} "${_out}" PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
set(_full "")
|
||||
if(LAUNCHER_BUILD_VERSION)
|
||||
set(_full "${LAUNCHER_BUILD_VERSION}")
|
||||
string(REGEX REPLACE "^v" "" _full "${_full}")
|
||||
string(REGEX REPLACE "-.*$" "" _base "${_full}")
|
||||
if(NOT _base STREQUAL _numeric)
|
||||
message(FATAL_ERROR "LAUNCHER_BUILD_VERSION=${LAUNCHER_BUILD_VERSION} не совпадает "
|
||||
"с project(BiLauncher VERSION ${_numeric})")
|
||||
endif()
|
||||
else()
|
||||
# Тег vX.Y.Z или vX.Y.Z-rcN ровно на HEAD, относящийся к текущей версии
|
||||
_launcher_git(_tags tag --points-at HEAD)
|
||||
string(REPLACE "\n" ";" _tags "${_tags}")
|
||||
string(REPLACE "." "\\." _numeric_re "${_numeric}")
|
||||
foreach(_tag IN LISTS _tags)
|
||||
if(_tag MATCHES "^v(${_numeric_re}(-rc[0-9]+)?)$")
|
||||
# Релизный тег важнее rc-тега на том же коммите
|
||||
if(NOT _full OR NOT CMAKE_MATCH_2)
|
||||
set(_full "${CMAKE_MATCH_1}")
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_full)
|
||||
if(_full MATCHES "-rc[0-9]*$")
|
||||
set(LAUNCHER_CHANNEL "rc")
|
||||
elseif(_full STREQUAL _numeric)
|
||||
set(LAUNCHER_CHANNEL "release")
|
||||
else()
|
||||
set(LAUNCHER_CHANNEL "dev")
|
||||
endif()
|
||||
set(LAUNCHER_VERSION "${_full}")
|
||||
else()
|
||||
# В CI на detached HEAD имени ветки в git нет — берём его из окружения
|
||||
set(_branch "$ENV{GITHUB_HEAD_REF}")
|
||||
if(NOT _branch AND "$ENV{GITHUB_REF_TYPE}" STREQUAL "branch")
|
||||
set(_branch "$ENV{GITHUB_REF_NAME}")
|
||||
endif()
|
||||
if(NOT _branch)
|
||||
_launcher_git(_branch rev-parse --abbrev-ref HEAD)
|
||||
endif()
|
||||
|
||||
if(_branch STREQUAL "master")
|
||||
set(LAUNCHER_CHANNEL "release")
|
||||
set(LAUNCHER_VERSION "${_numeric}")
|
||||
elseif(_branch STREQUAL "rc" OR _branch STREQUAL "beta")
|
||||
set(LAUNCHER_CHANNEL "rc")
|
||||
set(LAUNCHER_VERSION "${_numeric}-rc")
|
||||
else()
|
||||
set(LAUNCHER_CHANNEL "dev")
|
||||
set(LAUNCHER_VERSION "${_numeric}-dev")
|
||||
_launcher_git(_hash rev-parse --short=7 HEAD)
|
||||
if(_hash)
|
||||
# Неотслеживаемые файлы (каталоги сборки и т.п.) не считаем
|
||||
execute_process(COMMAND "${GIT_EXECUTABLE}" diff --quiet HEAD --
|
||||
WORKING_DIRECTORY "${LAUNCHER_SOURCE_DIR}" RESULT_VARIABLE _dirty ERROR_QUIET)
|
||||
if(NOT _dirty EQUAL 0)
|
||||
set(_hash "${_hash}.dirty")
|
||||
endif()
|
||||
string(APPEND LAUNCHER_VERSION "+${_hash}")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
configure_file("${CMAKE_CURRENT_LIST_DIR}/launcherversion.h.in" "${LAUNCHER_VERSION_HEADER}" @ONLY)
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// Сгенерировано cmake/LauncherVersion.cmake — не редактировать.
|
||||
|
||||
#define LAUNCHER_VERSION "@LAUNCHER_VERSION@" // 1.0.1, 1.0.1-rc2, 1.0.1-dev+a1b2c3d
|
||||
#define LAUNCHER_VERSION_NUMERIC "@LAUNCHER_VERSION_NUMERIC@"
|
||||
#define LAUNCHER_CHANNEL "@LAUNCHER_CHANNEL@" // dev, rc, release
|
||||
@@ -40,8 +40,41 @@ cmake --build build
|
||||
|
||||
Из Qt Creator проект открывается как обычный CMake-проект.
|
||||
|
||||
Версия приложения задаётся в одном месте, в `project(BiLauncher VERSION x.y.z)`. Оттуда она идёт в
|
||||
`LAUNCHER_VERSION`, в ресурс версии `.exe`, в `Info.plist` и в имена файлов релиза.
|
||||
### Версия и канал сборки
|
||||
|
||||
В репозитории хранится только число: `project(BiLauncher VERSION x.y.z)` в `CMakeLists.txt`. Оно
|
||||
одинаковое во всех ветках, поэтому merge `dev` → `rc` → `master` версию не перетирает и не даёт
|
||||
конфликтов. `PROJECT_VERSION` идёт в ресурс версии `.exe`, в `Info.plist` и в установщик: все три
|
||||
принимают только числовую версию.
|
||||
|
||||
Канал (dev / rc / релиз) в коде не записан — его на каждой сборке вычисляет
|
||||
`cmake/LauncherVersion.cmake` и пишет в сгенерированный `<build>/generated/launcherversion.h`
|
||||
(`LAUNCHER_VERSION`, `LAUNCHER_CHANNEL`). Порядок определения:
|
||||
|
||||
1. `-DLAUNCHER_BUILD_VERSION=1.0.1-rc2` — полная версия от CI; база обязана совпадать с
|
||||
`project()`, иначе configure падает;
|
||||
2. тег на HEAD: `vx.y.z` — релиз, `vx.y.z-rcN` — rc;
|
||||
3. ветка (из `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` или git): `master` — релиз, `rc` и `beta` — rc;
|
||||
4. любая другая ветка или сборка без git — dev.
|
||||
|
||||
| Сборка | `LAUNCHER_VERSION` | В окне |
|
||||
|---------------------------|----------------------|-----------------|
|
||||
| релиз (master, `vx.y.z`) | `1.0.1` | `Версия: 1.0.1` |
|
||||
| rc из CI (`vx.y.z-rcN`) | `1.0.1-rc2` | + бейдж RC |
|
||||
| rc локально | `1.0.1-rc` | + бейдж RC |
|
||||
| dev | `1.0.1-dev+a1b2c3d` | + бейдж DEV |
|
||||
|
||||
У dev-сборки после `+` — короткий хэш коммита, `.dirty` — есть незакоммиченные правки в
|
||||
отслеживаемых файлах. Генерация идёт на каждой сборке, так что после `git checkout` другой
|
||||
ветки reconfigure не нужен.
|
||||
|
||||
Полная строка уходит в `QCoreApplication::applicationVersion`, в аргументы запуска игры и в
|
||||
подпись «Версия: …» в левом верхнем углу окна; бейдж канала QML берёт из её суффикса. Почему так,
|
||||
см. [ADR-0002](adr/0002-launcher-version-display.md).
|
||||
|
||||
**Когда поднимать версию.** Номер поднимается в `dev` в начале цикла (сразу после релиза) и
|
||||
дальше приходит в `rc` и `master` через merge. Хотфикс прямо в `rc` или `master` — правка и bump
|
||||
patch там же, затем обратный merge `master` → `rc` → `dev`.
|
||||
|
||||
## Что собирается
|
||||
|
||||
@@ -98,16 +131,29 @@ QML-модуль собирается в `<build>/qml/BiLauncher` (`OUTPUT_DIREC
|
||||
|
||||
### Как выпустить версию
|
||||
|
||||
0. Запустить workflow `tests` вручную (Run workflow) и дождаться зелёного прогона на всех трёх
|
||||
системах, включая дымовую проверку установленного дерева. `release.yml` от тестов не зависит,
|
||||
поэтому этот шаг на совести выпускающего. См. [testing.md](testing.md).
|
||||
1. Поднять версию в `project(BiLauncher VERSION ...)` в `CMakeLists.txt`.
|
||||
2. Добавить в `CHANGELOG.md` раздел `## [x.y.z] — дата`. Его текст станет описанием релиза.
|
||||
3. Закоммитить и запушить тег `vx.y.z`. Для предрелиза — `vx.y.z-rc1` (всё после дефиса делает
|
||||
релиз pre-release).
|
||||
Для пре-релиза можно завести отдельный раздел `## [x.y.z-rcN]`: он берётся в первую очередь,
|
||||
а если его нет — используется `## [x.y.z]`.
|
||||
3. Запустить релиз одним из способов:
|
||||
- **Пуш в `master`** — полноценный релиз `vx.y.z`. Если релиз или тег с этой версией уже есть,
|
||||
прогон ничего не собирает: сначала поднимите версию.
|
||||
- **Пуш в `rc`** — пре-релиз `vx.y.z-rcN`, где `N` — следующий свободный номер для этой версии
|
||||
(`rc1`, `rc2`, ...).
|
||||
- **Тег `vx.y.z`** — полноценный релиз, **тег `vx.y.z-rcN`** — пре-релиз. Пре-релизом
|
||||
становится только тег с суффиксом `-rc`, любой другой суффикс даёт обычный релиз.
|
||||
|
||||
Если тег не совпадает с версией в `CMakeLists.txt` (без учёта суффикса), прогон сразу падает.
|
||||
|
||||
Для веток тег создаёт сам Gitea при публикации релиза, на коммите прогона. Этот тег снова
|
||||
запускает `release.yml`, но прогон видит, что релиз уже есть, и ничего не собирает.
|
||||
|
||||
### Что происходит
|
||||
|
||||
1. **create-release** (Linux) — сверяет тег с версией, вырезает раздел из `CHANGELOG.md` и
|
||||
1. **create-release** (Linux) — определяет тег (из пуша тега или по ветке), сверяет его с версией, вырезает раздел из `CHANGELOG.md` и
|
||||
создаёт **черновик** релиза.
|
||||
2. Параллельно три сборки, каждая прикладывает свои файлы к черновику:
|
||||
- **Linux**: `BiLauncher-<v>-x86_64.AppImage` и `BiLauncher-<v>-linux-x86_64.tar.gz`. Сборка
|
||||
@@ -166,6 +212,9 @@ VS 2022 Build Tools (это 20–40 минут), Git, Node.js, Inno Setup, вк
|
||||
|
||||
**macOS** — скрипта подготовки нет, раннер настроен вручную.
|
||||
|
||||
Те же раннеры гоняют автотесты (`.gitea/workflows/tests.yml`): Linux — на каждый push, все три
|
||||
системы — ночью и по кнопке. Подробности — [testing.md](testing.md).
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -46,16 +46,34 @@
|
||||
3. `migrateLegacyBuildsFile()` переносит старый `versions.json` в `customBuilds.json`. Это должно
|
||||
случиться до следующего шага, иначе поверх старых данных появится пустой файл.
|
||||
4. `loadData()` читает профили и сборки и сортирует их по `id`.
|
||||
5. `ensureDataFiles()` создаёт недостающие файлы.
|
||||
6. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||
5. Если это первый запуск, `importGameDir()` заводит сборку под то, что уже лежит в папке игры
|
||||
(см. ниже).
|
||||
6. `ensureDataFiles()` создаёт недостающие файлы.
|
||||
7. Если `BuildSwitcher` нашёл журнал оборванного переключения, бэкенд сразу продолжает его через
|
||||
`resumeInterrupted()`. Пользователю ничего не предлагается. Если начать переключение заново,
|
||||
оно сначала заархивирует полуразобранную `.minecraft` поверх целого архива прежней сборки.
|
||||
7. Подписка на сигналы сервисов.
|
||||
8. Подписка на сигналы сервисов.
|
||||
|
||||
Все проблемы, найденные по пути (битый JSON, папка без права записи, пропущенные записи сборок),
|
||||
копятся в `m_storageIssues` и уходят одним `launchError` через `QTimer::singleShot(0)`: к этому
|
||||
моменту QML уже подписан на сигнал.
|
||||
|
||||
### Сборка из папки игры
|
||||
|
||||
Первым запуском считается старт, когда нет ни `settings.json`, ни `customBuilds.json`, ни
|
||||
старого `versions.json`. Проверка делается до `loadData()`: битый файл она отводит в `.bak`, а
|
||||
`ensureDataFiles()` потом создаёт пустой. Существующий пустой `customBuilds.json` — это решение
|
||||
пользователя «сборок нет», и импорта тогда нет.
|
||||
|
||||
Если [GameDirImport::detect()](gamedirimport.md) что-то нашёл, запись сборки добавляется сразу,
|
||||
минуя `addCustomBuild()` (тот начал бы установку), становится активной и сохраняется вместе с
|
||||
`activeBuildId`. `BuildSwitcher` не вызывается: сборка описывает то, что уже разложено в папке
|
||||
игры, а активная сборка без архива — штатное состояние. Сама папка игры только читается.
|
||||
|
||||
Имя: `<loaderTitle> <версия>` или `Minecraft <версия>`, а без версии — `build.title.fromGameDir`.
|
||||
Через `QTimer::singleShot(0)` приходит `gameDirImported(buildName)`, раньше сообщения об ошибках
|
||||
хранилища, чтобы оно их не перекрыло. Main.qml показывает по нему зелёную плашку.
|
||||
|
||||
## Файлы на диске
|
||||
|
||||
Все пути берутся из [launcherpaths.h](launcherpaths.md).
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# gamedirimport.h
|
||||
|
||||
`gamedirimport.h`, `gamedirimport.cpp`
|
||||
|
||||
Пространство имён `GameDirImport`: что уже лежит в папке игры, когда лаунчер запускают впервые.
|
||||
Состояния нет, сеть не нужна, файлы только читаются. Вызывает его
|
||||
[LauncherBackend](LauncherBackend.md) при первом запуске, решение описано в
|
||||
[ADR-0001](../adr/0001-import-existing-minecraft.md).
|
||||
|
||||
## detect(gameDir)
|
||||
|
||||
Возвращает `std::optional<Detected>`: `minecraftVersion`, `resolvedVersionId`, `loader`,
|
||||
`loaderVersion` и `source` — откуда взята версия (`launcher_profiles`, `versions` или `content`).
|
||||
|
||||
`nullopt` — импортировать нечего: в `versions/` нет ни одной установленной версии и нет признаков
|
||||
того, что в папке играли. Признаки — непустые `mods`, `saves`, `resourcepacks`, `shaderpacks`,
|
||||
`config` или файлы `options.txt`, `servers.dat`. Служебные файлы других лаунчеров
|
||||
(`launcher_log.txt`, `webcache2`) не считаются: иначе пустая папка официального лаунчера давала
|
||||
бы сборку без версии.
|
||||
|
||||
Версия выбирается так:
|
||||
|
||||
1. профили из `launcher_profiles.json`, у которых `lastVersionId` установлен, по убыванию
|
||||
`lastUsed` (профиль без даты считается самым старым). `latest-release` и `latest-snapshot` без
|
||||
`lastVersionId` пропускаются: без манифеста не узнать, во что они разворачивались;
|
||||
2. установленные версии по убыванию времени правки `<id>/<id>.json`.
|
||||
|
||||
Версия с нечитаемым описанием пропускается. Если в `mods/` есть хотя бы один `.jar`, сначала
|
||||
ищется профиль с `inheritsFrom` (сперва в первом списке, потом во втором), и только потом берётся
|
||||
что есть. Не нашлось ничего — `source` равен `content`, поля версии пустые.
|
||||
|
||||
У профиля с `inheritsFrom` версия игры — это `inheritsFrom`, а `resolvedVersionId` — его `id`,
|
||||
поэтому запустится ровно найденный профиль, даже если модлоадер не опознан (OptiFine, LabyMod).
|
||||
У профиля без `inheritsFrom` версия — сам `id`.
|
||||
|
||||
## loaderOf(versionJson)
|
||||
|
||||
Модлоадер по `libraries[].name` описания профиля:
|
||||
|
||||
| Библиотека | `key` | `version` |
|
||||
|------------|-------|-----------|
|
||||
| `net.fabricmc:fabric-loader:<v>` | `fabric` | `<v>` |
|
||||
| `org.quiltmc:quilt-loader:<v>` | `quilt` | `<v>` |
|
||||
| `net.neoforged:neoforge:<v>` | `neoforge` | `<v>` |
|
||||
| `net.neoforged:forge:<mc>-<v>` | `neoforge` | `<v>` (NeoForge для 1.20.1) |
|
||||
| `net.minecraftforge:forge:<mc>-<v>` | `forge` | `<v>` |
|
||||
|
||||
Префикс `<mc>-` срезается, хвост `-1.7.10` у старых Forge остаётся: в каталоге
|
||||
[ModLoaderVersionService](ModLoaderVersionService.md) версии записаны так же, и карточка сборки
|
||||
найдёт выбранную строку.
|
||||
|
||||
Если таких библиотек нет, смотрит на `id`, который дают установщики: `fabric-loader-<v>-<mc>`,
|
||||
`quilt-loader-<v>-<mc>`, `neoforge-<v>`, `<mc>-forge-<v>`. Не опознано — пустой `key`.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -21,6 +21,19 @@
|
||||
|
||||
На Linux папка лаунчера получается `~/BiLauncher`, без точки, то есть видимая.
|
||||
|
||||
## BILAUNCHER_HOME
|
||||
|
||||
`homeOverride()` читает переменную окружения `BILAUNCHER_HOME`. Если в ней абсолютный путь, он
|
||||
заменяет домашнюю папку (`~` и `%USERPROFILE%` в таблице выше) для всех путей лаунчера, включая
|
||||
папку игры по умолчанию. Пустое или относительное значение не учитывается. Остальной Qt
|
||||
(`QDir::homePath()`, `QStandardPaths`) эту переменную не видит.
|
||||
|
||||
Переменная нужна тестам и дымовой проверке на Windows: там `QDir::homePath()` спрашивает профиль
|
||||
у системы и `USERPROFILE` игнорирует, так что иначе их нельзя увести из настоящего
|
||||
`%APPDATA%\BiLauncher`. Новых возможностей она не даёт: кто управляет окружением процесса, на
|
||||
Linux и macOS и так подменит `HOME`. Чтобы случайно оставленную переменную было видно, `main.cpp`
|
||||
при старте пишет в лог предупреждение с итоговой папкой лаунчера.
|
||||
|
||||
Внутри `rootDir()`:
|
||||
|
||||
```
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
приложения общий контекст OpenGL уже не выставить.
|
||||
2. **`QGuiApplication`**, а не `QApplication`: виджетов в проекте нет.
|
||||
3. **Имя и версия.** `applicationName` = `BiLauncher`, `applicationVersion` = `LAUNCHER_VERSION`
|
||||
(берётся из `project(... VERSION)` в CMake), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||
(в CMake это `PROJECT_VERSION` плюс `LAUNCHER_VERSION_SUFFIX`, например `1.0.1-dev`; QML читает её
|
||||
через `Qt.application.version`), организация `GaleonDev`, домен `galeonworx.com`. На
|
||||
пути к данным лаунчера это не влияет, см. [launcherpaths.h](launcherpaths.md).
|
||||
4. **Иконка окна.** На Windows и Linux в `QIcon` складываются все PNG из ресурса `:/icons/`
|
||||
(16–512 и `@2x`). На macOS этот шаг пропущен: там иконку берут из `icon.icns` внутри бандла.
|
||||
|
||||
+5
-1
@@ -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).
|
||||
|
||||
## Общая схема
|
||||
|
||||
@@ -121,6 +122,7 @@ LauncherBackend ──────────────── Loc (Localizati
|
||||
|---|---|
|
||||
| [BuildSwitcher](cpp/BuildSwitcher.md) | смена активной сборки, раскатка пака, продолжение после падения |
|
||||
| [BuildArchiveWorker](cpp/BuildArchiveWorker.md) | файловые операции в отдельном потоке |
|
||||
| [gamedirimport.h](cpp/gamedirimport.md) | сборка по содержимому папки игры при первом запуске |
|
||||
| [SeasonalBuildService](cpp/SeasonalBuildService.md) | каталог сезонных сборок и его формат |
|
||||
| [SeasonalPackDownloader](cpp/SeasonalPackDownloader.md) | загрузка архива сезонной сборки |
|
||||
| [launcherpaths.h](cpp/launcherpaths.md) | все пути к данным лаунчера |
|
||||
@@ -131,12 +133,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 |
|
||||
|
||||
---
|
||||
|
||||
+7
-2
@@ -25,6 +25,10 @@
|
||||
сборки справа сверху, кнопки папки и настроек под списком профилей. Кнопка сезонных сборок стоит в
|
||||
правом нижнем углу, потому что остальные углы заняты.
|
||||
|
||||
В левом верхнем углу фона стоит подпись `launcherVersion` — «Версия: 1.0.1-dev». Текст из
|
||||
`app.label.version`, номер из `Qt.application.version`, то есть из `LAUNCHER_VERSION`
|
||||
(см. [main.cpp](../cpp/main.md)).
|
||||
|
||||
Обе панели прогресса (загрузки и смены сборки) стоят в левом нижнем углу на одних и тех же якорях.
|
||||
Одновременно они не появляются: пока лаунчер занят загрузкой, бэкенд не начнёт смену сборки, и
|
||||
наоборот. Панель смены сборки отменить нельзя.
|
||||
@@ -63,8 +67,9 @@
|
||||
| `seasonalInstallFinished` | сообщение «сборка установлена, можно играть» |
|
||||
| `javaRuntimeInstalled` | обновляет строку Java в настройках |
|
||||
|
||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Кнопка активной сборки выключена во время
|
||||
игры и смены сборки.
|
||||
Кнопка запуска выключена, пока `busy` или `gameRunning`. Список профилей выключен, пока `busy`;
|
||||
кнопка активной сборки — пока `busy` или `gameRunning`. Так во время загрузки и установки (в том
|
||||
числе сезонной сборки) доступна только отмена на панели загрузки.
|
||||
|
||||
## Профили
|
||||
|
||||
|
||||
@@ -48,8 +48,9 @@
|
||||
Каталог обновляется при каждом открытии окна (`refreshSeasonalCatalog(false)`). Свежий кэш отвечает
|
||||
без сети.
|
||||
|
||||
Пока идёт установка, окно закрывается только кнопкой. Отменить установку можно на панели загрузки
|
||||
главного окна, и случайный щелчок мимо окна не должен её закрыть.
|
||||
Окно закрывается по Esc и щелчку мимо него и во время установки: отменить установку можно только
|
||||
на панели загрузки главного окна, которую модальное окно перекрывает. Установка при закрытии окна
|
||||
продолжается.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Автотесты
|
||||
|
||||
Тесты лежат в `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` | переключение сборок туда и обратно, продолжение после обрыва, пак |
|
||||
| unit | `tst_gamedirimport` | выбор версии по `launcher_profiles.json` и `versions/`, модлоадер по библиотекам и id, папка не меняется |
|
||||
| net | `tst_fakeinternet` | самопроверка перехвата HTTPS (см. ниже) |
|
||||
| net | `tst_versionmanifestservice` | каталог Mojang: разбор, кеш, офлайн, общие запросы |
|
||||
| net | `tst_seasonalbuildservice` | каталог сезонных сборок: проверки полей, формат, кеш по адресу |
|
||||
| net | `tst_modloaderversionservice` | Fabric, Quilt, Forge (с рекомендованными), NeoForge обе схемы |
|
||||
| net | `tst_javaruntimeservice` | Adoptium и Mojang, частичные отказы, кеш, запасной x64 на ARM |
|
||||
| net | `tst_authservice` | Ely.by: пароль, 2FA, продление токена, authlib-injector |
|
||||
| net | `tst_msaauthservice` | вся цепочка Microsoft → Xbox → XSTS → Minecraft, коды ошибок XSTS |
|
||||
| net | `tst_versioninstaller` | установка версии целиком, повторы, контрольные суммы, отмена, очередь |
|
||||
| net | `tst_seasonalpackdownloader` | загрузка пака: sha256, обрыв, отмена |
|
||||
| net | `tst_javainstaller` | Temurin zip и tar.gz, дерево Mojang, zip-slip, отмена |
|
||||
| net | `tst_modloaderinstaller` | Fabric по сети, Forge через подставную java, откат после сбоя |
|
||||
| backend | `tst_launcherbackend` | всё, что зовёт QML: профили, сборки, настройки, миграция, битые файлы, каталог версий, запуск офлайн / Ely.by с 2FA / Microsoft, Java, сезонная сборка от каталога до раскатки обновления |
|
||||
| qml | `tst_qml` | Qt Quick Test: `DarkCombo`, `LabelledField`, `ProgressPanel`, `LoaderRow`, диалоги с настоящим `LauncherBackend` |
|
||||
| qml | `tst_qml_smoke` | `Main.qml` поднимается, как в `main.cpp`, без единого предупреждения QML, в том числе после смены языка |
|
||||
|
||||
Ночью и перед релизом ещё собирается Release, ставится через `cmake --install` и запускается на
|
||||
10 секунд (`tests/scripts/smoke_installed.sh` / `.ps1`). Это ловит то, чего не видят тесты:
|
||||
недоложенные деплоем модули Qt и плагины, ссылки на библиотеки вне дерева, неподписанный бандл.
|
||||
|
||||
## Как тесты изолированы
|
||||
|
||||
**Файлы.** `LauncherPaths` строит пути от домашней папки. Общий `main` тестов
|
||||
(`tests/common/testenv.h`) до создания приложения направляет `BILAUNCHER_HOME` (а заодно `HOME`
|
||||
и `USERPROFILE`) на временную папку, и каждый процесс работает в своей, на всех ОС, параллельно.
|
||||
`BILAUNCHER_HOME` нужна ради Windows: там `QDir::homePath()` берёт профиль у системы и `HOME`
|
||||
с `USERPROFILE` не читает. Дочерние процессы наследуют переменную. Если папка лаунчера или папка
|
||||
игры по умолчанию всё же оказалась вне песочницы, процесс сразу завершается с кодом 2 и ничего не
|
||||
трогает. Дымовая проверка установленной сборки на Windows (`smoke_installed.ps1`) тоже запускает
|
||||
лаунчер с `BILAUNCHER_HOME` на временную папку.
|
||||
|
||||
**Сеть.** Адреса серверов в сервисах зашиты константами, а свои `QNetworkAccessManager` они
|
||||
создают сами. Зато прокси и SSL-настройки у Qt общие на процесс, и код лаунчера их не трогает.
|
||||
`FakeInternet` (`tests/common/fakeinternet.h`) ставит прокси на локальный порт и отключает
|
||||
проверку сертификата. Каждое https-соединение приходит к нему как `CONNECT host:443`, переводится
|
||||
в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов.
|
||||
Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет
|
||||
себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным
|
||||
сообщением, а в CI на всех ОС (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка.
|
||||
QML-тесты также завершаются ошибкой, если стенд не поднялся.
|
||||
|
||||
`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_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`. Для заданного порядка ответов установите `deferred = true`
|
||||
и вызовите `releaseResponse(url)` после обработки другого ответа клиентом.
|
||||
5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или
|
||||
классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и
|
||||
`Fixtures::readTemplate()`.
|
||||
|
||||
У moc есть ограничение: сырые строки `R"(...)"` и длинные вложенные инициализаторы `{{...}}`,
|
||||
разбитые на строки, внутри файла с `Q_OBJECT` сбивают его разбор («Not a signal or slot
|
||||
declaration» или пустой `.moc`). Такие данные выносите в фикстуры или собирайте по шагам.
|
||||
|
||||
---
|
||||
|
||||
При создании этого документа использовался ИИ.
|
||||
@@ -0,0 +1,244 @@
|
||||
#include "gamedirimport.h"
|
||||
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QList>
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
// Признаки того, что в папке играли: это принадлежит сборке. Служебные файлы
|
||||
// чужих лаунчеров (launcher_log.txt, webcache2 и т. п.) сюда не входят, иначе
|
||||
// пустая папка официального лаунчера давала бы сборку без версии.
|
||||
const QStringList kContentMarkers = {QStringLiteral("mods"),
|
||||
QStringLiteral("saves"),
|
||||
QStringLiteral("resourcepacks"),
|
||||
QStringLiteral("shaderpacks"),
|
||||
QStringLiteral("config"),
|
||||
QStringLiteral("options.txt"),
|
||||
QStringLiteral("servers.dat")};
|
||||
|
||||
struct Candidate
|
||||
{
|
||||
QString id;
|
||||
QJsonObject json;
|
||||
bool inherits = false;
|
||||
};
|
||||
|
||||
QJsonObject readObject(const QString &path)
|
||||
{
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
return QJsonDocument::fromJson(file.readAll()).object();
|
||||
}
|
||||
|
||||
QString versionJsonPath(const QString &gameDir, const QString &id)
|
||||
{
|
||||
return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
|
||||
}
|
||||
|
||||
bool hasContent(const QString &gameDir)
|
||||
{
|
||||
for (const QString &name : kContentMarkers) {
|
||||
const QFileInfo info(gameDir + u'/' + name);
|
||||
if (info.isFile())
|
||||
return true;
|
||||
if (info.isDir() && !QDir(info.filePath()).isEmpty())
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasMods(const QString &gameDir)
|
||||
{
|
||||
return !QDir(gameDir + QStringLiteral("/mods"))
|
||||
.entryList({QStringLiteral("*.jar")}, QDir::Files)
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
QDateTime parseLastUsed(const QString &text)
|
||||
{
|
||||
QDateTime when = QDateTime::fromString(text, Qt::ISODateWithMs);
|
||||
if (!when.isValid())
|
||||
when = QDateTime::fromString(text, Qt::ISODate);
|
||||
return when;
|
||||
}
|
||||
|
||||
// Версии из launcher_profiles.json, которые есть в versions/, свежие сверху.
|
||||
// Профили latest-release/latest-snapshot без lastVersionId пропускаются: без
|
||||
// манифеста не узнать, во что они разворачивались.
|
||||
QStringList profileVersions(const QString &gameDir, const QStringList &installed)
|
||||
{
|
||||
const QJsonObject profiles =
|
||||
readObject(gameDir + QStringLiteral("/launcher_profiles.json"))
|
||||
.value(QStringLiteral("profiles"))
|
||||
.toObject();
|
||||
|
||||
struct Used
|
||||
{
|
||||
QString id;
|
||||
QDateTime when;
|
||||
};
|
||||
QList<Used> used;
|
||||
for (auto it = profiles.constBegin(); it != profiles.constEnd(); ++it) {
|
||||
const QJsonObject profile = it.value().toObject();
|
||||
const QString id = profile.value(QStringLiteral("lastVersionId")).toString();
|
||||
if (id.isEmpty() || !installed.contains(id))
|
||||
continue;
|
||||
used.append({id, parseLastUsed(profile.value(QStringLiteral("lastUsed")).toString())});
|
||||
}
|
||||
// Профиль без даты считается самым старым.
|
||||
std::stable_sort(used.begin(), used.end(), [](const Used &a, const Used &b) {
|
||||
if (a.when.isValid() != b.when.isValid())
|
||||
return a.when.isValid();
|
||||
return a.when > b.when;
|
||||
});
|
||||
|
||||
QStringList ids;
|
||||
for (const Used &u : used) {
|
||||
if (!ids.contains(u.id))
|
||||
ids << u.id;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Установленные версии по времени правки описания, свежие сверху.
|
||||
QStringList versionsByMtime(const QString &gameDir, QStringList installed)
|
||||
{
|
||||
std::stable_sort(installed.begin(), installed.end(), [&](const QString &a, const QString &b) {
|
||||
return QFileInfo(versionJsonPath(gameDir, a)).lastModified()
|
||||
> QFileInfo(versionJsonPath(gameDir, b)).lastModified();
|
||||
});
|
||||
return installed;
|
||||
}
|
||||
|
||||
QList<Candidate> load(const QString &gameDir, const QStringList &ids)
|
||||
{
|
||||
QList<Candidate> out;
|
||||
for (const QString &id : ids) {
|
||||
const QJsonObject json = readObject(versionJsonPath(gameDir, id));
|
||||
// Битое описание не запустится — такую версию не предлагаем.
|
||||
if (json.isEmpty())
|
||||
continue;
|
||||
out.append({id, json, !json.value(QStringLiteral("inheritsFrom")).toString().isEmpty()});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// "1.20.1-47.2.0" → "47.2.0". Хвост "-1.7.10" у старых Forge остаётся: в
|
||||
// каталоге модлоадеров версии записаны так же.
|
||||
QString stripGamePrefix(const QString &version, const QString &game)
|
||||
{
|
||||
if (!game.isEmpty() && version.startsWith(game + u'-'))
|
||||
return version.mid(game.size() + 1);
|
||||
return version;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
namespace GameDirImport {
|
||||
|
||||
LoaderInfo loaderOf(const QJsonObject &versionJson)
|
||||
{
|
||||
const QString game = versionJson.value(QStringLiteral("inheritsFrom")).toString();
|
||||
|
||||
for (const QJsonValue &value : versionJson.value(QStringLiteral("libraries")).toArray()) {
|
||||
// group:artifact:version[:classifier]
|
||||
const QStringList parts = value.toObject().value(QStringLiteral("name")).toString().split(u':');
|
||||
if (parts.size() < 3)
|
||||
continue;
|
||||
const QString coord = parts[0] + u':' + parts[1];
|
||||
const QString version = parts[2];
|
||||
if (coord == QLatin1String("net.fabricmc:fabric-loader"))
|
||||
return {QStringLiteral("fabric"), version};
|
||||
if (coord == QLatin1String("org.quiltmc:quilt-loader"))
|
||||
return {QStringLiteral("quilt"), version};
|
||||
if (coord == QLatin1String("net.neoforged:neoforge"))
|
||||
return {QStringLiteral("neoforge"), version};
|
||||
// NeoForge для 1.20.1 публиковался под старым именем артефакта.
|
||||
if (coord == QLatin1String("net.neoforged:forge"))
|
||||
return {QStringLiteral("neoforge"), stripGamePrefix(version, game)};
|
||||
if (coord == QLatin1String("net.minecraftforge:forge"))
|
||||
return {QStringLiteral("forge"), stripGamePrefix(version, game)};
|
||||
}
|
||||
|
||||
// Библиотек модлоадера в описании может не быть (их вынесли в родителя или
|
||||
// описание урезали руками) — тогда смотрим на имя, которое дают установщики.
|
||||
const QString id = versionJson.value(QStringLiteral("id")).toString();
|
||||
for (const auto &[key, prefix] : {std::pair{QStringLiteral("fabric"), QStringLiteral("fabric-loader-")},
|
||||
std::pair{QStringLiteral("quilt"), QStringLiteral("quilt-loader-")}}) {
|
||||
const QString suffix = u'-' + game;
|
||||
if (!game.isEmpty() && id.startsWith(prefix) && id.endsWith(suffix))
|
||||
return {key, id.mid(prefix.size(), id.size() - prefix.size() - suffix.size())};
|
||||
}
|
||||
if (id.startsWith(QLatin1String("neoforge-")))
|
||||
return {QStringLiteral("neoforge"), id.mid(9)};
|
||||
const QString forgeInfix = game + QStringLiteral("-forge-");
|
||||
if (!game.isEmpty() && id.startsWith(forgeInfix))
|
||||
return {QStringLiteral("forge"), id.mid(forgeInfix.size())};
|
||||
return {};
|
||||
}
|
||||
|
||||
std::optional<Detected> detect(const QString &gameDir)
|
||||
{
|
||||
if (gameDir.isEmpty() || !QFileInfo(gameDir).isDir())
|
||||
return std::nullopt;
|
||||
|
||||
const QStringList installed = VersionLoader::installedVersions(gameDir);
|
||||
const bool content = hasContent(gameDir);
|
||||
if (installed.isEmpty() && !content)
|
||||
return std::nullopt;
|
||||
|
||||
const QList<Candidate> fromProfiles = load(gameDir, profileVersions(gameDir, installed));
|
||||
const QList<Candidate> fromVersions = load(gameDir, versionsByMtime(gameDir, installed));
|
||||
|
||||
// С модами в папке ванильная версия почти наверняка не та, с которой играли:
|
||||
// сначала ищем профиль модлоадера, и только потом берём что есть.
|
||||
const Candidate *chosen = nullptr;
|
||||
QString source;
|
||||
auto pick = [&](const QList<Candidate> &list, const QString &from, bool needInherits) {
|
||||
if (chosen)
|
||||
return;
|
||||
for (const Candidate &c : list) {
|
||||
if (!needInherits || c.inherits) {
|
||||
chosen = &c;
|
||||
source = from;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if (hasMods(gameDir)) {
|
||||
pick(fromProfiles, QStringLiteral("launcher_profiles"), true);
|
||||
pick(fromVersions, QStringLiteral("versions"), true);
|
||||
}
|
||||
pick(fromProfiles, QStringLiteral("launcher_profiles"), false);
|
||||
pick(fromVersions, QStringLiteral("versions"), false);
|
||||
|
||||
Detected result;
|
||||
if (!chosen) {
|
||||
result.source = QStringLiteral("content");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.source = source;
|
||||
if (chosen->inherits) {
|
||||
result.minecraftVersion = chosen->json.value(QStringLiteral("inheritsFrom")).toString();
|
||||
result.resolvedVersionId = chosen->id;
|
||||
const LoaderInfo loader = loaderOf(chosen->json);
|
||||
result.loader = loader.key;
|
||||
result.loaderVersion = loader.version;
|
||||
} else {
|
||||
result.minecraftVersion = chosen->id;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
// Что уже разложено в папке игры, когда лаунчер запускают впервые. Только
|
||||
// читает файлы: .minecraft не меняется ни при каком исходе.
|
||||
namespace GameDirImport {
|
||||
|
||||
// Модлоадер, найденный в описании профиля versions/<id>/<id>.json.
|
||||
struct LoaderInfo
|
||||
{
|
||||
QString key; // "" | forge | fabric | neoforge | quilt
|
||||
QString version; // в том же виде, что и в каталоге ModLoaderVersionService
|
||||
};
|
||||
|
||||
// Сборка, которую стоит завести под содержимое папки игры.
|
||||
struct Detected
|
||||
{
|
||||
QString minecraftVersion; // inheritsFrom профиля, а без него — сам id
|
||||
QString resolvedVersionId; // id профиля, если он наследует другую версию
|
||||
QString loader;
|
||||
QString loaderVersion;
|
||||
// Откуда взята версия: "launcher_profiles", "versions" или "content" —
|
||||
// версии не нашлось, но моды или миры есть. Для тестов и отладки.
|
||||
QString source;
|
||||
};
|
||||
|
||||
// Модлоадер по библиотекам профиля, а если по ним не понять — по его id.
|
||||
// Незнакомый профиль (OptiFine, LabyMod) даёт пустой key.
|
||||
LoaderInfo loaderOf(const QJsonObject &versionJson);
|
||||
|
||||
// nullopt — в папке нет ни установленных версий, ни модов, миров и настроек игры.
|
||||
std::optional<Detected> detect(const QString &gameDir);
|
||||
|
||||
}
|
||||
+6
-1
@@ -8,6 +8,7 @@
|
||||
#include <QFileInfo>
|
||||
#include <QHash>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
|
||||
namespace {
|
||||
@@ -46,7 +47,11 @@ bool isNativeLibraryFile(const QString &fileName)
|
||||
for (const QString &suffix : suffixes)
|
||||
if (fileName.endsWith(suffix, Qt::CaseInsensitive))
|
||||
return true;
|
||||
return fileName.contains(QStringLiteral(".so.")); // libfoo.so.1
|
||||
// Версионированные so (libfoo.so.1, libfoo.so.1.2.3). Хвост — строго цифры,
|
||||
// иначе в натив попадают служебные файлы LWJGL вроде liblwjgl.so.sha1 / .so.git.
|
||||
static const QRegularExpression versionedSo(QStringLiteral("\\.so(\\.\\d+)+$"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
return versionedSo.match(fileName).hasMatch();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "launcherversion.h"
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QObject>
|
||||
|
||||
+13
-1
@@ -9,7 +9,7 @@
|
||||
"en": "English"
|
||||
},
|
||||
"_readme": [
|
||||
"Единственный файл со всеми текстами интерфейса. Правится руками.",
|
||||
"Файл локализации",
|
||||
"Ключ: <домен>.<вид>.<имя>, сегменты в lowerCamelCase.",
|
||||
"Домены: app, common, settings, profile, build, seasonal, version, java,",
|
||||
"loader, auth, launch, game, switch, storage, zlib.",
|
||||
@@ -31,6 +31,10 @@
|
||||
}
|
||||
},
|
||||
"strings": {
|
||||
"app.label.version": {
|
||||
"ru": "Версия: %1",
|
||||
"en": "Version: %1"
|
||||
},
|
||||
"app.title": {
|
||||
"ru": "BiLauncher",
|
||||
"en": "BiLauncher"
|
||||
@@ -379,6 +383,10 @@
|
||||
"ru": "Активна сборка «%1»",
|
||||
"en": "Build \"%1\" is active"
|
||||
},
|
||||
"build.status.imported": {
|
||||
"ru": "В папке игры уже были файлы Minecraft, под них создана сборка «%1». Проверьте её версию и модлоадер в окне сборок.",
|
||||
"en": "The game folder already had Minecraft files, so the build \"%1\" was created for them. Check its version and mod loader in the builds window."
|
||||
},
|
||||
"build.status.named": {
|
||||
"ru": "Сборка «%1»: %2",
|
||||
"en": "Build \"%1\": %2"
|
||||
@@ -387,6 +395,10 @@
|
||||
"ru": "Готова к запуску",
|
||||
"en": "Ready to launch"
|
||||
},
|
||||
"build.title.fromGameDir": {
|
||||
"ru": "Сборка из папки игры",
|
||||
"en": "Build from the game folder"
|
||||
},
|
||||
"build.title.numbered": {
|
||||
"ru": "Сборка %1",
|
||||
"en": "Build %1"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "msaauthservice.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "buildswitcher.h"
|
||||
#include "gamedirimport.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
@@ -97,11 +98,20 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
if (!LauncherPaths::ensureRootExists(&storageError))
|
||||
m_storageIssues << Loc::text("storage.error.launcherDirFailed").arg(storageError);
|
||||
|
||||
// Первый запуск — ни настроек, ни списка сборок, ни старого versions.json.
|
||||
// Смотреть надо до loadData(): битый файл она отводит в .bak, а
|
||||
// ensureDataFiles() создаёт пустые. Пустой customBuilds.json — это уже
|
||||
// решение пользователя «сборок нет», и сборку из папки игры мы не заводим.
|
||||
const bool firstRun = !QFile::exists(LauncherPaths::settingsFile())
|
||||
&& !QFile::exists(LauncherPaths::customBuildsFile())
|
||||
&& !QFile::exists(LauncherPaths::legacyCustomBuildsFile());
|
||||
|
||||
loadSettings();
|
||||
// Порядок важен: миграция должна успеть до того, как ensureDataFiles()
|
||||
// создаст пустой customBuilds.json поверх старых данных.
|
||||
migrateLegacyBuildsFile();
|
||||
loadData();
|
||||
const QString importedBuild = firstRun && m_builds.isEmpty() ? importGameDir() : QString();
|
||||
ensureDataFiles();
|
||||
|
||||
// Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
|
||||
@@ -370,6 +380,10 @@ LauncherBackend::LauncherBackend(QObject *parent)
|
||||
});
|
||||
|
||||
// Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
|
||||
// Ошибки хранилища идут следом: плашка одна, и важнее показать их.
|
||||
if (!importedBuild.isEmpty()) {
|
||||
QTimer::singleShot(0, this, [this, importedBuild] { emit gameDirImported(importedBuild); });
|
||||
}
|
||||
if (!m_storageIssues.isEmpty()) {
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
emit launchError(m_storageIssues.join(u'\n'));
|
||||
@@ -2483,6 +2497,36 @@ bool LauncherBackend::migrateLegacyBuildsFile()
|
||||
return true;
|
||||
}
|
||||
|
||||
// Сборка описывает то, что уже разложено в .minecraft, поэтому BuildSwitcher не
|
||||
// нужен: активная сборка без архива — штатное состояние. Через addCustomBuild()
|
||||
// не идём, чтобы при старте не началась установка.
|
||||
QString LauncherBackend::importGameDir()
|
||||
{
|
||||
const std::optional<GameDirImport::Detected> found = GameDirImport::detect(gameDir());
|
||||
if (!found)
|
||||
return {};
|
||||
|
||||
CustomBuild build;
|
||||
build.id = m_nextBuildId++;
|
||||
build.minecraftVersion = found->minecraftVersion;
|
||||
build.resolvedVersionId = found->resolvedVersionId;
|
||||
build.loader = found->loader;
|
||||
build.loaderVersion = found->loaderVersion;
|
||||
if (build.minecraftVersion.isEmpty()) {
|
||||
build.name = Loc::text("build.title.fromGameDir");
|
||||
} else if (const auto loader = loaderFromKey(build.loader)) {
|
||||
build.name = loaderTitle(*loader) + u' ' + build.minecraftVersion;
|
||||
} else {
|
||||
build.name = QStringLiteral("Minecraft ") + build.minecraftVersion;
|
||||
}
|
||||
m_builds.append(build);
|
||||
|
||||
m_settings.insert(QStringLiteral("activeBuildId"), build.id);
|
||||
saveSettings();
|
||||
saveCustomBuilds();
|
||||
return build.name;
|
||||
}
|
||||
|
||||
// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
|
||||
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
|
||||
void LauncherBackend::ensureDataFiles()
|
||||
|
||||
@@ -232,6 +232,9 @@ signals:
|
||||
// Сезонная сборка установлена и активна — можно запускать игру.
|
||||
void seasonalInstallFinished(const QString &seasonalId, const QString &buildName);
|
||||
|
||||
// Первый запуск: по содержимому папки игры заведена активная сборка.
|
||||
void gameDirImported(const QString &buildName);
|
||||
|
||||
// Сборка Java установлена: окно настроек обновляет подпись, не переоткрываясь.
|
||||
void javaRuntimeInstalled(const QString &runtimeId);
|
||||
|
||||
@@ -313,6 +316,9 @@ private:
|
||||
// Перекладывает <root>/versions.json в customBuilds.json при первом запуске
|
||||
// после переименования. true — миграция прошла, читать нужно новый файл.
|
||||
bool migrateLegacyBuildsFile();
|
||||
// Первый запуск: заводит активную сборку под то, что уже лежит в папке игры.
|
||||
// Папку игры только читает. Возвращает имя сборки или пустую строку.
|
||||
QString importGameDir();
|
||||
|
||||
void invalidateCatalog();
|
||||
void rebuildCatalog() const;
|
||||
|
||||
+20
-3
@@ -3,21 +3,38 @@
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kFolderName = QStringLiteral("BiLauncher");
|
||||
|
||||
// Домашняя папка, от которой строятся все пути (см. LauncherPaths::homeOverride()).
|
||||
QString homeBase()
|
||||
{
|
||||
const QString overridden = LauncherPaths::homeOverride();
|
||||
return overridden.isEmpty() ? QDir::homePath() : overridden;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString LauncherPaths::homeOverride()
|
||||
{
|
||||
// Относительный путь зависел бы от текущей папки процесса — такой не принимаем.
|
||||
const QString value = QDir::fromNativeSeparators(qEnvironmentVariable("BILAUNCHER_HOME"));
|
||||
if (value.isEmpty() || !QDir::isAbsolutePath(value))
|
||||
return QString();
|
||||
return QDir::cleanPath(value);
|
||||
}
|
||||
|
||||
QString LauncherPaths::containerDir()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return QDir::homePath() + QStringLiteral("/AppData/Roaming");
|
||||
return homeBase() + QStringLiteral("/AppData/Roaming");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
return QDir::homePath() + QStringLiteral("/Library/Application Support");
|
||||
return homeBase() + QStringLiteral("/Library/Application Support");
|
||||
#else // Linux и прочие Unix-системы
|
||||
return QDir::homePath();
|
||||
return homeBase();
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
namespace LauncherPaths {
|
||||
|
||||
// Все пути строятся от домашней папки пользователя (QDir::homePath()). Переменная
|
||||
// окружения BILAUNCHER_HOME с абсолютным путём подменяет её — только для путей
|
||||
// лаунчера (папка лаунчера и папка игры по умолчанию), остальной Qt её не видит.
|
||||
// Нужна тестам и дымовой проверке на Windows: там QDir::homePath() спрашивает
|
||||
// профиль у системы и HOME/USERPROFILE игнорирует. Пустое или относительное
|
||||
// значение не учитывается. Возвращает подменённую папку или пустую строку.
|
||||
QString homeOverride();
|
||||
|
||||
QString containerDir();
|
||||
QString rootDir();
|
||||
QString defaultMinecraftDir();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "launcherpaths.h"
|
||||
#include "launcherversion.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QQmlApplicationEngine>
|
||||
@@ -33,6 +37,10 @@ int main(int argc, char *argv[])
|
||||
|
||||
QGuiApplication::setDesktopFileName(QStringLiteral("bilauncher"));
|
||||
|
||||
// Случайно оставленная BILAUNCHER_HOME увела бы данные в чужую папку — пусть это будет видно в логе.
|
||||
if (!LauncherPaths::homeOverride().isEmpty())
|
||||
qWarning().noquote() << "BILAUNCHER_HOME: данные лаунчера в" << QDir::toNativeSeparators(LauncherPaths::rootDir());
|
||||
|
||||
if (!Localization::instance().load())
|
||||
return -1;
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
{
|
||||
int outstanding = 2;
|
||||
QHash<QString, QList<LoaderVersionEntry>> merged;
|
||||
QHash<QString, QList<LoaderVersionEntry>> legacy;
|
||||
QHash<QString, QString> recommended;
|
||||
bool primaryOk = false;
|
||||
QString warning;
|
||||
@@ -258,6 +259,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto it = state->legacy.cbegin(); it != state->legacy.cend(); ++it)
|
||||
state->merged[it.key()] += it.value();
|
||||
for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
|
||||
const QString promoted = state->recommended.value(it.key());
|
||||
if (promoted.isEmpty())
|
||||
@@ -317,10 +320,8 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game
|
||||
connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
|
||||
legacy->deleteLater();
|
||||
if (legacy->error() == QNetworkReply::NoError) {
|
||||
const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||
/*legacyNeoForge=*/true);
|
||||
for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
|
||||
state->merged[it.key()] += it.value();
|
||||
state->legacy = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
|
||||
/*legacyNeoForge=*/true);
|
||||
}
|
||||
settle();
|
||||
});
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
; Установщик BiLauncher для Windows (Inno Setup 6).
|
||||
;
|
||||
; Собирается в CI после `cmake --install`:
|
||||
; iscc /DAppVersion=1.0.0 /DSourceDir=C:\...\stage /DOutputDir=C:\...\dist packaging\windows\BiLauncher.iss
|
||||
; SourceDir — префикс установки: bin\ с BiLauncher.exe и развёрнутым windeployqt Qt,
|
||||
; share\doc\BiLauncher\ с лицензиями.
|
||||
|
||||
#ifndef AppVersion
|
||||
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||
#ifndef AppVersion
|
||||
#error Передайте версию: /DAppVersion=X.Y.Z
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#error Передайте префикс установки: /DSourceDir=...
|
||||
#error Передайте префикс установки: /DSourceDir=...
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
|
||||
#if Pos("-", AppVersion) > 0
|
||||
#define NumericVersion Copy(AppVersion, 1, Pos("-", AppVersion) - 1)
|
||||
#else
|
||||
#define NumericVersion AppVersion
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
; AppId не менять между версиями — по нему обновление находит прежнюю установку.
|
||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||
AppName=BiLauncher
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName=BiLauncher {#AppVersion}
|
||||
AppPublisher=GaleonDev
|
||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||
VersionInfoVersion={#AppVersion}
|
||||
DefaultDirName={autopf}\BiLauncher
|
||||
DefaultGroupName=BiLauncher
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||
SetupIconFile=..\..\icons\icon.ico
|
||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
LZMANumBlockThreads=4
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
WizardStyle=modern
|
||||
AppId={{6E0B5C1D-8F2A-4C7E-9B3D-2A1F4E5C7B90}
|
||||
AppName=BiLauncher
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName=BiLauncher {#AppVersion}
|
||||
AppPublisher=GaleonDev
|
||||
AppPublisherURL=https://projects-live.galeonworx.com/galeon/BiLauncher
|
||||
AppSupportURL=https://projects-live.galeonworx.com/galeon/BiLauncher/issues
|
||||
VersionInfoVersion={#NumericVersion}
|
||||
VersionInfoTextVersion={#AppVersion}
|
||||
VersionInfoProductTextVersion={#AppVersion}
|
||||
DefaultDirName={autopf}\BiLauncher
|
||||
DefaultGroupName=BiLauncher
|
||||
DisableProgramGroupPage=yes
|
||||
LicenseFile={#SourceDir}\share\doc\BiLauncher\LICENSE
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename=BiLauncher-{#AppVersion}-win64-setup
|
||||
SetupIconFile=..\..\icons\icon.ico
|
||||
UninstallDisplayIcon={app}\bin\BiLauncher.exe
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
LZMANumBlockThreads=4
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
[Languages]
|
||||
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs; Excludes: "vc_redist.x64.exe"
|
||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||
Source: "{#SourceDir}\bin\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
|
||||
#endif
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||
[Icons]
|
||||
Name: "{autoprograms}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"
|
||||
Name: "{autodesktop}\BiLauncher"; Filename: "{app}\bin\BiLauncher.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
[Run]
|
||||
#ifexist SourceDir + "\bin\vc_redist.x64.exe"
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Установка Microsoft Visual C++ Runtime..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
||||
Filename: "{app}\bin\BiLauncher.exe"; Description: "{cm:LaunchProgram,BiLauncher}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,126 @@
|
||||
# Автотесты 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}" "${LAUNCHER_GENERATED_DIR}")
|
||||
add_dependencies(bilauncher_testlib launcher_version)
|
||||
target_link_libraries(bilauncher_testlib PUBLIC
|
||||
Qt6::Quick Qt6::QuickControls2 Qt6::Qml Qt6::Svg Qt6::Core Qt6::CorePrivate Qt6::Gui Qt6::Network
|
||||
)
|
||||
|
||||
# --- Подставная java --------------------------------------------------------------
|
||||
add_executable(fake_java fake_java/main.cpp)
|
||||
set_target_properties(fake_java PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON
|
||||
AUTOMOC OFF MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
|
||||
|
||||
# --- Общая обвязка тестов --------------------------------------------------------
|
||||
add_library(bilauncher_testsupport STATIC
|
||||
common/testenv.h common/testenv.cpp
|
||||
common/fixtures.h common/fixtures.cpp
|
||||
common/fakeinternet.h common/fakeinternet.cpp
|
||||
common/fakemojang.h common/fakemojang.cpp
|
||||
common/nettest.h
|
||||
)
|
||||
# Designated initializers (VersionSpec{.id = ...}) — C++20.
|
||||
target_compile_features(bilauncher_testsupport PUBLIC cxx_std_20)
|
||||
target_include_directories(bilauncher_testsupport PUBLIC common)
|
||||
target_compile_definitions(bilauncher_testsupport PUBLIC
|
||||
BILAUNCHER_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures"
|
||||
BILAUNCHER_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
|
||||
BILAUNCHER_FAKE_JAVA="$<TARGET_FILE:fake_java>"
|
||||
)
|
||||
target_link_libraries(bilauncher_testsupport PUBLIC bilauncher_testlib Qt6::Test Qt6::Network Qt6::CorePrivate)
|
||||
add_dependencies(bilauncher_testsupport fake_java)
|
||||
|
||||
# Окружение всех тестов: без окон, без GPU, без кеша QML на диске.
|
||||
set(BILAUNCHER_TEST_ENV
|
||||
"QT_QPA_PLATFORM=offscreen"
|
||||
"QT_QUICK_BACKEND=software"
|
||||
"QML_DISABLE_DISK_CACHE=1"
|
||||
"QT_LOGGING_RULES=qt.network.ssl.warning=false"
|
||||
)
|
||||
|
||||
# bilauncher_add_test(<имя> LABEL <метка> SOURCES ... [TIMEOUT сек] [GUI])
|
||||
function(bilauncher_add_test name)
|
||||
cmake_parse_arguments(ARG "GUI" "LABEL;TIMEOUT" "SOURCES" ${ARGN})
|
||||
add_executable(${name} ${ARG_SOURCES})
|
||||
set_target_properties(${name} PROPERTIES MACOSX_BUNDLE FALSE WIN32_EXECUTABLE FALSE)
|
||||
target_link_libraries(${name} PRIVATE bilauncher_testsupport)
|
||||
if(ARG_GUI)
|
||||
target_link_libraries(${name} PRIVATE bilauncher_testlibplugin Qt6::QuickTest)
|
||||
endif()
|
||||
add_test(NAME ${name} COMMAND ${name})
|
||||
if(NOT ARG_TIMEOUT)
|
||||
set(ARG_TIMEOUT 120)
|
||||
endif()
|
||||
set_tests_properties(${name} PROPERTIES
|
||||
LABELS "${ARG_LABEL}"
|
||||
TIMEOUT ${ARG_TIMEOUT}
|
||||
ENVIRONMENT "${BILAUNCHER_TEST_ENV}")
|
||||
if(WIN32)
|
||||
# DLL Qt — из установки Qt, без windeployqt для каждого теста.
|
||||
set_property(TEST ${name} APPEND PROPERTY ENVIRONMENT_MODIFICATION
|
||||
"PATH=path_list_prepend:$<TARGET_FILE_DIR:Qt6::Core>")
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
add_subdirectory(unit)
|
||||
add_subdirectory(net)
|
||||
add_subdirectory(backend)
|
||||
add_subdirectory(qml)
|
||||
@@ -0,0 +1,2 @@
|
||||
# LauncherBackend целиком: всё, что вызывает QML, с настоящими сервисами под ним.
|
||||
bilauncher_add_test(tst_launcherbackend LABEL backend TIMEOUT 300 SOURCES tst_launcherbackend.cpp)
|
||||
@@ -0,0 +1,809 @@
|
||||
// Интеграционные тесты LauncherBackend — того, что видит QML. Сервисы под ним
|
||||
// настоящие; сеть — FakeInternet, java — подставная, файлы — в песочнице.
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakemojang.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
#include "launcherbackend.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QGuiApplication>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kSeasonalIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
|
||||
const QString kElyAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
|
||||
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||
const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector.jar");
|
||||
|
||||
bool waitFor(const std::function<bool()> &condition, int ms = 30000)
|
||||
{
|
||||
return QTest::qWaitFor(condition, ms);
|
||||
}
|
||||
|
||||
QStringList strings(const QSignalSpy &spy, int arg = 0)
|
||||
{
|
||||
QStringList out;
|
||||
for (const QList<QVariant> &call : spy)
|
||||
out << call.value(arg).toString();
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestLauncherBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_game;
|
||||
|
||||
void writeSettings(const QJsonObject &extra = {})
|
||||
{
|
||||
QJsonObject settings{{"gameDir", m_game}, {"language", "en"}};
|
||||
for (auto it = extra.begin(); it != extra.end(); ++it)
|
||||
settings.insert(it.key(), it.value());
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(), settings);
|
||||
}
|
||||
|
||||
static std::unique_ptr<LauncherBackend> make() { return std::make_unique<LauncherBackend>(); }
|
||||
|
||||
// Ждём, пока backend освободится от фоновых операций.
|
||||
static bool waitIdle(LauncherBackend &backend, int ms = 30000)
|
||||
{
|
||||
return waitFor([&] { return !backend.busy() && !backend.downloading() && !backend.switching(); }, ms);
|
||||
}
|
||||
|
||||
// Установить версию через backend и дождаться результата.
|
||||
static bool installVersion(LauncherBackend &backend, const QString &id)
|
||||
{
|
||||
backend.installVersion(id);
|
||||
return waitFor([&] { return backend.isVersionInstalled(id) && !backend.downloading(); });
|
||||
}
|
||||
|
||||
QString fakeJava(const QString &name, const QStringList &config = {})
|
||||
{
|
||||
return Fixtures::installFakeJava(m_game + "/../" + name, "17.0.8", config);
|
||||
}
|
||||
|
||||
// Папка игры, в которой до лаунчера играли с Fabric. Возвращает её состав.
|
||||
static QStringList populateGameDir(const QString &dir)
|
||||
{
|
||||
QDir(dir).removeRecursively();
|
||||
const QString fabric = QStringLiteral("fabric-loader-0.15.0-1.20.1");
|
||||
Fixtures::write(dir + "/versions/1.20.1/1.20.1.json", R"({"id": "1.20.1", "type": "release"})");
|
||||
Fixtures::write(dir + "/versions/" + fabric + "/" + fabric + ".json",
|
||||
Fixtures::read("versions/" + fabric + ".json"));
|
||||
Fixtures::writeJson(dir + "/launcher_profiles.json",
|
||||
{{"profiles", QJsonObject{{"fabric", QJsonObject{
|
||||
{"type", "custom"}, {"lastVersionId", fabric},
|
||||
{"lastUsed", "2026-03-01T10:00:00.000Z"}}}}}});
|
||||
Fixtures::write(dir + "/mods/sodium.jar", "jar");
|
||||
Fixtures::write(dir + "/saves/World/level.dat", "world");
|
||||
Fixtures::write(dir + "/options.txt", "fov:90");
|
||||
return Fixtures::listTree(dir);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
BILAUNCHER_START_FAKE_INTERNET(m_net);
|
||||
}
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
writeSettings();
|
||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||
}
|
||||
|
||||
// --- данные и настройки ----------------------------------------------------
|
||||
|
||||
void freshStartCreatesDataFiles()
|
||||
{
|
||||
QFile::remove(LauncherPaths::settingsFile());
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
QTest::qWait(50);
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QVERIFY(QFile::exists(LauncherPaths::settingsFile()));
|
||||
QVERIFY(QFile::exists(LauncherPaths::profilesFile()));
|
||||
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile()));
|
||||
const QVariantMap s = backend->settings();
|
||||
QCOMPARE(s.value("maxMemoryMb").toInt(), 4096);
|
||||
QCOMPARE(s.value("minMemoryMb").toInt(), 512);
|
||||
QCOMPARE(s.value("language").toString(), QStringLiteral("system"));
|
||||
QCOMPARE(s.value("resolvedGameDir").toString(), QDir::toNativeSeparators(LauncherPaths::defaultMinecraftDir()));
|
||||
QVERIFY(backend->profileNames().isEmpty());
|
||||
QCOMPARE(backend->activeBuildIndex(), -1);
|
||||
QVERIFY(!backend->busy());
|
||||
QVERIFY(!backend->microsoftAvailable()); // тестовая сборка — без WebEngine
|
||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||
}
|
||||
|
||||
void firstRunImportsGameDir()
|
||||
{
|
||||
// Настоящий первый запуск: ни одного файла лаунчера, папка игры по умолчанию.
|
||||
QFile::remove(LauncherPaths::settingsFile());
|
||||
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||
const QStringList before = populateGameDir(game);
|
||||
const QByteArray profilesBefore = Fixtures::readFile(game + "/launcher_profiles.json");
|
||||
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
QVERIFY(imported.wait(2000)); // сообщение откладывается до подключения интерфейса
|
||||
QCOMPARE(imported.constFirst().constFirst().toString(), QStringLiteral("Fabric Loader 1.20.1"));
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||
QCOMPARE(backend->activeBuildIndex(), 0);
|
||||
const QVariantMap build = backend->customBuildAt(0);
|
||||
QCOMPARE(build.value("minecraftVersion").toString(), QStringLiteral("1.20.1"));
|
||||
QCOMPARE(build.value("loader").toString(), QStringLiteral("fabric"));
|
||||
QCOMPARE(build.value("loaderVersion").toString(), QStringLiteral("0.15.0"));
|
||||
QCOMPARE(build.value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||
const int id = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile()))
|
||||
.array().at(0).toObject().value("id").toInt();
|
||||
QCOMPARE(id, 1);
|
||||
QCOMPARE(backend->settings().value("activeBuildId").toInt(), id);
|
||||
QCOMPARE(Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt(), id);
|
||||
// Ничего не качается, ничего не переключается, папка игры та же.
|
||||
QVERIFY(!backend->downloading());
|
||||
QVERIFY(!backend->switching());
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
}
|
||||
QCOMPARE(Fixtures::listTree(game), before);
|
||||
QCOMPARE(Fixtures::readFile(game + "/launcher_profiles.json"), profilesBefore);
|
||||
QVERIFY(!QFile::exists(LauncherPaths::buildStorageDir() + "/index.json"));
|
||||
|
||||
// Второй запуск — уже не первый: новой сборки нет.
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||
QTest::qWait(50);
|
||||
QVERIFY(imported.isEmpty());
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Fabric Loader 1.20.1"});
|
||||
}
|
||||
QDir(game).removeRecursively();
|
||||
}
|
||||
|
||||
void importOnlyOnFirstRun()
|
||||
{
|
||||
// Настройки уже есть (init() их пишет), а списка сборок нет — это не первый запуск.
|
||||
populateGameDir(m_game);
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||
QTest::qWait(50);
|
||||
QVERIFY(imported.isEmpty());
|
||||
QVERIFY(backend->customBuildNames().isEmpty());
|
||||
}
|
||||
|
||||
// Пустой список сборок — пользователь сам всё удалил; воскрешать не надо.
|
||||
TestEnv::resetLauncherRoot();
|
||||
QVERIFY(LauncherPaths::ensureRootExists());
|
||||
const QString game = LauncherPaths::defaultMinecraftDir();
|
||||
populateGameDir(game);
|
||||
Fixtures::write(LauncherPaths::customBuildsFile(), "[]");
|
||||
{
|
||||
auto backend = make();
|
||||
QTest::qWait(50);
|
||||
QVERIFY(backend->customBuildNames().isEmpty());
|
||||
}
|
||||
QDir(game).removeRecursively();
|
||||
}
|
||||
|
||||
void firstRunWithEmptyGameDirCreatesNothing()
|
||||
{
|
||||
QFile::remove(LauncherPaths::settingsFile());
|
||||
QDir(LauncherPaths::defaultMinecraftDir()).removeRecursively();
|
||||
auto backend = make();
|
||||
QSignalSpy imported(backend.get(), &LauncherBackend::gameDirImported);
|
||||
QTest::qWait(50);
|
||||
QVERIFY(imported.isEmpty());
|
||||
QCOMPARE(backend->activeBuildIndex(), -1);
|
||||
}
|
||||
|
||||
void profilesCrudAndPersistence()
|
||||
{
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy changed(backend.get(), &LauncherBackend::profilesChanged);
|
||||
backend->addProfile("Steve", "steve", QString());
|
||||
backend->addProfile("Ely", "ely@example.com", "pw", "elyby");
|
||||
backend->addProfile("Odd", "odd", QString(), "something-unknown");
|
||||
QCOMPARE(changed.count(), 3);
|
||||
QCOMPARE(backend->profileNames(), QStringList({"Steve", "Ely", "Odd"}));
|
||||
QCOMPARE(backend->profileAt(1).value("authType").toString(), QStringLiteral("elyby"));
|
||||
QCOMPARE(backend->profileAt(2).value("authType").toString(), QStringLiteral("offline"));
|
||||
QVERIFY(backend->profileAt(9).isEmpty());
|
||||
|
||||
backend->updateProfile(0, "Steve2", "steve2", QString(), "offline");
|
||||
backend->removeProfile(2);
|
||||
backend->removeProfile(42); // мимо — без последствий
|
||||
QCOMPARE(backend->profileNames(), QStringList({"Steve2", "Ely"}));
|
||||
}
|
||||
const QJsonArray stored = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array();
|
||||
QCOMPARE(stored.size(), 2);
|
||||
QVERIFY(!stored.at(0).toObject().value("clientToken").toString().isEmpty());
|
||||
QVERIFY(stored.at(0).toObject().value("id").toInt() != stored.at(1).toObject().value("id").toInt());
|
||||
|
||||
auto reloaded = make();
|
||||
QCOMPARE(reloaded->profileNames(), QStringList({"Steve2", "Ely"}));
|
||||
QCOMPARE(reloaded->profileAt(1).value("login").toString(), QStringLiteral("ely@example.com"));
|
||||
}
|
||||
|
||||
void elyTokenDroppedWhenCredentialsChange()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
|
||||
{"id", 1}, {"name", "Ely"}, {"login", "a"}, {"password", "p"}, {"authType", "elyby"},
|
||||
{"clientToken", "c"}, {"accessToken", "tok"}, {"uuid", "u"}, {"resolvedName", "Nick"}}}).toJson());
|
||||
auto backend = make();
|
||||
backend->updateProfile(0, "Ely renamed", "a", "p", "elyby"); // только имя — токен живёт
|
||||
QCOMPARE(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile()))
|
||||
.array().at(0).toObject().value("accessToken").toString(), QStringLiteral("tok"));
|
||||
backend->updateProfile(0, "Ely renamed", "a", "new-password", "elyby");
|
||||
const QJsonObject p = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject();
|
||||
QVERIFY(p.value("accessToken").toString().isEmpty());
|
||||
QVERIFY(p.value("resolvedName").toString().isEmpty());
|
||||
QCOMPARE(p.value("clientToken").toString(), QStringLiteral("c"));
|
||||
}
|
||||
|
||||
void customBuildsAndActiveSwitch()
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->addCustomBuild("Alpha", "play.example.com");
|
||||
backend->addCustomBuild("Beta", QString());
|
||||
QCOMPARE(backend->customBuildNames(), QStringList({"Alpha", "Beta"}));
|
||||
QCOMPARE(backend->activeBuildIndex(), 0); // настройка пуста — активна первая
|
||||
QCOMPARE(backend->activeBuildName(), QStringLiteral("Alpha"));
|
||||
QVERIFY(!backend->downloading()); // без версии игры ставить нечего
|
||||
|
||||
// Правка версии сбрасывает выбранную под старую версию сборку модлоадера.
|
||||
backend->updateCustomBuild(0, {{"minecraftVersion", "1.20.1"}, {"loader", "fabric"},
|
||||
{"loaderVersion", "0.16.10"}, {"resolvedVersionId", "fabric-loader-0.16.10-1.20.1"}});
|
||||
QCOMPARE(backend->customBuildAt(0).value("launchVersionId").toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
backend->updateCustomBuild(0, {{"minecraftVersion", "1.21.1"}});
|
||||
QVariantMap alpha = backend->customBuildAt(0);
|
||||
QCOMPARE(alpha.value("loader").toString(), QStringLiteral("fabric"));
|
||||
QVERIFY(alpha.value("loaderVersion").toString().isEmpty());
|
||||
QVERIFY(alpha.value("resolvedVersionId").toString().isEmpty());
|
||||
QCOMPARE(alpha.value("launchVersionId").toString(), QStringLiteral("1.21.1"));
|
||||
QCOMPARE(backend->customBuildAt(1).value("launchVersionId").toString(), QStringLiteral("Beta")); // по имени
|
||||
QCOMPARE(backend->requiredJavaMajor(0), 21);
|
||||
QCOMPARE(backend->requiredJavaMajor(5), 0);
|
||||
|
||||
// Переключение активной сборки уносит содержимое .minecraft в архив.
|
||||
Fixtures::write(m_game + "/mods/alpha.jar", "a");
|
||||
QSignalSpy active(backend.get(), &LauncherBackend::activeBuildChanged);
|
||||
backend->setActiveBuildIndex(1);
|
||||
QVERIFY(waitIdle(*backend));
|
||||
QCOMPARE(backend->activeBuildIndex(), 1);
|
||||
QVERIFY(active.count() > 0);
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar"));
|
||||
QCOMPARE(backend->settings().value("activeBuildId").toInt(), Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt());
|
||||
|
||||
const QVariantMap info = backend->customBuildRemovalInfo(0);
|
||||
QCOMPARE(info.value("name").toString(), QStringLiteral("Alpha"));
|
||||
QVERIFY(info.value("hasArchive").toBool());
|
||||
QVERIFY(!info.value("active").toBool());
|
||||
|
||||
// Удаление активной — активной становится первая из оставшихся, её архив разворачивается.
|
||||
backend->removeCustomBuild(1);
|
||||
QVERIFY(waitIdle(*backend));
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Alpha"});
|
||||
QCOMPARE(backend->activeBuildIndex(), 0);
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("a"));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
}
|
||||
|
||||
void settingsPersistAndGameDirChange()
|
||||
{
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(),
|
||||
QJsonObject{{"gameDir", m_game}, {"language", "en"},
|
||||
{"launcherName", "old"}, {"launcherVersion", "1.0"}});
|
||||
const QString other = TestEnv::freshDir(QStringLiteral("other-game"));
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy installedChanged(backend.get(), &LauncherBackend::installedVersionsChanged);
|
||||
QSignalSpy settingsChanged(backend.get(), &LauncherBackend::settingsChanged);
|
||||
backend->updateSettings({{"maxMemoryMb", 2048}, {"jvmArgs", "-XX:+UseZGC"}});
|
||||
QCOMPARE(installedChanged.count(), 0);
|
||||
backend->updateSettings({{"gameDir", other}});
|
||||
QCOMPARE(installedChanged.count(), 1);
|
||||
QCOMPARE(settingsChanged.count(), 2);
|
||||
QCOMPARE(backend->settings().value("resolvedGameDir").toString(), QDir::toNativeSeparators(other));
|
||||
}
|
||||
const QJsonObject stored = Fixtures::readJsonFile(LauncherPaths::settingsFile());
|
||||
QCOMPARE(stored.value("maxMemoryMb").toInt(), 2048);
|
||||
QVERIFY(!stored.contains("launcherName")); // устаревшие ключи вычищены
|
||||
QVERIFY(!stored.contains("launcherVersion"));
|
||||
auto reloaded = make();
|
||||
QCOMPARE(reloaded->settings().value("jvmArgs").toString(), QStringLiteral("-XX:+UseZGC"));
|
||||
}
|
||||
|
||||
void languageSettingSwitchesLocalization()
|
||||
{
|
||||
auto backend = make();
|
||||
backend->updateSettings({{"language", "ru"}});
|
||||
QCOMPARE(Localization::instance().language(), QStringLiteral("ru"));
|
||||
backend->updateSettings({{"language", "en"}});
|
||||
QCOMPARE(Localization::instance().language(), QStringLiteral("en"));
|
||||
}
|
||||
|
||||
void legacyBuildsFileIsMigrated()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::legacyCustomBuildsFile(), QJsonDocument(QJsonArray{
|
||||
QJsonObject{{"id", 3}, {"name", "Old build"}, {"serverUrl", "mc.example.com"}, {"versionId", "1.12.2"}}}).toJson());
|
||||
auto backend = make();
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Old build"});
|
||||
QCOMPARE(backend->customBuildAt(0).value("minecraftVersion").toString(), QStringLiteral("1.12.2"));
|
||||
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile()));
|
||||
QVERIFY(!QFile::exists(LauncherPaths::legacyCustomBuildsFile()));
|
||||
QVERIFY(QFile::exists(LauncherPaths::legacyCustomBuildsFile() + ".migrated"));
|
||||
// Новые сборки получают id после старых.
|
||||
backend->addCustomBuild("New", QString());
|
||||
const QJsonArray builds = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).array();
|
||||
QCOMPARE(builds.at(1).toObject().value("id").toInt(), 4);
|
||||
}
|
||||
|
||||
void corruptFilesAreSetAsideAndReported()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::profilesFile(), "{ this is not json");
|
||||
Fixtures::write(LauncherPaths::customBuildsFile(), "{\"object\": \"not an array\"}");
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
QVERIFY(errors.wait(2000)); // сообщение откладывается до подключения интерфейса
|
||||
const QString message = errors.constFirst().constFirst().toString();
|
||||
QVERIFY(message.contains(QDir::toNativeSeparators(LauncherPaths::profilesFile() + ".bak")));
|
||||
QVERIFY(QFile::exists(LauncherPaths::profilesFile() + ".bak"));
|
||||
QVERIFY(QFile::exists(LauncherPaths::customBuildsFile() + ".bak"));
|
||||
// Свежие файлы созданы заново и валидны.
|
||||
QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).isArray());
|
||||
QVERIFY(QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::customBuildsFile())).isArray());
|
||||
QVERIFY(backend->profileNames().isEmpty());
|
||||
}
|
||||
|
||||
void unusableBuildRecordsAreSkipped()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::customBuildsFile(), QJsonDocument(QJsonArray{
|
||||
QJsonObject{{"id", 1}, {"name", " "}}, QJsonObject{{"id", 2}, {"name", "Good"}}}).toJson());
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
QVERIFY(errors.wait(2000));
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Good"});
|
||||
}
|
||||
|
||||
// --- версии ------------------------------------------------------------------
|
||||
|
||||
void versionCatalogInstallAndRemove()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
|
||||
Fixtures::write(m_game + "/versions/my-pack/my-pack.json", "{\"mainClass\":\"x\"}");
|
||||
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->refreshVersionCatalog();
|
||||
QVERIFY(waitFor([&] { return backend->versionCatalog().size() == 3; }));
|
||||
const QVariantList catalog = backend->versionCatalog();
|
||||
QCOMPARE(catalog.at(0).toMap().value("id").toString(), QStringLiteral("1.20.1"));
|
||||
QCOMPARE(catalog.at(0).toMap().value("category").toString(), QStringLiteral("release"));
|
||||
QVERIFY(!catalog.at(0).toMap().value("installed").toBool());
|
||||
QCOMPARE(catalog.at(2).toMap().value("id").toString(), QStringLiteral("my-pack")); // неизвестная манифесту
|
||||
QCOMPARE(catalog.at(2).toMap().value("category").toString(), QStringLiteral("other"));
|
||||
|
||||
QSignalSpy installed(backend.get(), &LauncherBackend::installedVersionsChanged);
|
||||
QVERIFY(installVersion(*backend, "1.20.1"));
|
||||
QVERIFY(installed.count() > 0);
|
||||
QVERIFY(backend->installedVersions().contains("1.20.1"));
|
||||
QVERIFY(backend->versionCatalog().at(0).toMap().value("installed").toBool());
|
||||
|
||||
backend->addCustomBuild("Uses 1.20.1", QString(), "1.20.1");
|
||||
const QVariantMap info = backend->versionRemovalInfo("1.20.1");
|
||||
QVERIFY(info.value("installed").toBool());
|
||||
QVERIFY(info.value("sizeMb").toInt() >= 1);
|
||||
QCOMPARE(info.value("builds").toStringList(), QStringList{"Uses 1.20.1"});
|
||||
|
||||
backend->removeVersion("1.20.1");
|
||||
QVERIFY(!backend->isVersionInstalled("1.20.1"));
|
||||
QVERIFY(!QDir(m_game + "/versions/1.20.1").exists());
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
}
|
||||
|
||||
void addingBuildWithVersionStartsDownload()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
auto backend = make();
|
||||
backend->addCustomBuild("Auto", QString(), "1.20.1");
|
||||
QVERIFY(backend->downloading());
|
||||
QCOMPARE(backend->downloadVersion(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(waitFor([&] { return backend->isVersionInstalled("1.20.1") && !backend->downloading(); }));
|
||||
QVERIFY(backend->checkInstallation(0).isEmpty());
|
||||
}
|
||||
|
||||
void checkInstallationMessages()
|
||||
{
|
||||
auto backend = make();
|
||||
QCOMPARE(backend->checkInstallation(0), QStringList{Loc::text("common.status.noBuildSelected")});
|
||||
backend->addCustomBuild("Missing", QString());
|
||||
QStringList issues = backend->checkInstallation(0);
|
||||
QCOMPARE(issues.size(), 1);
|
||||
QVERIFY(!issues.constFirst().isEmpty());
|
||||
}
|
||||
|
||||
void downloadFailureIsReported()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.overrideFile("versions/1.20.1/1.20.1.jar", FakeResponse::error(404));
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->installVersion("1.20.1");
|
||||
QVERIFY(errors.wait(30000));
|
||||
QVERIFY(errors.constFirst().constFirst().toString().startsWith(
|
||||
Loc::text("version.error.downloadFailed").arg("1.20.1", QString()).section(u'%', 0, 0)));
|
||||
QVERIFY(waitIdle(*backend));
|
||||
}
|
||||
|
||||
// --- запуск --------------------------------------------------------------
|
||||
|
||||
void launchValidationErrors()
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->launchGame(0, 0);
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noProfile"));
|
||||
backend->addProfile("Steve", "steve", QString());
|
||||
backend->launchGame(0, 0);
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("launch.error.noBuild"));
|
||||
backend->addCustomBuild("B", QString());
|
||||
backend->launchGame(0, 0);
|
||||
QCOMPARE(errors.constLast().constFirst().toString(),
|
||||
Loc::text("launch.error.noVersionsInstalled").arg(QDir::toNativeSeparators(m_game + "/versions")));
|
||||
QVERIFY(!backend->busy());
|
||||
}
|
||||
|
||||
void offlineLaunchRunsJava()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString argsFile = m_game + "/../game-args.txt";
|
||||
const QString java = fakeJava("jdk-launch", {"args_file=" + argsFile, "stdout=[Render thread/INFO]: Setting user: Steve"});
|
||||
writeSettings({{"javaPath", java}, {"maxMemoryMb", 3072}, {"jvmArgs", "-Dtest.flag=1"}});
|
||||
|
||||
auto backend = make();
|
||||
QVERIFY(installVersion(*backend, "1.20.1"));
|
||||
backend->addProfile("Steve", "steve", QString());
|
||||
backend->addCustomBuild("Server build", "play.example.com:25570", "1.20.1");
|
||||
QVERIFY(waitIdle(*backend));
|
||||
|
||||
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
|
||||
QSignalSpy finished(backend.get(), &LauncherBackend::gameFinished);
|
||||
QSignalSpy output(backend.get(), &LauncherBackend::gameOutput);
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->launchGame(0, 0);
|
||||
QVERIFY2(waitFor([&] { return launched.count() + errors.count() > 0; }), "нет ни launched, ни launchError");
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QCOMPARE(launched.constFirst().at(0).toString(), QStringLiteral("Steve"));
|
||||
QCOMPARE(launched.constFirst().at(1).toString(), QStringLiteral("Server build"));
|
||||
QVERIFY(waitFor([&] { return finished.count() == 1; }));
|
||||
QVERIFY(!backend->gameRunning());
|
||||
QVERIFY(!backend->busy());
|
||||
QVERIFY(strings(output).contains("[Render thread/INFO]: Setting user: Steve"));
|
||||
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
|
||||
QVERIFY(args.contains("-Xmx3072M"));
|
||||
QVERIFY(args.contains("-Dtest.flag=1"));
|
||||
QVERIFY(args.contains("net.minecraft.client.main.Main"));
|
||||
const qsizetype server = args.indexOf("--server");
|
||||
QVERIFY(server > 0);
|
||||
QCOMPARE(args.at(server + 1), QStringLiteral("play.example.com"));
|
||||
QCOMPARE(args.at(server + 3), QStringLiteral("25570"));
|
||||
}
|
||||
|
||||
void elyLaunchWithTwoFactor()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::bytes("injector"));
|
||||
m_net.route(kElyAuth, [](const FakeRequest &request) {
|
||||
const QJsonObject in = QJsonDocument::fromJson(request.body).object();
|
||||
if (in.value("password").toString() == QLatin1String("pw:654321"))
|
||||
return FakeResponse::json(QJsonObject{{"accessToken", "ely-token"},
|
||||
{"selectedProfile", QJsonObject{{"id", "aaaa-bbbb"}, {"name", "ElyNick"}}}});
|
||||
return FakeResponse::json(QJsonObject{{"errorMessage", "Account protected with two factor auth."}}, 401);
|
||||
});
|
||||
const QString argsFile = m_game + "/../ely-args.txt";
|
||||
writeSettings({{"javaPath", fakeJava("jdk-ely", {"args_file=" + argsFile})}});
|
||||
|
||||
auto backend = make();
|
||||
QVERIFY(installVersion(*backend, "1.20.1"));
|
||||
backend->addProfile("Ely", "ely@example.com", "pw", "elyby");
|
||||
backend->addCustomBuild("B", QString(), "1.20.1");
|
||||
QVERIFY(waitIdle(*backend));
|
||||
|
||||
QSignalSpy twoFactor(backend.get(), &LauncherBackend::twoFactorRequired);
|
||||
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->launchGame(0, 0);
|
||||
QVERIFY(waitFor([&] { return twoFactor.count() + errors.count() > 0; }));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QCOMPARE(twoFactor.constFirst().constFirst().toString(), QStringLiteral("Ely"));
|
||||
QVERIFY(!backend->busy()); // ждём ввода кода
|
||||
|
||||
backend->submitTwoFactorCode(" ");
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.ely.error.enterCode"));
|
||||
errors.clear();
|
||||
backend->submitTwoFactorCode("654321");
|
||||
QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; }));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
|
||||
QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(!backend->gameRunning(), 15000);
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
|
||||
QVERIFY(args.constFirst().startsWith("-javaagent:"));
|
||||
QVERIFY(args.constFirst().endsWith("=ely.by"));
|
||||
QVERIFY(args.contains("ElyNick"));
|
||||
QVERIFY(args.contains("ely-token"));
|
||||
// Токен и ник запомнены — в следующий раз пароль не понадобится.
|
||||
const QJsonObject profile = QJsonDocument::fromJson(Fixtures::readFile(LauncherPaths::profilesFile())).array().at(0).toObject();
|
||||
QCOMPARE(profile.value("accessToken").toString(), QStringLiteral("ely-token"));
|
||||
QCOMPARE(profile.value("resolvedName").toString(), QStringLiteral("ElyNick"));
|
||||
QCOMPARE(profile.value("uuid").toString(), QStringLiteral("aaaabbbb"));
|
||||
}
|
||||
|
||||
void microsoftProfileWithFreshTokenLaunchesOffline()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString argsFile = m_game + "/../ms-args.txt";
|
||||
writeSettings({{"javaPath", fakeJava("jdk-ms", {"args_file=" + argsFile})}});
|
||||
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
|
||||
{"id", 1}, {"name", "MS"}, {"authType", "microsoft"}, {"clientToken", "c"},
|
||||
{"accessToken", "mc-token"}, {"refreshToken", "r"}, {"uuid", "069a79f444e94726a5befca90e38aaf5"},
|
||||
{"resolvedName", "Notch"}, {"xuid", "253"}, {"userType", "msa"},
|
||||
{"tokenExpiry", QDateTime::currentDateTimeUtc().addSecs(3600).toString(Qt::ISODate)}}}).toJson());
|
||||
|
||||
auto backend = make();
|
||||
QVERIFY(installVersion(*backend, "1.20.1"));
|
||||
backend->addCustomBuild("B", QString(), "1.20.1");
|
||||
QVERIFY(waitIdle(*backend));
|
||||
const int requestsBefore = m_net.requests().size();
|
||||
|
||||
QSignalSpy launched(backend.get(), &LauncherBackend::launched);
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->launchGame(0, 0);
|
||||
QVERIFY(waitFor([&] { return launched.count() + errors.count() > 0; }));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QCOMPARE(m_net.requests().size(), requestsBefore); // токен жив — в Microsoft не ходили
|
||||
QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(argsFile), 15000);
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split('\n', Qt::SkipEmptyParts);
|
||||
QVERIFY(args.contains("Notch"));
|
||||
QVERIFY(args.contains("mc-token"));
|
||||
}
|
||||
|
||||
void microsoftProfileWithoutSessionAsksRelogin()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
Fixtures::write(LauncherPaths::profilesFile(), QJsonDocument(QJsonArray{QJsonObject{
|
||||
{"id", 1}, {"name", "MS"}, {"authType", "microsoft"}}}).toJson());
|
||||
auto backend = make();
|
||||
QVERIFY(installVersion(*backend, "1.20.1"));
|
||||
backend->addCustomBuild("B", QString(), "1.20.1");
|
||||
QVERIFY(waitIdle(*backend));
|
||||
|
||||
QSignalSpy relogin(backend.get(), &LauncherBackend::microsoftReloginRequired);
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->launchGame(0, 0);
|
||||
QCOMPARE(relogin.count(), 1);
|
||||
QCOMPARE(relogin.constFirst().constFirst().toInt(), 0);
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("auth.msa.error.profileNeedsLogin").arg("MS"));
|
||||
QVERIFY(!backend->busy());
|
||||
}
|
||||
|
||||
void microsoftLoginNeedsWebEngine()
|
||||
{
|
||||
auto backend = make();
|
||||
QSignalSpy failed(backend.get(), &LauncherBackend::microsoftLoginFailed);
|
||||
backend->startMicrosoftLogin(-1);
|
||||
QCOMPARE(failed.count(), 1);
|
||||
QCOMPARE(failed.constFirst().constFirst().toString(), Loc::text("auth.msa.error.noWebEngine"));
|
||||
|
||||
const QVariantMap redirect = backend->inspectMicrosoftRedirect(
|
||||
"https://login.live.com/oauth20_desktop.srf?code=abc");
|
||||
QVERIFY(redirect.value("matched").toBool());
|
||||
QCOMPARE(redirect.value("code").toString(), QStringLiteral("abc"));
|
||||
QVERIFY(!backend->inspectMicrosoftRedirect("https://example.com/").value("matched").toBool());
|
||||
}
|
||||
|
||||
// --- Java ----------------------------------------------------------------
|
||||
|
||||
void javaCatalogInstallSelectRemove()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes({{QStringLiteral("jdk-21/bin/") + Fixtures::javaExecutableName(), "bin"}});
|
||||
m_net.route("https://api.adoptium.net/v3/info/available_releases",
|
||||
FakeResponse::json(QJsonObject{{"available_lts_releases", QJsonArray{21}}, {"available_releases", QJsonArray{21}}}));
|
||||
m_net.route("https://api.adoptium.net/v3/assets/feature_releases/*", [zip](const FakeRequest &request) {
|
||||
const QString arch = request.queryItems().queryItemValue("architecture");
|
||||
const QJsonObject binary{
|
||||
{"architecture", arch}, {"image_type", "jdk"},
|
||||
{"package", QJsonObject{{"name", "jdk.zip"}, {"link", "https://github.com/adoptium/x/jdk.zip"},
|
||||
{"checksum", Fixtures::sha256(zip)}, {"size", zip.size()}}}};
|
||||
const QJsonArray releases{QJsonObject{
|
||||
{"release_name", "jdk-21.0.2+13"}, {"timestamp", "2025-01-20T00:00:00Z"},
|
||||
{"binaries", QJsonArray{binary}},
|
||||
{"version_data", QJsonObject{{"major", 21}, {"openjdk_version", "21.0.2+13-LTS"}}}}};
|
||||
return FakeResponse::json(QJsonDocument(releases).toJson());
|
||||
});
|
||||
m_net.route("https://github.com/adoptium/x/jdk.zip", FakeResponse::bytes(zip));
|
||||
|
||||
auto backend = make();
|
||||
QSignalSpy installedSpy(backend.get(), &LauncherBackend::javaRuntimeInstalled);
|
||||
backend->refreshJavaCatalog();
|
||||
QVERIFY(waitFor([&] { return !backend->javaCatalog().isEmpty() && !backend->javaCatalogLoading(); }));
|
||||
const QVariantMap row = backend->javaCatalog().constFirst().toMap();
|
||||
QCOMPARE(row.value("major").toInt(), 21);
|
||||
QVERIFY(row.value("lts").toBool());
|
||||
QVERIFY(!row.value("installed").toBool());
|
||||
const QString id = row.value("id").toString();
|
||||
QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool());
|
||||
|
||||
backend->installJavaRuntime(id);
|
||||
QVERIFY(waitFor([&] { return installedSpy.count() == 1; }));
|
||||
QCOMPARE(installedSpy.constFirst().constFirst().toString(), id);
|
||||
QVERIFY(backend->javaRuntimeInfo(id).value("installed").toBool());
|
||||
QVERIFY(backend->javaCatalog().constFirst().toMap().value("installed").toBool());
|
||||
|
||||
// Выбранная сборка Java попадает в настройки, а при удалении настройка очищается.
|
||||
backend->updateSettings({{"javaRuntime", id}});
|
||||
const QString javaPath = backend->javaRuntimeInfo(id).value("javaPath").toString();
|
||||
QVERIFY(backend->detectedJava().join('\n').contains(javaPath));
|
||||
backend->removeJavaRuntime(id);
|
||||
QVERIFY(backend->settings().value("javaRuntime").toString().isEmpty());
|
||||
QVERIFY(!backend->javaRuntimeInfo(id).value("installed").toBool());
|
||||
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->installJavaRuntime("no-such-runtime");
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("java.error.buildNotInCatalog").arg("no-such-runtime"));
|
||||
}
|
||||
|
||||
// --- сезонные сборки --------------------------------------------------------
|
||||
|
||||
void seasonalBuildEndToEnd()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
|
||||
// Java 17 уже скачана лаунчером — шаг Java пропускается.
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = QStringLiteral("temurin-jdk-17-test");
|
||||
runtime.kind = QStringLiteral("jdk");
|
||||
runtime.major = 17;
|
||||
runtime.version = QStringLiteral("17.0.8");
|
||||
runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8");
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
|
||||
|
||||
const auto publishSeason = [&](int revision, const QMap<QString, QByteArray> &files) {
|
||||
const QByteArray pack = Fixtures::zipBytes(files);
|
||||
const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-6-r%1.zip").arg(revision);
|
||||
m_net.route(url, FakeResponse::bytes(pack));
|
||||
const QJsonObject entry{
|
||||
{"id", "season-6"}, {"name", "Season 6"}, {"revision", revision}, {"minecraftVersion", "1.20.1"},
|
||||
{"loader", ""}, {"loaderVersion", ""}, {"modCount", files.size()}, {"seasonStart", "2026-01-01"},
|
||||
{"serverUrl", "season6.example.com"}, {"javaMajor", 17},
|
||||
{"archive", QJsonObject{{"url", url}, {"size", pack.size()}, {"sha256", Fixtures::sha256(pack)}}}};
|
||||
m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}}));
|
||||
};
|
||||
publishSeason(1, {{"mods/a.jar", "a1"}, {"mods/old.jar", "old"}, {"config/c.toml", "c=1"}});
|
||||
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
QSignalSpy done(backend.get(), &LauncherBackend::seasonalInstallFinished);
|
||||
backend->refreshSeasonalCatalog();
|
||||
QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; }));
|
||||
QVariantMap row = backend->seasonalCatalog().constFirst().toMap();
|
||||
QCOMPARE(row.value("status").toString(), Loc::text("seasonal.status.notInstalled"));
|
||||
QCOMPARE(row.value("loaderTitle").toString(), Loc::text("loader.label.vanilla"));
|
||||
QCOMPARE(row.value("seasonStart").toString(), QStringLiteral("01.01.2026"));
|
||||
|
||||
backend->installSeasonalBuild("season-6");
|
||||
QVERIFY(backend->seasonalInstalling());
|
||||
QVERIFY(backend->busy());
|
||||
QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QCOMPARE(done.constFirst().at(0).toString(), QStringLiteral("season-6"));
|
||||
QVERIFY(!backend->seasonalInstalling());
|
||||
QVERIFY(waitIdle(*backend));
|
||||
|
||||
// Сборка заведена, активна, версия игры стоит, файлы пака разложены, ревизия записана.
|
||||
QCOMPARE(backend->customBuildNames(), QStringList{"Season 6"});
|
||||
QCOMPARE(backend->activeBuildIndex(), 0);
|
||||
QVERIFY(backend->checkInstallation(0).isEmpty());
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a1"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/config/c.toml"), QByteArray("c=1"));
|
||||
const QVariantMap build = backend->customBuildAt(0);
|
||||
QCOMPARE(build.value("seasonalId").toString(), QStringLiteral("season-6"));
|
||||
QCOMPARE(build.value("seasonalRevision").toInt(), 1);
|
||||
QCOMPARE(build.value("serverUrl").toString(), QStringLiteral("season6.example.com"));
|
||||
const int buildId = Fixtures::readJsonFile(LauncherPaths::settingsFile()).value("activeBuildId").toInt();
|
||||
const QJsonObject state = Fixtures::readJsonFile(LauncherPaths::seasonalStateFile(buildId));
|
||||
QCOMPARE(state.value("revision").toInt(), 1);
|
||||
QCOMPARE(state.value("entries").toArray().size(), 3);
|
||||
QCOMPARE(backend->seasonalCatalog().constFirst().toMap().value("status").toString(),
|
||||
Loc::text("seasonal.status.installed"));
|
||||
|
||||
// Ревизия 2: мод old.jar из сборки убрали, a.jar обновили. Пользовательский мир не трогаем.
|
||||
Fixtures::write(m_game + "/saves/MyWorld/level.dat", "mine");
|
||||
publishSeason(2, {{"mods/a.jar", "a2"}, {"config/c.toml", "c=2"}});
|
||||
backend->refreshSeasonalCatalog(true);
|
||||
QVERIFY(waitFor([&] { return backend->seasonalCatalog().constFirst().toMap().value("updateAvailable").toBool(); }));
|
||||
done.clear();
|
||||
backend->installSeasonalBuild("season-6");
|
||||
QVERIFY(waitFor([&] { return done.count() + errors.count() > 0; }, 60000));
|
||||
QVERIFY2(errors.isEmpty(), qPrintable(strings(errors).join('\n')));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/a.jar"), QByteArray("a2"));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/old.jar"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/saves/MyWorld/level.dat"), QByteArray("mine"));
|
||||
QCOMPARE(backend->customBuildNames().size(), 1); // та же запись, а не вторая
|
||||
QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 2);
|
||||
// Служебный файл пака за собой не оставляем.
|
||||
QVERIFY(QDir(LauncherPaths::buildDir(buildId)).entryList({".pack-*"}, QDir::Files | QDir::Hidden).isEmpty());
|
||||
}
|
||||
|
||||
void seasonalPackChecksumFailureAborts()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = QStringLiteral("temurin-jdk-17-test2");
|
||||
runtime.kind = QStringLiteral("jdk");
|
||||
runtime.major = 17;
|
||||
runtime.version = QStringLiteral("17.0.8");
|
||||
runtime.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(runtime.id), "17.0.8");
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
|
||||
|
||||
const QString url = QStringLiteral("https://launcher-builds.galeonworx.com/packs/bad.zip");
|
||||
m_net.route(url, FakeResponse::bytes("not what was promised"));
|
||||
const QJsonObject entry{
|
||||
{"id", "bad-season"}, {"name", "Bad"}, {"revision", 1}, {"minecraftVersion", "1.20.1"},
|
||||
{"loader", ""}, {"javaMajor", 17},
|
||||
{"archive", QJsonObject{{"url", url}, {"size", 21}, {"sha256", QString(64, u'0')}}}};
|
||||
m_net.route(kSeasonalIndex, FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{entry}}}));
|
||||
|
||||
auto backend = make();
|
||||
QSignalSpy errors(backend.get(), &LauncherBackend::launchError);
|
||||
backend->refreshSeasonalCatalog();
|
||||
QVERIFY(waitFor([&] { return backend->seasonalCatalog().size() == 1; }));
|
||||
backend->installSeasonalBuild("bad-season");
|
||||
QVERIFY(waitFor([&] { return !backend->seasonalInstalling(); }, 60000));
|
||||
const QString all = strings(errors).join('\n');
|
||||
QVERIFY2(all.contains(Loc::text("seasonal.error.checksumMismatch")), qPrintable(all));
|
||||
QVERIFY(!backend->busy());
|
||||
QCOMPARE(backend->customBuildAt(0).value("seasonalRevision").toInt(), 0); // не отмечена установленной
|
||||
|
||||
backend->installSeasonalBuild("unknown-id");
|
||||
QCOMPARE(errors.constLast().constFirst().toString(), Loc::text("seasonal.error.notInCatalog").arg("unknown-id"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_GUI_TEST_MAIN(TestLauncherBackend)
|
||||
#include "tst_launcherbackend.moc"
|
||||
@@ -0,0 +1,449 @@
|
||||
#include "fakeinternet.h"
|
||||
|
||||
#include "fixtures.h"
|
||||
|
||||
#include <QEventLoop>
|
||||
#include <QHostAddress>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkProxy>
|
||||
#include <QSslConfiguration>
|
||||
#include <QSslKey>
|
||||
#include <QSslSocket>
|
||||
#include <QTimer>
|
||||
|
||||
struct FakeInternet::Connection
|
||||
{
|
||||
QSslSocket *socket = nullptr;
|
||||
QByteArray buffer;
|
||||
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
QByteArray reasonPhrase(int status)
|
||||
{
|
||||
switch (status) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 204: return "No Content";
|
||||
case 301: return "Moved Permanently";
|
||||
case 302: return "Found";
|
||||
case 304: return "Not Modified";
|
||||
case 400: return "Bad Request";
|
||||
case 401: return "Unauthorized";
|
||||
case 403: return "Forbidden";
|
||||
case 404: return "Not Found";
|
||||
case 410: return "Gone";
|
||||
case 429: return "Too Many Requests";
|
||||
case 500: return "Internal Server Error";
|
||||
case 502: return "Bad Gateway";
|
||||
case 503: return "Service Unavailable";
|
||||
default: return "Status";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QUrl FakeRequest::url() const
|
||||
{
|
||||
QUrl result;
|
||||
result.setScheme(scheme);
|
||||
result.setHost(host);
|
||||
result.setPath(path);
|
||||
if (!query.isEmpty())
|
||||
result.setQuery(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::json(const QByteArray &body, int status)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.status = status;
|
||||
response.body = body;
|
||||
response.contentType = "application/json";
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
|
||||
{
|
||||
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::text(const QByteArray &body, int status)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.status = status;
|
||||
response.body = body;
|
||||
response.contentType = "text/plain; charset=utf-8";
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::bytes(const QByteArray &body)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.body = body;
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::error(int status, const QByteArray &body)
|
||||
{
|
||||
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeInternet::FakeInternet(QObject *parent)
|
||||
: QTcpServer(parent)
|
||||
{
|
||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||
}
|
||||
|
||||
FakeInternet::~FakeInternet()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
bool FakeInternet::tlsAvailable(QString *why)
|
||||
{
|
||||
if (!QSslSocket::supportsSsl()) {
|
||||
if (why)
|
||||
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FakeInternet::start(QString *error)
|
||||
{
|
||||
if (m_started)
|
||||
return true;
|
||||
if (!tlsAvailable(error))
|
||||
return false;
|
||||
|
||||
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
|
||||
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
|
||||
|
||||
if (!listen(QHostAddress::LocalHost, 0)) {
|
||||
if (error)
|
||||
*error = errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QNetworkProxy::setApplicationProxy(
|
||||
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
|
||||
|
||||
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
|
||||
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||
QSslConfiguration::setDefaultConfiguration(ssl);
|
||||
|
||||
m_started = true;
|
||||
|
||||
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
|
||||
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
|
||||
// чем получить десятки непонятных падений в тестах сервисов.
|
||||
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
|
||||
route(probe, FakeResponse::text("pong"));
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
|
||||
QEventLoop loop;
|
||||
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
|
||||
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
if (!reply->isFinished())
|
||||
loop.exec();
|
||||
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
|
||||
&& reply->readAll() == "pong";
|
||||
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
|
||||
delete reply;
|
||||
reset();
|
||||
if (!ok) {
|
||||
stop();
|
||||
if (error)
|
||||
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
|
||||
.arg(why, QSslSocket::activeBackend());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FakeInternet::stop()
|
||||
{
|
||||
if (!m_started)
|
||||
return;
|
||||
// Никуда наружу и после остановки: прокси на закрытый порт.
|
||||
close();
|
||||
m_started = false;
|
||||
}
|
||||
|
||||
void FakeInternet::route(const QString &pattern, const Handler &handler)
|
||||
{
|
||||
unroute(pattern);
|
||||
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
|
||||
Route route;
|
||||
route.pattern = pattern;
|
||||
route.host = url.host().toLower();
|
||||
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
|
||||
route.prefix = pattern.endsWith(u'*');
|
||||
route.handler = handler;
|
||||
m_routes << route;
|
||||
}
|
||||
|
||||
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
|
||||
{
|
||||
route(pattern, [response](const FakeRequest &) { return response; });
|
||||
}
|
||||
|
||||
void FakeInternet::unroute(const QString &pattern)
|
||||
{
|
||||
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
|
||||
}
|
||||
|
||||
void FakeInternet::reset()
|
||||
{
|
||||
m_deferred.clear();
|
||||
m_diagnostics.clear();
|
||||
m_routes.clear();
|
||||
m_requests.clear();
|
||||
m_unmatched.clear();
|
||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||
}
|
||||
|
||||
bool FakeInternet::releaseResponse(const QString &url)
|
||||
{
|
||||
for (qsizetype i = 0; i < m_deferred.size(); ++i) {
|
||||
if (m_deferred.at(i).url == url) {
|
||||
const auto pending = m_deferred.takeAt(i);
|
||||
respond(pending.socket, pending.response, pending.keepAlive);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QString FakeInternet::diagnostics() const
|
||||
{
|
||||
return QStringLiteral("TLS backend: %1\nRequests: %2\n%3")
|
||||
.arg(QSslSocket::activeBackend(), requestedUrls().join(u' '), m_diagnostics.join(u'\n'));
|
||||
}
|
||||
|
||||
QList<FakeRequest> FakeInternet::requests() const
|
||||
{
|
||||
return m_requests;
|
||||
}
|
||||
|
||||
int FakeInternet::hits(const QString &url) const
|
||||
{
|
||||
const QUrl wanted(url);
|
||||
int count = 0;
|
||||
for (const FakeRequest &request : m_requests) {
|
||||
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
|
||||
&& request.path == wanted.path())
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QStringList FakeInternet::unmatched() const
|
||||
{
|
||||
return m_unmatched;
|
||||
}
|
||||
|
||||
QStringList FakeInternet::requestedUrls() const
|
||||
{
|
||||
QStringList result;
|
||||
for (const FakeRequest &request : m_requests)
|
||||
result << request.url().toString();
|
||||
return result;
|
||||
}
|
||||
|
||||
void FakeInternet::incomingConnection(qintptr descriptor)
|
||||
{
|
||||
auto *socket = new QSslSocket(this);
|
||||
if (!socket->setSocketDescriptor(descriptor)) {
|
||||
delete socket;
|
||||
return;
|
||||
}
|
||||
|
||||
auto *connection = new Connection;
|
||||
connection->socket = socket;
|
||||
|
||||
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
|
||||
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
|
||||
socket->ignoreSslErrors();
|
||||
});
|
||||
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
|
||||
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
|
||||
socket->deleteLater();
|
||||
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
|
||||
});
|
||||
}
|
||||
|
||||
void FakeInternet::onReadyRead(Connection *connection)
|
||||
{
|
||||
connection->buffer += connection->socket->readAll();
|
||||
processBuffer(connection);
|
||||
}
|
||||
|
||||
void FakeInternet::processBuffer(Connection *connection)
|
||||
{
|
||||
for (;;) {
|
||||
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0)
|
||||
return;
|
||||
|
||||
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
|
||||
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
|
||||
if (requestLine.size() < 2) {
|
||||
connection->socket->abort();
|
||||
return;
|
||||
}
|
||||
|
||||
FakeRequest request;
|
||||
request.method = requestLine.at(0);
|
||||
for (qsizetype i = 1; i < lines.size(); ++i) {
|
||||
const QByteArray line = lines.at(i).trimmed();
|
||||
const qsizetype colon = line.indexOf(':');
|
||||
if (colon > 0)
|
||||
request.headers.insert(line.left(colon).trimmed().toLower(),
|
||||
line.mid(colon + 1).trimmed());
|
||||
}
|
||||
|
||||
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
|
||||
if (connection->buffer.size() < headerEnd + 4 + bodySize)
|
||||
return; // тело ещё не дошло
|
||||
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
|
||||
connection->buffer.remove(0, headerEnd + 4 + bodySize);
|
||||
|
||||
const QByteArray target = requestLine.at(1);
|
||||
|
||||
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
|
||||
QString host = QString::fromLatin1(target);
|
||||
host = host.left(host.lastIndexOf(u':')).toLower();
|
||||
connection->tunnelHost = host;
|
||||
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
connection->socket->flush();
|
||||
startTls(connection);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!connection->tunnelHost.isEmpty()) {
|
||||
request.scheme = QStringLiteral("https");
|
||||
request.host = connection->tunnelHost;
|
||||
const QUrl relative(QString::fromLatin1(target));
|
||||
request.path = relative.path(QUrl::FullyDecoded);
|
||||
request.query = relative.query(QUrl::FullyEncoded);
|
||||
} else {
|
||||
// Простой http через прокси — адрес в absolute-form.
|
||||
const QUrl absolute(QString::fromLatin1(target));
|
||||
request.scheme = absolute.scheme();
|
||||
request.host = absolute.host().toLower();
|
||||
request.path = absolute.path(QUrl::FullyDecoded);
|
||||
request.query = absolute.query(QUrl::FullyEncoded);
|
||||
}
|
||||
if (request.path.isEmpty())
|
||||
request.path = QStringLiteral("/");
|
||||
|
||||
dispatch(connection, request);
|
||||
}
|
||||
}
|
||||
|
||||
void FakeInternet::startTls(Connection *connection)
|
||||
{
|
||||
QSslSocket *socket = connection->socket;
|
||||
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
|
||||
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
|
||||
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||
socket->startServerEncryption();
|
||||
}
|
||||
|
||||
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
|
||||
{
|
||||
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
|
||||
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
|
||||
const Route &route = m_routes.at(i);
|
||||
if (route.host != request.host)
|
||||
continue;
|
||||
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
|
||||
return &route;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||||
{
|
||||
m_requests << request;
|
||||
const QString url = request.url().toString();
|
||||
|
||||
FakeResponse response;
|
||||
if (const Route *route = match(request)) {
|
||||
response = route->handler(request);
|
||||
} else {
|
||||
m_unmatched << url;
|
||||
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
|
||||
qPrintable(url));
|
||||
response = m_fallback;
|
||||
}
|
||||
|
||||
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||
QPointer<QSslSocket> socket(connection->socket);
|
||||
if (response.hang)
|
||||
return;
|
||||
if (response.deferred) {
|
||||
m_deferred.append({url, socket, response, keepAlive});
|
||||
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<QSslSocket> 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";
|
||||
|
||||
if (response.truncateTo >= 0) {
|
||||
m_diagnostics << QStringLiteral("truncate: declared=%1, sent=%2")
|
||||
.arg(response.body.size()).arg(response.body.left(response.truncateTo).size());
|
||||
// Сначала отправляем TLS-записи с неполным телом, затем рвём TCP.
|
||||
// disconnectFromHost() выполняет штатное завершение TLS, а здесь
|
||||
// моделируется обрыв. Готовность определяется буферами, не задержкой.
|
||||
connect(socket, &QSslSocket::encryptedBytesWritten, socket, [this, socket](qint64) {
|
||||
QTimer::singleShot(0, socket, [this, socket] {
|
||||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||
return;
|
||||
if (socket->bytesToWrite() || socket->encryptedBytesToWrite())
|
||||
return;
|
||||
m_diagnostics << QStringLiteral("truncate: TLS buffers drained, aborting TCP");
|
||||
socket->abort();
|
||||
m_diagnostics << QStringLiteral("truncate: state after abort=%1").arg(socket->state());
|
||||
});
|
||||
});
|
||||
socket->write(head);
|
||||
socket->write(response.body.left(response.truncateTo));
|
||||
return;
|
||||
}
|
||||
socket->write(head);
|
||||
socket->write(response.body);
|
||||
if (close)
|
||||
socket->disconnectFromHost();
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
#pragma once
|
||||
|
||||
// «Фейковый интернет» для тестов сетевых сервисов без правки их кода.
|
||||
//
|
||||
// Сервисы лаунчера ходят на захардкоженные https-адреса через собственные
|
||||
// QNetworkAccessManager. Прокси и SSL-конфигурация по умолчанию у Qt общие
|
||||
// на весь процесс, поэтому start():
|
||||
// - ставит QNetworkProxy::setApplicationProxy() на локальный порт;
|
||||
// - отключает проверку сертификата в QSslConfiguration::defaultConfiguration().
|
||||
// Дальше каждое https-соединение приходит сюда как CONNECT host:443; сокет
|
||||
// переводится в TLS с тестовым самоподписанным сертификатом и обслуживается
|
||||
// как обычный HTTP/1.1 — по таблице маршрутов (host + path).
|
||||
//
|
||||
// Запрос на неизвестный адрес получает 404 и попадает в unmatched(): тест
|
||||
// может проверить, что код не ушёл куда-то, куда не ожидали.
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTcpServer>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QSslSocket;
|
||||
class QTcpSocket;
|
||||
|
||||
struct FakeRequest
|
||||
{
|
||||
QByteArray method;
|
||||
QString scheme; // https | http
|
||||
QString host;
|
||||
QString path;
|
||||
QString query; // без «?»
|
||||
QHash<QByteArray, QByteArray> headers; // имена в нижнем регистре
|
||||
QByteArray body;
|
||||
|
||||
QUrl url() const;
|
||||
QUrlQuery queryItems() const { return QUrlQuery(query); }
|
||||
QUrlQuery formItems() const { return QUrlQuery(QString::fromUtf8(body)); }
|
||||
QByteArray header(const QByteArray &name) const { return headers.value(name.toLower()); }
|
||||
};
|
||||
|
||||
struct FakeResponse
|
||||
{
|
||||
int status = 200;
|
||||
QByteArray body;
|
||||
QByteArray contentType = "application/octet-stream";
|
||||
QList<QPair<QByteArray, QByteArray>> headers;
|
||||
int delayMs = 0; // ответить с задержкой
|
||||
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||
bool deferred = false; // ждать явного releaseResponse(), без таймера
|
||||
bool drop = false; // оборвать соединение без ответа
|
||||
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||
|
||||
static FakeResponse json(const QByteArray &body, int status = 200);
|
||||
static FakeResponse json(const class QJsonObject &object, int status = 200);
|
||||
static FakeResponse text(const QByteArray &body, int status = 200);
|
||||
static FakeResponse bytes(const QByteArray &body);
|
||||
static FakeResponse error(int status, const QByteArray &body = {});
|
||||
};
|
||||
|
||||
class FakeInternet : public QTcpServer
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using Handler = std::function<FakeResponse(const FakeRequest &)>;
|
||||
|
||||
explicit FakeInternet(QObject *parent = nullptr);
|
||||
~FakeInternet() override;
|
||||
|
||||
// Слушает 127.0.0.1 и перенаправляет на себя весь трафик процесса.
|
||||
// При ошибке пишет причину в error (например, нет TLS-бэкенда с поддержкой сервера).
|
||||
bool start(QString *error = nullptr);
|
||||
void stop();
|
||||
|
||||
// Есть ли TLS вообще (без него сетевые тесты пропускаются).
|
||||
static bool tlsAvailable(QString *why = nullptr);
|
||||
|
||||
// Маршрут: "https://host/path" — точное совпадение пути (query не учитывается),
|
||||
// "https://host/prefix/*" — всё, что начинается с prefix/.
|
||||
void route(const QString &pattern, const Handler &handler);
|
||||
void route(const QString &pattern, const FakeResponse &response);
|
||||
void unroute(const QString &pattern);
|
||||
|
||||
// Ответ на всё, что не подошло ни к одному маршруту (по умолчанию 404).
|
||||
void setFallback(const FakeResponse &response) { m_fallback = response; }
|
||||
|
||||
// Сбросить маршруты и журнал.
|
||||
void reset();
|
||||
bool releaseResponse(const QString &url);
|
||||
QString diagnostics() const;
|
||||
|
||||
QList<FakeRequest> requests() const;
|
||||
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||
int hits(const QString &url) const;
|
||||
QStringList unmatched() const;
|
||||
QStringList requestedUrls() const;
|
||||
|
||||
signals:
|
||||
void requestServed(const QString &url);
|
||||
|
||||
protected:
|
||||
void incomingConnection(qintptr descriptor) override;
|
||||
|
||||
private:
|
||||
struct Connection;
|
||||
struct Route
|
||||
{
|
||||
QString pattern;
|
||||
QString host;
|
||||
QString path;
|
||||
bool prefix = false;
|
||||
Handler handler;
|
||||
};
|
||||
|
||||
void onReadyRead(Connection *connection);
|
||||
void processBuffer(Connection *connection);
|
||||
void dispatch(Connection *connection, FakeRequest request);
|
||||
void respond(QPointer<QSslSocket> socket, const FakeResponse &response, bool keepAlive);
|
||||
void startTls(Connection *connection);
|
||||
const Route *match(const FakeRequest &request) const;
|
||||
|
||||
QList<Route> m_routes;
|
||||
FakeResponse m_fallback;
|
||||
QList<FakeRequest> m_requests;
|
||||
QStringList m_unmatched;
|
||||
struct DeferredResponse
|
||||
{
|
||||
QString url;
|
||||
QPointer<QSslSocket> socket;
|
||||
FakeResponse response;
|
||||
bool keepAlive;
|
||||
};
|
||||
QList<DeferredResponse> m_deferred;
|
||||
QStringList m_diagnostics;
|
||||
QByteArray m_certificate;
|
||||
QByteArray m_key;
|
||||
bool m_started = false;
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
#include "fakemojang.h"
|
||||
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kMeta = QStringLiteral("https://piston-meta.mojang.com/v1/packages/");
|
||||
const QString kData = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
|
||||
const QString kLibraries = QStringLiteral("https://libraries.minecraft.net/");
|
||||
const QString kResources = QStringLiteral("https://resources.download.minecraft.net/");
|
||||
|
||||
QJsonObject download(const QString &url, const QByteArray &data, const QString &path = {})
|
||||
{
|
||||
QJsonObject object{{"url", url}, {"sha1", Fixtures::sha1(data)}, {"size", data.size()}};
|
||||
if (!path.isEmpty())
|
||||
object.insert("path", path);
|
||||
return object;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FakeMojang::FakeMojang(FakeInternet &net)
|
||||
: m_net(net)
|
||||
{
|
||||
}
|
||||
|
||||
QString FakeMojang::manifestUrl()
|
||||
{
|
||||
return QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
}
|
||||
|
||||
QString FakeMojang::addObject(const QString &relativePath, const QByteArray &data)
|
||||
{
|
||||
QString url;
|
||||
if (relativePath.startsWith(QLatin1String("libraries/")))
|
||||
url = kLibraries + relativePath.mid(int(sizeof("libraries/") - 1));
|
||||
else if (relativePath.startsWith(QLatin1String("assets/objects/")))
|
||||
url = kResources + relativePath.mid(int(sizeof("assets/objects/") - 1));
|
||||
else
|
||||
url = kData + Fixtures::sha1(data) + u'/' + relativePath.section(u'/', -1);
|
||||
|
||||
m_files.insert(relativePath, {url, data});
|
||||
m_net.route(url, FakeResponse::bytes(data));
|
||||
return url;
|
||||
}
|
||||
|
||||
void FakeMojang::overrideFile(const QString &relativePath, const FakeResponse &response)
|
||||
{
|
||||
m_net.route(m_files.value(relativePath).url, response);
|
||||
}
|
||||
|
||||
QString FakeMojang::fileUrl(const QString &relativePath) const
|
||||
{
|
||||
return m_files.value(relativePath).url;
|
||||
}
|
||||
|
||||
void FakeMojang::publish(const VersionSpec &spec)
|
||||
{
|
||||
QHash<QString, QByteArray> expected;
|
||||
QJsonObject json{{"id", spec.id}, {"type", spec.type}, {"releaseTime", spec.releaseTime}};
|
||||
|
||||
if (!spec.inheritsFrom.isEmpty()) {
|
||||
json.insert("inheritsFrom", spec.inheritsFrom);
|
||||
json.insert("mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient");
|
||||
const QString libPath = QStringLiteral("net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar");
|
||||
const QByteArray libData = "fabric-loader-" + spec.id.toUtf8();
|
||||
const QString url = addObject("libraries/" + libPath, libData);
|
||||
json.insert("libraries", QJsonArray{QJsonObject{
|
||||
{"name", "net.fabricmc:fabric-loader:0.16.10"},
|
||||
{"downloads", QJsonObject{{"artifact", download(url, libData, libPath)}}}}});
|
||||
expected.insert("libraries/" + libPath, libData);
|
||||
} else {
|
||||
json.insert("mainClass", "net.minecraft.client.main.Main");
|
||||
json.insert("arguments", QJsonObject{
|
||||
{"game", QJsonArray{"--username", "${auth_player_name}", "--version", "${version_name}",
|
||||
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||
"--accessToken", "${auth_access_token}", "--userType", "${user_type}",
|
||||
"--versionType", "${version_type}"}},
|
||||
{"jvm", QJsonArray{"-Djava.library.path=${natives_directory}", "-cp", "${classpath}"}}});
|
||||
json.insert("javaVersion", QJsonObject{{"component", "java-runtime-gamma"}, {"majorVersion", spec.javaMajor}});
|
||||
|
||||
// client.jar
|
||||
const QString clientPath = "versions/" + spec.id + u'/' + spec.id + ".jar";
|
||||
const QByteArray clientData = "client-jar-of-" + spec.id.toUtf8();
|
||||
const QString clientUrl = addObject(clientPath, clientData);
|
||||
json.insert("downloads", QJsonObject{{"client", download(clientUrl, clientData)}});
|
||||
expected.insert(clientPath, clientData);
|
||||
|
||||
// Обычная библиотека и натив под текущую платформу.
|
||||
const QString native = VersionLoader::nativeClassifier();
|
||||
const QString libPath = QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
const QByteArray libData = "brigadier";
|
||||
const QString nativePath = QStringLiteral("org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-%1.jar").arg(native);
|
||||
const QByteArray nativeData = Fixtures::zipBytes({{"liblwjgl.so", "so"}, {"lwjgl.dll", "dll"},
|
||||
{"liblwjgl.dylib", "dylib"}});
|
||||
const QString libUrl = addObject("libraries/" + libPath, libData);
|
||||
const QString nativeUrl = addObject("libraries/" + nativePath, nativeData);
|
||||
json.insert("libraries", QJsonArray{
|
||||
QJsonObject{{"name", "com.mojang:brigadier:1.1.8"},
|
||||
{"downloads", QJsonObject{{"artifact", download(libUrl, libData, libPath)}}}},
|
||||
QJsonObject{{"name", "org.lwjgl:lwjgl:3.3.1:" + native},
|
||||
{"downloads", QJsonObject{{"artifact", download(nativeUrl, nativeData, nativePath)}}},
|
||||
{"rules", QJsonArray{QJsonObject{{"action", "allow"},
|
||||
{"os", QJsonObject{{"name", VersionLoader::osName()}}}}}}}});
|
||||
expected.insert("libraries/" + libPath, libData);
|
||||
expected.insert("libraries/" + nativePath, nativeData);
|
||||
|
||||
// Ассеты.
|
||||
const QString indexId = spec.legacyAssets ? QStringLiteral("legacy") : spec.id + "-assets";
|
||||
QJsonObject objects;
|
||||
qint64 total = 0;
|
||||
for (const QString &name : {QStringLiteral("minecraft/sounds/click.ogg"), QStringLiteral("icons/icon_16x16.png")}) {
|
||||
const QByteArray data = (name + "@" + spec.id).toUtf8();
|
||||
const QString hash = Fixtures::sha1(data);
|
||||
const QString objectPath = "assets/objects/" + hash.left(2) + u'/' + hash;
|
||||
addObject(objectPath, data);
|
||||
objects.insert(name, QJsonObject{{"hash", hash}, {"size", data.size()}});
|
||||
expected.insert(objectPath, data);
|
||||
if (spec.legacyAssets)
|
||||
expected.insert("assets/virtual/legacy/" + name, data);
|
||||
total += data.size();
|
||||
}
|
||||
QJsonObject index{{"objects", objects}};
|
||||
if (spec.legacyAssets)
|
||||
index.insert("virtual", true);
|
||||
const QByteArray indexData = QJsonDocument(index).toJson(QJsonDocument::Compact);
|
||||
const QString indexPath = "assets/indexes/" + indexId + ".json";
|
||||
const QString indexUrl = addObject(indexPath, indexData);
|
||||
QJsonObject assetIndex = download(indexUrl, indexData);
|
||||
assetIndex.insert("id", indexId);
|
||||
assetIndex.insert("totalSize", total);
|
||||
json.insert("assetIndex", assetIndex);
|
||||
json.insert("assets", indexId);
|
||||
expected.insert(indexPath, indexData);
|
||||
|
||||
// Конфиг логирования.
|
||||
const QByteArray logData = "<Configuration/>";
|
||||
const QString logPath = QStringLiteral("assets/log_configs/client-1.12.xml");
|
||||
const QString logUrl = addObject(logPath, logData);
|
||||
QJsonObject logFile = download(logUrl, logData);
|
||||
logFile.insert("id", "client-1.12.xml");
|
||||
json.insert("logging", QJsonObject{{"client", QJsonObject{
|
||||
{"argument", "-Dlog4j.configurationFile=${path}"},
|
||||
{"file", logFile}, {"type", "log4j2-xml"}}}});
|
||||
expected.insert(logPath, logData);
|
||||
}
|
||||
|
||||
const QByteArray jsonData = QJsonDocument(json).toJson(QJsonDocument::Compact);
|
||||
m_versionJson.insert(spec.id, jsonData);
|
||||
expected.insert("versions/" + spec.id + u'/' + spec.id + ".json", jsonData);
|
||||
m_expected.insert(spec.id, expected);
|
||||
m_net.route(versionJsonUrl(spec.id), FakeResponse::json(jsonData));
|
||||
|
||||
m_specs.removeIf([&](const VersionSpec &s) { return s.id == spec.id; });
|
||||
m_specs << spec;
|
||||
routeManifest();
|
||||
}
|
||||
|
||||
QString FakeMojang::versionJsonUrl(const QString &id) const
|
||||
{
|
||||
return kMeta + Fixtures::sha1(m_versionJson.value(id)) + u'/' + id + ".json";
|
||||
}
|
||||
|
||||
QByteArray FakeMojang::versionJson(const QString &id) const
|
||||
{
|
||||
return m_versionJson.value(id);
|
||||
}
|
||||
|
||||
void FakeMojang::routeManifest()
|
||||
{
|
||||
QJsonArray versions;
|
||||
for (const VersionSpec &spec : std::as_const(m_specs)) {
|
||||
if (!spec.listed)
|
||||
continue;
|
||||
versions << QJsonObject{{"id", spec.id},
|
||||
{"type", spec.type},
|
||||
{"url", versionJsonUrl(spec.id)},
|
||||
{"sha1", Fixtures::sha1(m_versionJson.value(spec.id))},
|
||||
{"releaseTime", spec.releaseTime},
|
||||
{"time", spec.releaseTime}};
|
||||
}
|
||||
m_net.route(manifestUrl(), FakeResponse::json(QJsonObject{{"versions", versions}}));
|
||||
}
|
||||
|
||||
QHash<QString, QByteArray> FakeMojang::expectedFiles(const QString &id) const
|
||||
{
|
||||
return m_expected.value(id);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
// Миниатюрный Mojang для FakeInternet: манифест версий, json версий, client.jar,
|
||||
// библиотеки (в т.ч. натив под текущую ОС), индекс ассетов, сами ассеты и
|
||||
// конфиг логирования. Содержимое крошечное, но устроено как настоящее —
|
||||
// VersionInstaller и GameLauncher работают с ним без поблажек.
|
||||
|
||||
#include "fakeinternet.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class FakeMojang
|
||||
{
|
||||
public:
|
||||
struct VersionSpec
|
||||
{
|
||||
QString id;
|
||||
QString type = QStringLiteral("release");
|
||||
QString inheritsFrom; // непусто — «дочерняя» версия без своих файлов
|
||||
bool legacyAssets = false; // индекс с virtual: true (как у 1.6 и старше)
|
||||
int javaMajor = 17;
|
||||
QString releaseTime = QStringLiteral("2023-06-12T13:25:51+00:00");
|
||||
bool listed = true; // попадает ли в манифест
|
||||
};
|
||||
|
||||
explicit FakeMojang(FakeInternet &net);
|
||||
|
||||
// Публикует версию: json + все файлы, на которые он ссылается.
|
||||
void publish(const VersionSpec &spec);
|
||||
// Переопубликовать манифест (после publish всех версий вызывается автоматически).
|
||||
void routeManifest();
|
||||
|
||||
// Подменить ответ на конкретный файл (по относительному пути внутри .minecraft,
|
||||
// например "libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar").
|
||||
void overrideFile(const QString &relativePath, const FakeResponse &response);
|
||||
|
||||
// Что должно лечь на диск после установки: относительный путь → содержимое.
|
||||
QHash<QString, QByteArray> expectedFiles(const QString &id) const;
|
||||
// URL json версии и URL конкретного файла.
|
||||
QString versionJsonUrl(const QString &id) const;
|
||||
QString fileUrl(const QString &relativePath) const;
|
||||
QByteArray versionJson(const QString &id) const;
|
||||
|
||||
static QString manifestUrl();
|
||||
|
||||
private:
|
||||
struct File
|
||||
{
|
||||
QString url;
|
||||
QByteArray data;
|
||||
};
|
||||
|
||||
QString addObject(const QString &relativePath, const QByteArray &data);
|
||||
|
||||
FakeInternet &m_net;
|
||||
QList<VersionSpec> m_specs;
|
||||
QHash<QString, QByteArray> m_versionJson; // id → json
|
||||
QHash<QString, QHash<QString, QByteArray>> m_expected; // id → (путь → данные)
|
||||
QHash<QString, File> m_files; // относительный путь → файл
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcess>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtCore/private/qzipreader_p.h>
|
||||
#include <QtCore/private/qzipwriter_p.h>
|
||||
|
||||
QString Fixtures::path(const QString &relative)
|
||||
{
|
||||
return QStringLiteral(BILAUNCHER_FIXTURES_DIR) + u'/' + relative;
|
||||
}
|
||||
|
||||
QByteArray Fixtures::read(const QString &relative)
|
||||
{
|
||||
QFile file(path(relative));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
qFatal("Нет фикстуры %s", qPrintable(file.fileName()));
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
QJsonObject Fixtures::readJson(const QString &relative)
|
||||
{
|
||||
return QJsonDocument::fromJson(read(relative)).object();
|
||||
}
|
||||
|
||||
QString Fixtures::otherOsName()
|
||||
{
|
||||
return VersionLoader::osName() == QLatin1String("windows") ? QStringLiteral("linux")
|
||||
: QStringLiteral("windows");
|
||||
}
|
||||
|
||||
QByteArray Fixtures::applyTemplate(QByteArray text)
|
||||
{
|
||||
text.replace("%OS%", VersionLoader::osName().toUtf8());
|
||||
text.replace("%OTHER%", otherOsName().toUtf8());
|
||||
text.replace("%ARCH%", VersionLoader::osArch().toUtf8());
|
||||
text.replace("%NATIVE%", VersionLoader::nativeClassifier().toUtf8());
|
||||
return text;
|
||||
}
|
||||
|
||||
QByteArray Fixtures::readTemplate(const QString &relative)
|
||||
{
|
||||
return applyTemplate(read(relative));
|
||||
}
|
||||
|
||||
bool Fixtures::write(const QString &filePath, const QByteArray &data)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return false;
|
||||
return file.write(data) == data.size();
|
||||
}
|
||||
|
||||
bool Fixtures::writeJson(const QString &filePath, const QJsonObject &object)
|
||||
{
|
||||
return write(filePath, QJsonDocument(object).toJson(QJsonDocument::Indented));
|
||||
}
|
||||
|
||||
QByteArray Fixtures::readFile(const QString &filePath)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return {};
|
||||
return file.readAll();
|
||||
}
|
||||
|
||||
QJsonObject Fixtures::readJsonFile(const QString &filePath)
|
||||
{
|
||||
return QJsonDocument::fromJson(readFile(filePath)).object();
|
||||
}
|
||||
|
||||
QByteArray Fixtures::zipBytes(const QMap<QString, QByteArray> &entries)
|
||||
{
|
||||
QBuffer buffer;
|
||||
buffer.open(QIODevice::WriteOnly);
|
||||
{
|
||||
QZipWriter writer(&buffer);
|
||||
writer.setCompressionPolicy(QZipWriter::AutoCompress);
|
||||
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
|
||||
if (it.key().endsWith(u'/'))
|
||||
writer.addDirectory(it.key());
|
||||
else
|
||||
writer.addFile(it.key(), it.value());
|
||||
}
|
||||
writer.close();
|
||||
}
|
||||
return buffer.data();
|
||||
}
|
||||
|
||||
bool Fixtures::makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries)
|
||||
{
|
||||
return write(zipPath, zipBytes(entries));
|
||||
}
|
||||
|
||||
QStringList Fixtures::zipEntries(const QString &zipPath)
|
||||
{
|
||||
QZipReader reader(zipPath);
|
||||
QStringList result;
|
||||
for (const QZipReader::FileInfo &info : reader.fileInfoList()) {
|
||||
if (info.isFile)
|
||||
result << info.filePath;
|
||||
}
|
||||
result.sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Fixtures::makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries)
|
||||
{
|
||||
QTemporaryDir staging;
|
||||
if (!staging.isValid())
|
||||
return false;
|
||||
QStringList tops;
|
||||
for (auto it = entries.cbegin(); it != entries.cend(); ++it) {
|
||||
if (!write(staging.path() + u'/' + it.key(), it.value()))
|
||||
return false;
|
||||
const QString top = it.key().section(u'/', 0, 0);
|
||||
if (!tops.contains(top))
|
||||
tops << top;
|
||||
}
|
||||
QDir().mkpath(QFileInfo(archivePath).absolutePath());
|
||||
|
||||
QProcess tar;
|
||||
tar.setWorkingDirectory(staging.path());
|
||||
#if defined(Q_OS_WIN)
|
||||
// Системный bsdtar по полному пути: GNU tar из Git, который бывает раньше в PATH,
|
||||
// принимает «C:» за имя удалённого хоста (та же история в release.yml).
|
||||
const QString program = qEnvironmentVariable("SystemRoot") + QStringLiteral("\\System32\\tar.exe");
|
||||
#else
|
||||
const QString program = QStringLiteral("tar");
|
||||
#endif
|
||||
tar.start(program,
|
||||
QStringList{QStringLiteral("-czf"), QDir::toNativeSeparators(archivePath)} + tops);
|
||||
return tar.waitForFinished(30000) && tar.exitStatus() == QProcess::NormalExit
|
||||
&& tar.exitCode() == 0;
|
||||
}
|
||||
|
||||
QString Fixtures::sha1(const QByteArray &data)
|
||||
{
|
||||
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha1).toHex());
|
||||
}
|
||||
|
||||
QString Fixtures::sha256(const QByteArray &data)
|
||||
{
|
||||
return QString::fromLatin1(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex());
|
||||
}
|
||||
|
||||
QStringList Fixtures::listTree(const QString &dir)
|
||||
{
|
||||
QStringList result;
|
||||
QDirIterator it(dir, QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot,
|
||||
QDirIterator::Subdirectories);
|
||||
const QDir base(dir);
|
||||
while (it.hasNext())
|
||||
result << base.relativeFilePath(it.next());
|
||||
result.sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
QString Fixtures::javaExecutableName()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return QStringLiteral("java.exe");
|
||||
#else
|
||||
return QStringLiteral("java");
|
||||
#endif
|
||||
}
|
||||
|
||||
void Fixtures::configureFakeJava(const QString &javaPath, const QString &version,
|
||||
const QStringList &config)
|
||||
{
|
||||
QStringList lines{QStringLiteral("version=") + version};
|
||||
lines += config;
|
||||
write(QFileInfo(javaPath).absolutePath() + QStringLiteral("/fake-java.cfg"),
|
||||
lines.join(u'\n').toUtf8() + '\n');
|
||||
}
|
||||
|
||||
QString Fixtures::installFakeJava(const QString &dir, const QString &version,
|
||||
const QStringList &config)
|
||||
{
|
||||
const QString binDir = dir + QStringLiteral("/bin");
|
||||
QDir().mkpath(binDir);
|
||||
const QString java = binDir + u'/' + javaExecutableName();
|
||||
QFile::remove(java);
|
||||
if (!QFile::copy(QStringLiteral(BILAUNCHER_FAKE_JAVA), java))
|
||||
qFatal("Не удалось скопировать fake_java в %s", qPrintable(java));
|
||||
QFile::setPermissions(java, QFile::permissions(java) | QFileDevice::ExeOwner
|
||||
| QFileDevice::ExeGroup | QFileDevice::ExeOther);
|
||||
#if defined(Q_OS_WIN)
|
||||
const QString javaw = binDir + QStringLiteral("/javaw.exe");
|
||||
QFile::remove(javaw);
|
||||
QFile::copy(java, javaw);
|
||||
#endif
|
||||
configureFakeJava(java, version, config);
|
||||
return java;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
// Фикстуры и мелкие помощники для подготовки файлов в песочнице.
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
namespace Fixtures {
|
||||
|
||||
// Абсолютный путь к tests/fixtures/<relative>.
|
||||
QString path(const QString &relative);
|
||||
|
||||
// Содержимое фикстуры; при отсутствии файла тест падает через qFatal.
|
||||
QByteArray read(const QString &relative);
|
||||
QJsonObject readJson(const QString &relative);
|
||||
|
||||
// Фикстура-шаблон: %OS%, %OTHER%, %ARCH%, %NATIVE% заменяются на значения
|
||||
// текущей платформы (имя ОС в терминах Mojang, «чужая» ОС, архитектура,
|
||||
// классификатор нативов LWJGL) — один json годится для всех трёх ОС.
|
||||
QByteArray readTemplate(const QString &relative);
|
||||
QByteArray applyTemplate(QByteArray text);
|
||||
QString otherOsName();
|
||||
|
||||
// Пишет файл, создавая недостающие папки.
|
||||
bool write(const QString &path, const QByteArray &data);
|
||||
bool writeJson(const QString &path, const QJsonObject &object);
|
||||
QByteArray readFile(const QString &path);
|
||||
QJsonObject readJsonFile(const QString &path);
|
||||
|
||||
// Zip из пар «путь внутри архива → содержимое» (через QZipWriter, как в коде лаунчера).
|
||||
bool makeZip(const QString &zipPath, const QMap<QString, QByteArray> &entries);
|
||||
QByteArray zipBytes(const QMap<QString, QByteArray> &entries);
|
||||
// Список файлов в zip — как его видит QZipReader (ведущие «.» и «/» он срезает).
|
||||
QStringList zipEntries(const QString &zipPath);
|
||||
|
||||
// tar.gz через системный tar (есть и на Windows 10+, и на Unix).
|
||||
bool makeTarGz(const QString &archivePath, const QMap<QString, QByteArray> &entries);
|
||||
|
||||
QString sha1(const QByteArray &data);
|
||||
QString sha256(const QByteArray &data);
|
||||
|
||||
// Все файлы под папкой (относительные пути, «/» в качестве разделителя), отсортированы.
|
||||
QStringList listTree(const QString &dir);
|
||||
|
||||
// Подставная java (см. tests/fake_java). Копирует бинарник в <dir>/bin/java[.exe]
|
||||
// (на Windows ещё и javaw.exe) и пишет рядом fake-java.cfg из строк config.
|
||||
// Возвращает путь к java.
|
||||
QString installFakeJava(const QString &dir, const QString &version,
|
||||
const QStringList &config = {});
|
||||
// Переписать fake-java.cfg у уже установленной подставной java.
|
||||
void configureFakeJava(const QString &javaPath, const QString &version,
|
||||
const QStringList &config);
|
||||
|
||||
QString javaExecutableName();
|
||||
|
||||
} // namespace Fixtures
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
// Помощники для тестов сетевых сервисов.
|
||||
|
||||
#include "fakeinternet.h"
|
||||
|
||||
#include <QString>
|
||||
#include <QtTest/QTest>
|
||||
|
||||
// В initTestCase(): поднять FakeInternet или пропустить весь тест-класс.
|
||||
// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на всех ОС) недоступность —
|
||||
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||
#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;
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
#include <qt_windows.h>
|
||||
#include <stdlib.h> // _wputenv_s
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
QTemporaryDir *g_sandbox = nullptr;
|
||||
|
||||
[[noreturn]] void abortSetup(const QString &message)
|
||||
{
|
||||
const QString line = QStringLiteral("TestEnv: ") + message + u'\n';
|
||||
#if defined(Q_OS_WIN)
|
||||
// В живую консоль пишем UTF-16 напрямую: кодовая страница консоли тогда не важна.
|
||||
const HANDLE handle = GetStdHandle(STD_ERROR_HANDLE);
|
||||
DWORD mode = 0;
|
||||
if (handle != INVALID_HANDLE_VALUE && GetConsoleMode(handle, &mode)) {
|
||||
DWORD written = 0;
|
||||
WriteConsoleW(handle, reinterpret_cast<const wchar_t *>(line.utf16()),
|
||||
DWORD(line.size()), &written, nullptr);
|
||||
std::exit(2);
|
||||
}
|
||||
#endif
|
||||
// Вывод в трубу (ctest, CI) — UTF-8, как его и читает ctest. Локальная 8-битная
|
||||
// кодировка Windows (cp1252/cp1251) превращала кириллицу в «???».
|
||||
std::fputs(line.toUtf8().constData(), stderr);
|
||||
std::fflush(stderr);
|
||||
std::exit(2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TestEnv::prepareSandbox()
|
||||
{
|
||||
g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX"));
|
||||
if (!g_sandbox->isValid())
|
||||
abortSetup(QStringLiteral("не удалось создать временную папку"));
|
||||
|
||||
const QString home = QDir::toNativeSeparators(g_sandbox->path());
|
||||
setEnvPath("HOME", home);
|
||||
setEnvPath("USERPROFILE", home);
|
||||
// LauncherPaths: на Windows QDir::homePath() HOME/USERPROFILE не читает, а эту
|
||||
// переменную лаунчер читает везде. Дочерние процессы получают её по наследству.
|
||||
setEnvPath("BILAUNCHER_HOME", home);
|
||||
// Чужая Java из окружения машины не должна влиять на выбор Java в тестах.
|
||||
qunsetenv("JAVA_HOME");
|
||||
|
||||
std::atexit([] {
|
||||
delete g_sandbox;
|
||||
g_sandbox = nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
void TestEnv::finishSetup()
|
||||
{
|
||||
QCoreApplication::setApplicationName(QStringLiteral("BiLauncher"));
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("GaleonDev"));
|
||||
|
||||
const QString root = QDir::cleanPath(LauncherPaths::rootDir());
|
||||
const QString sandbox = QDir::cleanPath(g_sandbox->path());
|
||||
const QString game = QDir::cleanPath(LauncherPaths::defaultMinecraftDir());
|
||||
// Ни одна папка лаунчера не должна указывать на настоящий профиль: resetLauncherRoot()
|
||||
// стирает rootDir() целиком.
|
||||
for (const QString &path : {root, game}) {
|
||||
if (!path.startsWith(sandbox + u'/'))
|
||||
abortSetup(QStringLiteral("путь лаунчера %1 вне песочницы %2 — отказываюсь запускаться")
|
||||
.arg(QDir::toNativeSeparators(path), QDir::toNativeSeparators(sandbox)));
|
||||
}
|
||||
|
||||
if (!Localization::instance().load())
|
||||
abortSetup(QStringLiteral("не загрузился каталог переводов"));
|
||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||
}
|
||||
|
||||
void TestEnv::setEnvPath(const char *name, const QString &path)
|
||||
{
|
||||
// На Windows qputenv() пишет в ANSI-кодировке и испортил бы, например,
|
||||
// кириллическое имя пользователя во временном пути.
|
||||
#if defined(Q_OS_WIN)
|
||||
_wputenv_s(reinterpret_cast<const wchar_t *>(QString::fromLatin1(name).utf16()),
|
||||
reinterpret_cast<const wchar_t *>(path.utf16()));
|
||||
#else
|
||||
qputenv(name, QFile::encodeName(path));
|
||||
#endif
|
||||
}
|
||||
|
||||
QString TestEnv::homeDir()
|
||||
{
|
||||
return g_sandbox->path();
|
||||
}
|
||||
|
||||
QString TestEnv::freshDir(const QString &name)
|
||||
{
|
||||
const QString path = g_sandbox->path() + QStringLiteral("/work/") + name;
|
||||
QDir(path).removeRecursively();
|
||||
QDir().mkpath(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
void TestEnv::resetLauncherRoot()
|
||||
{
|
||||
QDir(LauncherPaths::rootDir()).removeRecursively();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
// Общая обвязка тестов: песочница для домашней папки и main для QtTest.
|
||||
//
|
||||
// LauncherPaths строит все пути от домашней папки. Каждый тестовый процесс до
|
||||
// создания QCoreApplication заводит свою временную папку и направляет туда
|
||||
// BILAUNCHER_HOME (её LauncherPaths читает на всех ОС), а заодно HOME и
|
||||
// USERPROFILE (их читает QDir::homePath() на Linux и macOS). На Windows
|
||||
// QDir::homePath() переменные окружения игнорирует, поэтому без BILAUNCHER_HOME
|
||||
// там не обойтись. Реальные папка лаунчера и .minecraft машины, на которой гоняются
|
||||
// тесты, не трогаются: если путь лаунчера всё же оказался вне песочницы,
|
||||
// процесс сразу завершается с кодом 2.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QString>
|
||||
#include <QtTest/QTest>
|
||||
|
||||
namespace TestEnv {
|
||||
|
||||
// До QCoreApplication: временная папка и подмена BILAUNCHER_HOME/HOME/USERPROFILE.
|
||||
void prepareSandbox();
|
||||
|
||||
// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу,
|
||||
// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины).
|
||||
void finishSetup();
|
||||
|
||||
// Переменная окружения с путём, без потерь символов вне ANSI-кодировки на Windows.
|
||||
void setEnvPath(const char *name, const QString &path);
|
||||
|
||||
// Корень песочницы — домашняя папка, от которой LauncherPaths строит пути в тестах.
|
||||
QString homeDir();
|
||||
|
||||
// Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.).
|
||||
QString freshDir(const QString &name);
|
||||
|
||||
// Стирает папку лаунчера (LauncherPaths::rootDir()) — между тестами.
|
||||
void resetLauncherRoot();
|
||||
|
||||
} // namespace TestEnv
|
||||
|
||||
#define BILAUNCHER_TEST_MAIN_IMPL(AppType, TestObject) \
|
||||
int main(int argc, char *argv[]) \
|
||||
{ \
|
||||
TestEnv::prepareSandbox(); \
|
||||
AppType app(argc, argv); \
|
||||
TestEnv::finishSetup(); \
|
||||
TestObject tc; \
|
||||
QTEST_SET_MAIN_SOURCE_PATH \
|
||||
return QTest::qExec(&tc, argc, argv); \
|
||||
}
|
||||
|
||||
#define BILAUNCHER_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QCoreApplication, TestObject)
|
||||
|
||||
// Тесту с GUI нужно самому подключить <QGuiApplication>.
|
||||
#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject)
|
||||
@@ -0,0 +1,138 @@
|
||||
// Подставная «java» для тестов. Без Qt, чтобы её копию можно было положить
|
||||
// в любую папку и запустить без поиска библиотек Qt.
|
||||
//
|
||||
// Поведение задаётся файлом fake-java.cfg рядом с исполняемым файлом,
|
||||
// по строке «ключ=значение»:
|
||||
// version=21.0.2 что печатать на «-version» (в stderr, как настоящая java)
|
||||
// exit=0 код выхода обычного запуска
|
||||
// sleep_ms=0 сколько «работать» перед выходом
|
||||
// args_file=<путь> куда записать аргументы, по одному на строку
|
||||
// dump_env=<ИМЯ> дописать в args_file строку «env:ИМЯ=значение» (можно несколько)
|
||||
// stdout=<строка> что напечатать в stdout (можно несколько)
|
||||
// create=<путь>\t<текст> создать файл с текстом; относительный путь — от рабочей папки
|
||||
// crash=1 завершиться аварийно (abort)
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#elif defined(__APPLE__)
|
||||
#include <mach-o/dyld.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path executablePath(const char *argv0)
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
wchar_t buffer[MAX_PATH * 4];
|
||||
const DWORD size = GetModuleFileNameW(nullptr, buffer, DWORD(std::size(buffer)));
|
||||
return fs::path(std::wstring(buffer, size));
|
||||
#elif defined(__APPLE__)
|
||||
char buffer[4096];
|
||||
uint32_t size = sizeof(buffer);
|
||||
if (_NSGetExecutablePath(buffer, &size) == 0)
|
||||
return fs::canonical(buffer);
|
||||
return fs::absolute(argv0);
|
||||
#else
|
||||
std::error_code ec;
|
||||
const fs::path self = fs::read_symlink("/proc/self/exe", ec);
|
||||
return ec ? fs::absolute(argv0) : self;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
std::string version = "17.0.8";
|
||||
int exitCode = 0;
|
||||
int sleepMs = 0;
|
||||
bool crash = false;
|
||||
std::string argsFile;
|
||||
std::vector<std::string> dumpEnv;
|
||||
std::vector<std::string> stdoutLines;
|
||||
std::vector<std::pair<std::string, std::string>> creates;
|
||||
|
||||
std::ifstream config(executablePath(argv[0]).parent_path() / "fake-java.cfg");
|
||||
for (std::string line; std::getline(config, line);) {
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
line.pop_back();
|
||||
const auto eq = line.find('=');
|
||||
if (eq == std::string::npos)
|
||||
continue;
|
||||
const std::string key = line.substr(0, eq);
|
||||
const std::string value = line.substr(eq + 1);
|
||||
if (key == "version")
|
||||
version = value;
|
||||
else if (key == "exit")
|
||||
exitCode = std::atoi(value.c_str());
|
||||
else if (key == "sleep_ms")
|
||||
sleepMs = std::atoi(value.c_str());
|
||||
else if (key == "crash")
|
||||
crash = value == "1";
|
||||
else if (key == "args_file")
|
||||
argsFile = value;
|
||||
else if (key == "dump_env")
|
||||
dumpEnv.push_back(value);
|
||||
else if (key == "stdout")
|
||||
stdoutLines.push_back(value);
|
||||
else if (key == "create") {
|
||||
const auto tab = value.find('\t');
|
||||
creates.emplace_back(value.substr(0, tab),
|
||||
tab == std::string::npos ? std::string() : value.substr(tab + 1));
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::string(argv[i]) == "-version") {
|
||||
std::cerr << "openjdk version \"" << version << "\" 2024-01-16\n"
|
||||
<< "OpenJDK Runtime Environment (fake)\n";
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!argsFile.empty()) {
|
||||
// binary: в текстовом режиме Windows превратит '\n' в "\r\n", и тесты увидят «-jar\r».
|
||||
std::ofstream out(argsFile, std::ios::trunc | std::ios::binary);
|
||||
for (int i = 1; i < argc; ++i)
|
||||
out << argv[i] << '\n';
|
||||
for (const std::string &name : dumpEnv) {
|
||||
const char *value = std::getenv(name.c_str());
|
||||
out << "env:" << name << '=' << (value ? value : "") << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto &[path, text] : creates) {
|
||||
const fs::path target(path);
|
||||
std::error_code ec;
|
||||
if (target.has_parent_path())
|
||||
fs::create_directories(target.parent_path(), ec);
|
||||
std::ofstream(target, std::ios::trunc | std::ios::binary) << text;
|
||||
}
|
||||
|
||||
for (const std::string &line : stdoutLines)
|
||||
std::cout << line << '\n';
|
||||
std::cout.flush();
|
||||
|
||||
if (sleepMs > 0)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs));
|
||||
if (crash) {
|
||||
#if defined(_WIN32)
|
||||
// QProcess считает аварией коды 0x8xxxxxxx–0xCxxxxxxx; abort() дал бы код 3.
|
||||
TerminateProcess(GetCurrentProcess(), 0xC0000005);
|
||||
#endif
|
||||
std::abort();
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"loader": {"separator": ".", "build": 9, "maven": "net.fabricmc:fabric-loader:0.16.9", "version": "0.16.9", "stable": true},
|
||||
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||
{"loader": {"separator": ".", "build": 10, "maven": "net.fabricmc:fabric-loader:0.16.10", "version": "0.16.10", "stable": true},
|
||||
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||
{"loader": {"separator": "+", "build": 1, "maven": "net.fabricmc:fabric-loader:0.17.0-beta.1", "version": "0.17.0-beta.1", "stable": false},
|
||||
"intermediary": {"maven": "net.fabricmc:intermediary:1.20.1", "version": "1.20.1", "stable": true}},
|
||||
{"loader": {"version": ""}}
|
||||
]
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>net.minecraftforge</groupId>
|
||||
<artifactId>forge</artifactId>
|
||||
<versioning>
|
||||
<latest>1.21.1-52.0.10</latest>
|
||||
<release>1.21.1-52.0.10</release>
|
||||
<versions>
|
||||
<version>1.7.10-10.13.4.1614-1.7.10</version>
|
||||
<version>1.20.1-47.3.0</version>
|
||||
<version>1.20.1-47.4.0</version>
|
||||
<version>1.20.1-47.4.1</version>
|
||||
<version>1.21.1-52.0.10</version>
|
||||
<version>1.21.1-52.0.2-beta</version>
|
||||
<version>broken-</version>
|
||||
</versions>
|
||||
</versioning>
|
||||
</metadata>
|
||||
@@ -0,0 +1,2 @@
|
||||
{"homepage": "https://files.minecraftforge.net/net/minecraftforge/forge/",
|
||||
"promos": {"1.20.1-latest": "47.4.1", "1.20.1-recommended": "47.4.0", "1.21.1-latest": "52.0.10"}}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>forge</artifactId>
|
||||
<versioning>
|
||||
<versions>
|
||||
<version>1.20.1-47.1.106</version>
|
||||
<version>1.20.1-47.1.79</version>
|
||||
</versions>
|
||||
</versioning>
|
||||
</metadata>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<metadata>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>neoforge</artifactId>
|
||||
<versioning>
|
||||
<versions>
|
||||
<version>20.4.80-beta</version>
|
||||
<version>20.4.237</version>
|
||||
<version>21.0.10</version>
|
||||
<version>21.1.200</version>
|
||||
<version>21.1.248</version>
|
||||
<version>26.1.0.5</version>
|
||||
<version>26.1.1.3</version>
|
||||
</versions>
|
||||
</versioning>
|
||||
</metadata>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{"loader": {"separator": ".", "build": 21, "maven": "org.quiltmc:quilt-loader:0.26.4-beta.1", "version": "0.26.4-beta.1"}},
|
||||
{"loader": {"separator": ".", "build": 20, "maven": "org.quiltmc:quilt-loader:0.26.3", "version": "0.26.3"}}
|
||||
]
|
||||
+16
@@ -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"}
|
||||
]
|
||||
}
|
||||
Vendored
+240
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -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-----
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8UBteu5TVZT26
|
||||
0tr9JIEhfxdRCzyuO6YqgrXdFU8qWRIaWfm6bPQxklqz3lDAcN/yzSopHbA7IVmA
|
||||
OXlLhgPqyi0eKpwXrNX0WY/XhKjR0Av4H9GzGcsVzyx4IO1boYHV9LQbfoQkFEWm
|
||||
3ZGLMYbHjN2Pm2HOWt/Saxd3MRjyAWuIWG56NLgNtfsGU+Oe5pdvaIWlNCc3D4yk
|
||||
gNx1+dS5SbnrHb3BlFu2syc6pI/tipWLTpczbDk1sNSVdcJJZHnuc+WuV4mUxfVh
|
||||
sN0RF/s2eCXYQIEI4BgnemQqRDYjw17Tn1sL1lGgX91qCAZyWnUlOLY+AN7B59KQ
|
||||
c+JOvt4FAgMBAAECggEABOS0AV8we+cYlo06IY/cRymNSo7XMdPc+ofRAAC4wOn1
|
||||
nXevFJgWNF+QOdJSGN4WA7IvIUhjNvYj5EAaFctSuLwuNfV3fun/2Uymfzw0Qtd8
|
||||
KCNziIroCxYKGskIQfRNcmjccUZ1WpaoRLDHQm20CeVwMHYiMYGNBWPBBVjiRA+X
|
||||
+T+iQq1f4GX2ErPw1dlcCSW/HVzyRqRwxB12VfQpW71Jq9h7oeOtcrVAEZj+rPaa
|
||||
io1u/Z4kmRxuyqgE9tcDUmdYhvijnZRhPSqWJhmQyVzzCpUX8Vtwy/Bow1EKUBaa
|
||||
jKtwu75ap4oTvaSJsHbX3+69eXJjIvXAQTKC7HgUFQKBgQDendqYimm1G/GDCBCj
|
||||
vXLYhHkP4MRI78jcggc4rJIv+rZGto7QZZRIEi5eVAdqlqoKL/xBIi7bDc6AH73p
|
||||
/M+xTWDJ4p09AMOY0k8WwsThzNOsA+R3futbKaM4NSTqwMzqaZs+jcyl/sIJUr8S
|
||||
tq7ndQtMSSf0O3mCg8nnvQuKLwKBgQDYjVmugVxKYchOZ3yXjPD7a/H54n4NiqnW
|
||||
/BvFPwhxMtQtb3YzzgyAuBskK3PEFjI+hKoLDGjHV/uZS1sAm1qVm2mN/lyPSeaU
|
||||
P2+1EOKsvWl9DoOkwTeb3QoGkXzZ35fORCLd7iphicFptFSgmqtaKT7KSQyKHaw2
|
||||
pxSwnPlyCwKBgCraqGIFAyRiJLPcsZdTuPAFUGjGFAMWZcxNG6bFUb76vGwQaJ2l
|
||||
cE4Pce2mr2bNah5b7mt7cRmqJr5zXdPVXwX6p0812J8NrpmUorT2+X+eNoE+npSC
|
||||
UjxSX6FKWbj3NHtLqR75nRO3XxL8YtvvXuwfk/UzgAV8DYAabpu1wKrxAoGAMUep
|
||||
ev7RQ9QQw1blkY9vX6fm/xi92tMNZ3Ej2LVJdIk+o8BTw+JGEhzTgF2rEJqEgJms
|
||||
IKuIRKUPComNggvYh2dAlCGoaqtEn8P35dqb2veXw+/DoYCe6QdaOrhtEx/A6h2z
|
||||
WZHTbzLsIuP4xjusdu4f7eTFx8CM6jsHiFGQqpUCgYBZlBn76C+3ctH4sgLfU/Zd
|
||||
Kz8cP+K/QpvA2R6K7DBn57drVG6wRIhibv3YO4YwDntLyOwZ2r5VA5uTvLqU2gGO
|
||||
AYDMTZ+g9csla0QjpoMDz3ThUgeYMb5nJ3maMx/kq3aNTGMKjncTLhiC7CNSWagC
|
||||
Mv+y9lT/Hf6ZSiiti8EQGQ==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "fabric-loader-0.15.0-1.20.1", "inheritsFrom": "1.20.1", "type": "release",
|
||||
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
|
||||
"libraries": [
|
||||
{"name": "net.fabricmc:fabric-loader:0.15.0", "url": "https://maven.fabricmc.net/"},
|
||||
{"name": "com.mojang:brigadier:9.9.9", "url": "https://maven.fabricmc.net/"}
|
||||
]
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "1.7.10", "mainClass": "net.minecraft.client.main.Main",
|
||||
"minecraftArguments": "--username ${auth_player_name} --session ${auth_session} --gameDir ${game_directory}",
|
||||
"assets": "1.7.10",
|
||||
"libraries": [
|
||||
{"name": "org.lwjgl.lwjgl:lwjgl-platform:2.9.1",
|
||||
"natives": {"linux": "natives-linux", "windows": "natives-windows-${arch}", "osx": "natives-osx"},
|
||||
"extract": {"exclude": ["META-INF/"]},
|
||||
"downloads": {"classifiers": {
|
||||
"natives-linux": {"path": "lw/natives-linux.jar", "url": "https://l/nl.jar", "sha1": "1", "size": 1},
|
||||
"natives-windows-64": {"path": "lw/natives-windows-64.jar", "url": "https://l/nw.jar", "sha1": "2", "size": 2},
|
||||
"natives-windows-32": {"path": "lw/natives-windows-32.jar", "url": "https://l/nw32.jar", "sha1": "3", "size": 3},
|
||||
"natives-osx": {"path": "lw/natives-osx.jar", "url": "https://l/no.jar", "sha1": "4", "size": 4}}}},
|
||||
{"name": "net.minecraft:launchwrapper:1.12", "url": "https://libraries.example/maven"}
|
||||
]
|
||||
}
|
||||
+42
@@ -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%"}}]}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,298 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kAuth = QStringLiteral("https://authserver.ely.by/auth/authenticate");
|
||||
const QString kValidate = QStringLiteral("https://authserver.ely.by/auth/validate");
|
||||
const QString kRefresh = QStringLiteral("https://authserver.ely.by/auth/refresh");
|
||||
const QString kInjectorManifest = QStringLiteral("https://authlib-injector.yushi.moe/artifact/latest.json");
|
||||
const QString kInjectorJar = QStringLiteral("https://authlib-injector.yushi.moe/artifact/53/authlib-injector-1.2.5.jar");
|
||||
|
||||
QJsonObject body(const FakeRequest &request)
|
||||
{
|
||||
return QJsonDocument::fromJson(request.body).object();
|
||||
}
|
||||
|
||||
QJsonObject profileResponse(const QString &token, const QString &clientToken)
|
||||
{
|
||||
return QJsonObject{{"accessToken", token},
|
||||
{"clientToken", clientToken},
|
||||
{"selectedProfile", QJsonObject{{"id", "ffffffff-ffff-ffff-ffff-ffffffffffff"},
|
||||
{"name", "ElyPlayer"}}}};
|
||||
}
|
||||
|
||||
// Сервер Ely.by в миниатюре: пароль «secret», у пользователя «2fa» — ещё код 123456.
|
||||
FakeResponse elyAuthenticate(const FakeRequest &request)
|
||||
{
|
||||
const QJsonObject in = body(request);
|
||||
const QString user = in.value("username").toString();
|
||||
const QString password = in.value("password").toString();
|
||||
if (user == QLatin1String("2fa")) {
|
||||
if (password == QLatin1String("secret:123456"))
|
||||
return FakeResponse::json(profileResponse("token-2fa", in.value("clientToken").toString()));
|
||||
if (password == QLatin1String("secret"))
|
||||
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
|
||||
{"errorMessage", "Account protected with two factor auth."}}, 401);
|
||||
}
|
||||
if (password == QLatin1String("secret"))
|
||||
return FakeResponse::json(profileResponse("token-" + user, in.value("clientToken").toString()));
|
||||
return FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"},
|
||||
{"errorMessage", "Invalid credentials. Invalid username or password."}}, 401);
|
||||
}
|
||||
|
||||
struct Captured
|
||||
{
|
||||
int calls = 0;
|
||||
AuthResult result;
|
||||
auto callback()
|
||||
{
|
||||
return [this](const AuthResult &r) { ++calls; result = r; };
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestAuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_dir;
|
||||
|
||||
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
m_net.route(kAuth, elyAuthenticate);
|
||||
m_dir = TestEnv::freshDir(QStringLiteral("injector"));
|
||||
}
|
||||
|
||||
void passwordLogin()
|
||||
{
|
||||
AuthService service;
|
||||
QSignalSpy progress(&service, &AuthService::progress);
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "client-1", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
|
||||
QCOMPARE(c.result.uuid, QStringLiteral("ffffffffffffffffffffffffffffffff")); // без дефисов
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
|
||||
QCOMPARE(c.result.clientToken, QStringLiteral("client-1"));
|
||||
QCOMPARE(c.result.userType, QStringLiteral("ELYBY"));
|
||||
QCOMPARE(progress.count(), 1);
|
||||
|
||||
const QJsonObject sent = body(m_net.requests().constFirst());
|
||||
QCOMPARE(sent.value("username").toString(), QStringLiteral("steve"));
|
||||
QCOMPARE(sent.value("clientToken").toString(), QStringLiteral("client-1"));
|
||||
QCOMPARE(sent.value("requestUser").toBool(), true);
|
||||
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArray("application/json"));
|
||||
}
|
||||
|
||||
void emptyClientTokenIsGenerated()
|
||||
{
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", QString(), QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QVERIFY(!c.result.clientToken.isEmpty());
|
||||
QCOMPARE(body(m_net.requests().constFirst()).value("clientToken").toString(), c.result.clientToken);
|
||||
}
|
||||
|
||||
void wrongPasswordAndEmptyPassword()
|
||||
{
|
||||
AuthService service;
|
||||
Captured wrong;
|
||||
service.loginElyBy("steve", "nope", "c", QString(), wrong.callback());
|
||||
QVERIFY(wait(wrong));
|
||||
QVERIFY(!wrong.result.ok);
|
||||
QVERIFY(!wrong.result.twoFactorRequired);
|
||||
QCOMPARE(wrong.result.error, QStringLiteral("Invalid credentials. Invalid username or password."));
|
||||
|
||||
Captured empty;
|
||||
service.loginElyBy("steve", QString(), "c", QString(), empty.callback());
|
||||
QCOMPARE(empty.calls, 1); // сразу, без сети
|
||||
QCOMPARE(empty.result.error, Loc::text("auth.ely.error.noPassword"));
|
||||
QCOMPARE(m_net.requests().size(), 1);
|
||||
}
|
||||
|
||||
void twoFactorFlow()
|
||||
{
|
||||
AuthService service;
|
||||
Captured first;
|
||||
service.loginElyBy("2fa", "secret", "c", QString(), first.callback());
|
||||
QVERIFY(wait(first));
|
||||
QVERIFY(!first.result.ok);
|
||||
QVERIFY(first.result.twoFactorRequired);
|
||||
QCOMPARE(first.result.error, Loc::text("auth.ely.error.twoFactor"));
|
||||
|
||||
Captured second;
|
||||
service.loginElyByWithTotp("2fa", "secret", "123456", "c", second.callback());
|
||||
QVERIFY(wait(second));
|
||||
QVERIFY2(second.result.ok, qPrintable(second.result.error));
|
||||
QCOMPARE(second.result.accessToken, QStringLiteral("token-2fa"));
|
||||
// Код уходит через двоеточие после пароля — так требует Ely.by.
|
||||
QCOMPARE(body(m_net.requests().constLast()).value("password").toString(), QStringLiteral("secret:123456"));
|
||||
}
|
||||
|
||||
void validTokenSkipsPassword()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json("{}"));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "stored-token", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("stored-token"));
|
||||
QCOMPARE(m_net.hits(kAuth), 0);
|
||||
QCOMPARE(body(m_net.requests().constFirst()).value("accessToken").toString(), QStringLiteral("stored-token"));
|
||||
}
|
||||
|
||||
void expiredTokenIsRefreshed()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json(QJsonObject{{"error", "ForbiddenOperationException"}}, 401));
|
||||
m_net.route(kRefresh, [](const FakeRequest &request) {
|
||||
const QJsonObject in = body(request);
|
||||
return FakeResponse::json(profileResponse("refreshed-" + in.value("accessToken").toString(),
|
||||
in.value("clientToken").toString()));
|
||||
});
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "old", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("refreshed-old"));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("ElyPlayer"));
|
||||
QCOMPARE(m_net.hits(kAuth), 0);
|
||||
}
|
||||
|
||||
void failedRefreshFallsBackToPassword()
|
||||
{
|
||||
m_net.route(kValidate, FakeResponse::json("{}", 401));
|
||||
m_net.route(kRefresh, FakeResponse::json("{}", 401));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", "old", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.ok);
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("token-steve"));
|
||||
QCOMPARE(m_net.hits(kAuth), 1);
|
||||
}
|
||||
|
||||
void transportErrorIsReported()
|
||||
{
|
||||
FakeResponse drop;
|
||||
drop.drop = true;
|
||||
m_net.route(kAuth, drop);
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QVERIFY(c.result.error.startsWith(Loc::text("auth.ely.error.unreachable").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void responseWithoutProfileIsError()
|
||||
{
|
||||
m_net.route(kAuth, FakeResponse::json(QJsonObject{{"accessToken", "t"}}));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, Loc::text("auth.ely.error.noGameProfile"));
|
||||
}
|
||||
|
||||
void serverErrorWithoutMessageShowsCode()
|
||||
{
|
||||
m_net.route(kAuth, FakeResponse::json("{}", 500));
|
||||
AuthService service;
|
||||
Captured c;
|
||||
service.loginElyBy("steve", "secret", "c", QString(), c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QCOMPARE(c.result.error, Loc::text("auth.error.serverCode").arg(500));
|
||||
}
|
||||
|
||||
// --- authlib-injector -------------------------------------------------------
|
||||
|
||||
void injectorDownloadVerifyAndCache()
|
||||
{
|
||||
const QByteArray jar = "PK\x03\x04 fake authlib-injector";
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
|
||||
{"build_number", 53}, {"version", "1.2.5"},
|
||||
{"download_url", kInjectorJar},
|
||||
{"checksums", QJsonObject{{"sha256", Fixtures::sha256(jar)}}}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::bytes(jar));
|
||||
|
||||
AuthService service;
|
||||
QString path, error;
|
||||
int calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QVERIFY2(error.isEmpty(), qPrintable(error));
|
||||
QCOMPARE(path, m_dir + "/authlib-injector.jar");
|
||||
QCOMPARE(Fixtures::readFile(path), jar);
|
||||
|
||||
// Второй раз — сразу из файла.
|
||||
calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QCOMPARE(calls, 1);
|
||||
QCOMPARE(m_net.hits(kInjectorJar), 1);
|
||||
}
|
||||
|
||||
void injectorChecksumMismatch()
|
||||
{
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{
|
||||
{"download_url", kInjectorJar},
|
||||
{"checksums", QJsonObject{{"sha256", QString(64, u'0')}}}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::bytes("tampered"));
|
||||
AuthService service;
|
||||
QString path, error;
|
||||
int calls = 0;
|
||||
service.ensureAuthlibInjector(m_dir, [&](const QString &p, const QString &e) { ++calls; path = p; error = e; });
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QCOMPARE(error, Loc::text("auth.ely.error.injectorChecksum"));
|
||||
QVERIFY(path.isEmpty());
|
||||
QVERIFY(!QFile::exists(m_dir + "/authlib-injector.jar"));
|
||||
}
|
||||
|
||||
void injectorManifestProblems()
|
||||
{
|
||||
AuthService service;
|
||||
QString error;
|
||||
int calls = 0;
|
||||
const auto cb = [&](const QString &, const QString &e) { ++calls; error = e; };
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"version", "1.2.5"}}));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 1, 15000);
|
||||
QCOMPARE(error, Loc::text("auth.ely.error.injectorNoUrl"));
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::error(503));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 2, 15000);
|
||||
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorInfoFailed").section(u'%', 0, 0)));
|
||||
|
||||
m_net.route(kInjectorManifest, FakeResponse::json(QJsonObject{{"download_url", kInjectorJar}}));
|
||||
m_net.route(kInjectorJar, FakeResponse::error(404));
|
||||
service.ensureAuthlibInjector(m_dir, cb);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(calls, 3, 15000);
|
||||
QVERIFY(error.startsWith(Loc::text("auth.ely.error.injectorDownloadFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestAuthService)
|
||||
#include "tst_authservice.moc"
|
||||
@@ -0,0 +1,149 @@
|
||||
// Самопроверка FakeInternet: если она красная, остальные сетевые тесты
|
||||
// бессмысленны — значит, перехват трафика на этой платформе не работает.
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakeinternet.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestFakeInternet : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
QNetworkReply *get(QNetworkAccessManager &manager, const QString &url)
|
||||
{
|
||||
return manager.get(QNetworkRequest(QUrl(url)));
|
||||
}
|
||||
|
||||
static bool waitFinished(QNetworkReply *reply)
|
||||
{
|
||||
if (reply->isFinished())
|
||||
return true;
|
||||
QSignalSpy spy(reply, &QNetworkReply::finished);
|
||||
return spy.wait(15000);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
BILAUNCHER_START_FAKE_INTERNET(m_net);
|
||||
}
|
||||
|
||||
void init() { m_net.reset(); }
|
||||
|
||||
void httpsGetIsIntercepted()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"),
|
||||
FakeResponse::json(QByteArrayLiteral("{\"ok\":true}")));
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> reply(
|
||||
get(manager, QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json?x=1")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->error(), QNetworkReply::NoError);
|
||||
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"ok\":true}"));
|
||||
QCOMPARE(m_net.hits(QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json")), 1);
|
||||
QCOMPARE(m_net.requests().constFirst().query, QStringLiteral("x=1"));
|
||||
}
|
||||
|
||||
void postBodyAndHeaders()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://authserver.ely.by/auth/authenticate"),
|
||||
[](const FakeRequest &request) {
|
||||
return FakeResponse::json(request.body, request.method == "POST" ? 200 : 405);
|
||||
});
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request(QUrl(QStringLiteral("https://authserver.ely.by/auth/authenticate")));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
QScopedPointer<QNetworkReply> reply(manager.post(request, QByteArrayLiteral("{\"a\":1}")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("{\"a\":1}"));
|
||||
QCOMPARE(m_net.requests().constFirst().header("content-type"), QByteArrayLiteral("application/json"));
|
||||
}
|
||||
|
||||
void prefixRoutesAndKeepAlive()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://resources.download.minecraft.net/*"),
|
||||
[](const FakeRequest &request) { return FakeResponse::text(request.path.toUtf8()); });
|
||||
QNetworkAccessManager manager;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
const QString path = QStringLiteral("/ab/ab%1").arg(i);
|
||||
QScopedPointer<QNetworkReply> reply(
|
||||
get(manager, QStringLiteral("https://resources.download.minecraft.net") + path));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), path.toUtf8());
|
||||
}
|
||||
QCOMPARE(m_net.requests().size(), 5);
|
||||
}
|
||||
|
||||
void unknownRouteIs404AndRecorded()
|
||||
{
|
||||
QNetworkAccessManager manager;
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("FakeInternet: нет маршрута")));
|
||||
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("https://example.invalid/x")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 404);
|
||||
QCOMPARE(m_net.unmatched(), QStringList{QStringLiteral("https://example.invalid/x")});
|
||||
}
|
||||
|
||||
void errorStatusesAndDrop()
|
||||
{
|
||||
m_net.route(QStringLiteral("https://a.test/500"), FakeResponse::error(500));
|
||||
m_net.route(QStringLiteral("https://a.test/drop"), [] {
|
||||
FakeResponse response;
|
||||
response.drop = true;
|
||||
return response;
|
||||
}());
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> r500(get(manager, QStringLiteral("https://a.test/500")));
|
||||
QVERIFY(waitFinished(r500.data()));
|
||||
QCOMPARE(r500->error(), QNetworkReply::InternalServerError);
|
||||
|
||||
QScopedPointer<QNetworkReply> drop(get(manager, QStringLiteral("https://a.test/drop")));
|
||||
QVERIFY(waitFinished(drop.data()));
|
||||
QVERIFY(drop->error() != QNetworkReply::NoError);
|
||||
}
|
||||
|
||||
void truncatedResponse_data()
|
||||
{
|
||||
QTest::addColumn<int>("bytes");
|
||||
QTest::newRow("empty") << 0;
|
||||
QTest::newRow("short") << 10;
|
||||
QTest::newRow("multiple-tls-records") << 65536;
|
||||
QTest::newRow("last-byte-missing") << 256 * 1024 - 1;
|
||||
}
|
||||
|
||||
void truncatedResponse()
|
||||
{
|
||||
QFETCH(int, bytes);
|
||||
const QByteArray body(256 * 1024, 'x');
|
||||
FakeResponse cut = FakeResponse::bytes(body);
|
||||
cut.truncateTo = bytes;
|
||||
m_net.route(QStringLiteral("https://a.test/short"), cut);
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> shortReply(get(manager, QStringLiteral("https://a.test/short")));
|
||||
QVERIFY2(waitFinished(shortReply.data()),
|
||||
qPrintable(m_net.diagnostics() + "\nClient: " + shortReply->errorString()));
|
||||
QVERIFY(shortReply->error() != QNetworkReply::NoError);
|
||||
QCOMPARE(shortReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(), 200);
|
||||
QCOMPARE(shortReply->readAll(), body.left(bytes));
|
||||
}
|
||||
|
||||
void plainHttpThroughProxy()
|
||||
{
|
||||
m_net.route(QStringLiteral("http://plain.test/hello"), FakeResponse::text("hi"));
|
||||
QNetworkAccessManager manager;
|
||||
QScopedPointer<QNetworkReply> reply(get(manager, QStringLiteral("http://plain.test/hello")));
|
||||
QVERIFY(waitFinished(reply.data()));
|
||||
QCOMPARE(reply->readAll(), QByteArrayLiteral("hi"));
|
||||
QCOMPARE(m_net.requests().constFirst().scheme, QStringLiteral("http"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestFakeInternet)
|
||||
#include "tst_fakeinternet.moc"
|
||||
@@ -0,0 +1,292 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "javainstaller.h"
|
||||
#include "javalocator.h"
|
||||
#include "javaruntime.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kTemurin = QStringLiteral("https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21/");
|
||||
const QString kMojangManifest = QStringLiteral("https://piston-meta.mojang.com/v1/packages/x/manifest.json");
|
||||
const QString kMojangFiles = QStringLiteral("https://piston-data.mojang.com/v1/objects/");
|
||||
|
||||
JavaRuntimeEntry temurinEntry(const QString &archive, const QByteArray &data)
|
||||
{
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("temurin-jdk-21.0.2_13-test-") + QString(archive).replace(u'.', u'-');
|
||||
entry.kind = JavaRuntimeKind::Jdk;
|
||||
entry.major = 21;
|
||||
entry.version = QStringLiteral("21.0.2+13");
|
||||
entry.archive = archive;
|
||||
entry.url = QUrl(kTemurin + "OpenJDK21U-jdk." + archive);
|
||||
entry.checksum = Fixtures::sha256(data);
|
||||
entry.size = data.size();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Раскладка архива Temurin: один корневой каталог с bin/java внутри.
|
||||
QMap<QString, QByteArray> jdkTree()
|
||||
{
|
||||
return {{QStringLiteral("jdk-21.0.2+13/bin/") + Fixtures::javaExecutableName(), "#!/bin/sh\necho java\n"},
|
||||
{QStringLiteral("jdk-21.0.2+13/lib/modules"), QByteArray(2048, 'm')},
|
||||
{QStringLiteral("jdk-21.0.2+13/release"), "JAVA_VERSION=\"21.0.2\""}};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestJavaInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_work;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(JavaInstaller &installer)
|
||||
: started(&installer, &JavaInstaller::started)
|
||||
, finished(&installer, &JavaInstaller::finished)
|
||||
, failed(&installer, &JavaInstaller::failed)
|
||||
, canceled(&installer, &JavaInstaller::canceled)
|
||||
{}
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
|
||||
QSignalSpy started, finished, failed, canceled;
|
||||
};
|
||||
|
||||
void verifyRegistered(const JavaRuntimeEntry &entry, const Run &run)
|
||||
{
|
||||
QCOMPARE(run.finished.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().at(0).toString(), entry.id);
|
||||
const QString javaPath = run.finished.constFirst().at(1).toString();
|
||||
QVERIFY(QFileInfo(javaPath).isFile());
|
||||
QVERIFY(javaPath.startsWith(JavaRuntimeStore::dirFor(entry.id) + u'/'));
|
||||
#if !defined(Q_OS_WIN)
|
||||
QVERIFY(QFileInfo(javaPath).isExecutable());
|
||||
#endif
|
||||
const auto installed = JavaRuntimeStore::find(entry.id);
|
||||
QVERIFY(installed.has_value());
|
||||
QCOMPARE(installed->major, entry.major);
|
||||
QCOMPARE(installed->version, entry.version);
|
||||
QCOMPARE(installed->kind, javaKindKey(entry.kind));
|
||||
// Лаунчер видит её при поиске Java.
|
||||
QStringList canonical;
|
||||
for (const QString &path : JavaLocator::findAll(m_work))
|
||||
canonical << QFileInfo(path).canonicalFilePath();
|
||||
QVERIFY(canonical.contains(QFileInfo(javaPath).canonicalFilePath()));
|
||||
// Скачанный архив за собой не оставляем.
|
||||
QVERIFY(QDir(LauncherPaths::javaDownloadDir()).entryList(QDir::Files).isEmpty());
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_work = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void installsZipArchive()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(installer.isRunning());
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
QVERIFY(!installer.isRunning());
|
||||
}
|
||||
|
||||
void installsTarGzArchive()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
QSKIP("Temurin под Windows — только zip; tar.gz распаковывается системным tar из PATH");
|
||||
#endif
|
||||
const QString archive = m_work + "/jdk.tar.gz";
|
||||
if (!Fixtures::makeTarGz(archive, jdkTree()))
|
||||
QSKIP("системный tar недоступен");
|
||||
const QByteArray data = Fixtures::readFile(archive);
|
||||
const JavaRuntimeEntry entry = temurinEntry("tar.gz", data);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(data));
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
}
|
||||
|
||||
void archiveChecksumMismatch()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
entry.checksum = QString(64, u'0');
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.archiveChecksum"));
|
||||
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
|
||||
}
|
||||
|
||||
void archiveWithoutJavaFails()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes({{"jdk/README", "no binaries here"}});
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.noExecutable"));
|
||||
QVERIFY(!JavaRuntimeStore::find(entry.id).has_value());
|
||||
}
|
||||
|
||||
void zipSlipIsRejected()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes({{"jdk/../../../escaped", "x"}});
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.archiveBadPath").arg("jdk/../../../escaped"));
|
||||
QVERIFY(!QFile::exists(LauncherPaths::rootDir() + "/escaped"));
|
||||
}
|
||||
|
||||
void unknownFormatAndInvalidEntry()
|
||||
{
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(JavaRuntimeEntry{});
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.buildUnavailable"));
|
||||
|
||||
JavaRuntimeEntry entry = temurinEntry("7z", "x");
|
||||
Run run2(installer);
|
||||
installer.install(entry);
|
||||
QCOMPARE(run2.failure(), Loc::text("java.error.unknownArchiveFormat").arg("7z"));
|
||||
}
|
||||
|
||||
void mojangComponent()
|
||||
{
|
||||
const QByteArray java = "#!/bin/sh\necho mojang-java\n";
|
||||
const QByteArray lib = QByteArray(1024, 'l');
|
||||
const QString binary = QStringLiteral("bin/") + Fixtures::javaExecutableName();
|
||||
QJsonObject files{
|
||||
{"bin", QJsonObject{{"type", "directory"}}},
|
||||
{binary, QJsonObject{{"type", "file"}, {"executable", true},
|
||||
{"downloads", QJsonObject{{"raw", QJsonObject{
|
||||
{"url", kMojangFiles + Fixtures::sha1(java) + "/java"},
|
||||
{"sha1", Fixtures::sha1(java)}, {"size", java.size()}}}}}}},
|
||||
{"lib/modules", QJsonObject{{"type", "file"},
|
||||
{"downloads", QJsonObject{{"raw", QJsonObject{
|
||||
{"url", kMojangFiles + Fixtures::sha1(lib) + "/modules"},
|
||||
{"sha1", Fixtures::sha1(lib)}, {"size", lib.size()}}}}}}},
|
||||
{"lib/modules-link", QJsonObject{{"type", "link"}, {"target", "modules"}}}};
|
||||
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{{"files", files}}));
|
||||
m_net.route(kMojangFiles + Fixtures::sha1(java) + "/java", FakeResponse::bytes(java));
|
||||
int libAttempts = 0;
|
||||
m_net.route(kMojangFiles + Fixtures::sha1(lib) + "/modules", [&](const FakeRequest &) {
|
||||
return ++libAttempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes(lib); // временный сбой
|
||||
});
|
||||
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("mojang-java-runtime-delta");
|
||||
entry.kind = JavaRuntimeKind::Mojang;
|
||||
entry.major = 21;
|
||||
entry.version = QStringLiteral("21.0.7");
|
||||
entry.component = QStringLiteral("java-runtime-delta");
|
||||
entry.archive = QStringLiteral("mojang");
|
||||
entry.url = QUrl(kMojangManifest);
|
||||
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyRegistered(entry, run);
|
||||
QCOMPARE(libAttempts, 2);
|
||||
const QString dir = JavaRuntimeStore::dirFor(entry.id);
|
||||
QCOMPARE(Fixtures::readFile(dir + "/lib/modules"), lib);
|
||||
QVERIFY(QFileInfo::exists(dir + "/lib/modules-link"));
|
||||
}
|
||||
|
||||
void mojangManifestEscapingIsRejected()
|
||||
{
|
||||
m_net.route(kMojangManifest, FakeResponse::json(QJsonObject{
|
||||
{"files", QJsonObject{{"../../evil", QJsonObject{{"type", "directory"}}}}}}));
|
||||
JavaRuntimeEntry entry;
|
||||
entry.id = QStringLiteral("mojang-evil");
|
||||
entry.kind = JavaRuntimeKind::Mojang;
|
||||
entry.major = 17;
|
||||
entry.version = QStringLiteral("17");
|
||||
entry.archive = QStringLiteral("mojang");
|
||||
entry.url = QUrl(kMojangManifest);
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("java.error.manifestBadPath").arg("../../evil"));
|
||||
}
|
||||
|
||||
void cancelDuringDownload()
|
||||
{
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", "whatever");
|
||||
m_net.route(entry.url.toString(), hang);
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(entry.url.toString()), 1, 15000);
|
||||
installer.cancel();
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(!QDir(JavaRuntimeStore::dirFor(entry.id)).exists());
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
// abort() синхронно доводит ответ до handleArchiveFinished(), который уже
|
||||
// шлёт canceled; затем cancel() видит m_reply == nullptr и шлёт его ещё раз.
|
||||
QEXPECT_FAIL("", "canceled приходит дважды: из handleArchiveFinished() и из cancel()", Continue);
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
}
|
||||
|
||||
void reinstallReplacesOld()
|
||||
{
|
||||
const QByteArray zip = Fixtures::zipBytes(jdkTree());
|
||||
const JavaRuntimeEntry entry = temurinEntry("zip", zip);
|
||||
Fixtures::write(JavaRuntimeStore::dirFor(entry.id) + "/stale/file", "old");
|
||||
m_net.route(entry.url.toString(), FakeResponse::bytes(zip));
|
||||
JavaInstaller installer;
|
||||
Run run(installer);
|
||||
installer.install(entry);
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QVERIFY(!QFile::exists(JavaRuntimeStore::dirFor(entry.id) + "/stale/file"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaInstaller)
|
||||
#include "tst_javainstaller.moc"
|
||||
@@ -0,0 +1,319 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
#include "javaruntimeservice.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
#include <QSysInfo>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kReleases = QStringLiteral("https://api.adoptium.net/v3/info/available_releases");
|
||||
const QString kFeature = QStringLiteral("https://api.adoptium.net/v3/assets/feature_releases/*");
|
||||
const QString kMojang = QStringLiteral(
|
||||
"https://piston-meta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json");
|
||||
|
||||
bool VersionLoaderOsIsWindows()
|
||||
{
|
||||
#if defined(Q_OS_WIN)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
QString hostAdoptiumArch()
|
||||
{
|
||||
const QString arch = QSysInfo::currentCpuArchitecture();
|
||||
if (arch == QLatin1String("arm64"))
|
||||
return QStringLiteral("aarch64");
|
||||
if (arch == QLatin1String("x86_64"))
|
||||
return QStringLiteral("x64");
|
||||
return arch;
|
||||
}
|
||||
|
||||
// Один релиз Temurin в формате Adoptium API.
|
||||
QJsonObject release(int major, const QString &arch, const QString &openjdk, int security,
|
||||
const QString ×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<FakeRequest> requests = m_net.requests();
|
||||
int featureRequests = 0;
|
||||
for (const FakeRequest &request : requests) {
|
||||
if (!request.path.startsWith("/v3/assets/feature_releases/"))
|
||||
continue;
|
||||
++featureRequests;
|
||||
QCOMPARE(request.queryItems().queryItemValue("architecture"), hostAdoptiumArch());
|
||||
QCOMPARE(request.queryItems().queryItemValue("image_type"), QString());
|
||||
QCOMPARE(request.queryItems().queryItemValue("vendor"), QStringLiteral("eclipse"));
|
||||
}
|
||||
QCOMPARE(featureRequests, 4);
|
||||
|
||||
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
|
||||
const QList<JavaRuntimeEntry> jres = service.entries(JavaRuntimeKind::Jre);
|
||||
QCOMPARE(jdks.size(), 7); // 8 — один релиз, 17/21/24 — по два
|
||||
QCOMPARE(jres.size(), 7);
|
||||
// Свежие major первыми, внутри — по дате.
|
||||
QCOMPARE(jdks.first().major, 24);
|
||||
QCOMPARE(jdks.first().version, QStringLiteral("24.0.2+13"));
|
||||
QCOMPARE(jdks.last().major, 8);
|
||||
QCOMPARE(jdks.last().version, QStringLiteral("8u452"));
|
||||
|
||||
const JavaRuntimeEntry jdk21 = jdks.at(2);
|
||||
QCOMPARE(jdk21.major, 21);
|
||||
QCOMPARE(jdk21.version, QStringLiteral("21.0.2+13")); // «-LTS» отрезан
|
||||
QVERIFY(jdk21.lts);
|
||||
QVERIFY(!jdks.first().lts); // 24 — не LTS
|
||||
QCOMPARE(jdk21.architecture, hostAdoptiumArch());
|
||||
QCOMPARE(jdk21.size, qint64(190000000));
|
||||
QCOMPARE(jdk21.archive, VersionLoaderOsIsWindows() ? QStringLiteral("zip") : QStringLiteral("tar.gz"));
|
||||
QCOMPARE(jdk21.id, JavaRuntimeStore::sanitizeId("temurin-jdk-21.0.2+13-" + hostAdoptiumArch()));
|
||||
QVERIFY(service.find(jdk21.id).has_value());
|
||||
|
||||
// Mojang: java-runtime-delta (21) и jre-legacy (8); пустые, exe и http — выброшены.
|
||||
const QList<JavaRuntimeEntry> mojang = service.entries(JavaRuntimeKind::Mojang);
|
||||
QCOMPARE(mojang.size(), 2);
|
||||
const auto delta = service.find("mojang-java-runtime-delta");
|
||||
QVERIFY(delta.has_value());
|
||||
QCOMPARE(delta->major, 21);
|
||||
QCOMPARE(delta->component, QStringLiteral("java-runtime-delta"));
|
||||
QCOMPARE(delta->archive, QStringLiteral("mojang"));
|
||||
QCOMPARE(delta->checksum, QStringLiteral("dddd"));
|
||||
QCOMPARE(service.find("mojang-jre-legacy")->major, 8);
|
||||
|
||||
QVERIFY(QFile::exists(LauncherPaths::javaCatalogFile()));
|
||||
}
|
||||
|
||||
void bestForPrefersSmallestSuitableMajorThenJdk()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
|
||||
QVERIFY(!service.bestFor(0).has_value());
|
||||
QVERIFY(!service.bestFor(99).has_value());
|
||||
const auto for17 = service.bestFor(17);
|
||||
QVERIFY(for17.has_value());
|
||||
QCOMPARE(for17->major, 17);
|
||||
QCOMPARE(for17->kind, JavaRuntimeKind::Jdk);
|
||||
QCOMPARE(for17->version, QStringLiteral("17.0.2+13")); // самый свежий релиз
|
||||
QCOMPARE(service.bestFor(18)->major, 21);
|
||||
QCOMPARE(service.bestFor(8)->major, 8);
|
||||
}
|
||||
|
||||
void partialFailureStillProducesCatalog()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::error(500));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("java.error.mojangRuntimesFailed").section(u'%', 0, 0)));
|
||||
QVERIFY(service.entries(JavaRuntimeKind::Mojang).isEmpty());
|
||||
QVERIFY(!service.entries(JavaRuntimeKind::Jdk).isEmpty());
|
||||
}
|
||||
|
||||
void adoptiumDownMojangUp()
|
||||
{
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Mojang).size(), 2);
|
||||
QVERIFY(service.entries(JavaRuntimeKind::Jdk).isEmpty());
|
||||
}
|
||||
|
||||
void everythingDownWithoutCacheFails()
|
||||
{
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.contains("; ")); // оба предупреждения склеены
|
||||
QVERIFY(!service.hasData());
|
||||
}
|
||||
|
||||
void everythingDownWithCacheUsesCache()
|
||||
{
|
||||
routeAdoptium();
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
{
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
}
|
||||
m_net.reset();
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
|
||||
JavaRuntimeService service;
|
||||
CallResult cached;
|
||||
QVERIFY(load(service, cached));
|
||||
QVERIFY(cached.ok);
|
||||
QVERIFY(m_net.requests().isEmpty()); // кеш свежий — сети нет
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
|
||||
|
||||
CallResult forced;
|
||||
QVERIFY(load(service, forced, true));
|
||||
QVERIFY(forced.ok);
|
||||
QVERIFY(!forced.warning.isEmpty());
|
||||
QCOMPARE(service.entries(JavaRuntimeKind::Jdk).size(), 7);
|
||||
}
|
||||
|
||||
void cacheForOtherPlatformIsIgnored()
|
||||
{
|
||||
QJsonObject entry;
|
||||
entry["id"] = "x";
|
||||
entry["kind"] = "jdk";
|
||||
entry["major"] = 17;
|
||||
entry["url"] = "https://x/y.zip";
|
||||
QJsonObject cache;
|
||||
cache["platform"] = "amiga-os";
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
cache["entries"] = QJsonArray{entry};
|
||||
Fixtures::writeJson(LauncherPaths::javaCatalogFile(), cache);
|
||||
m_net.route(kReleases, FakeResponse::error(503));
|
||||
m_net.route(kMojang, FakeResponse::error(503));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(!service.hasData());
|
||||
QVERIFY(!r.ok);
|
||||
}
|
||||
|
||||
void aarch64FallsBackToX64()
|
||||
{
|
||||
if (hostAdoptiumArch() != QLatin1String("aarch64"))
|
||||
QSKIP("запасной x64 запрашивается только на ARM-машинах");
|
||||
routeAdoptium(QStringLiteral("aarch64"));
|
||||
m_net.route(kMojang, FakeResponse::json(mojangAll()));
|
||||
JavaRuntimeService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, r));
|
||||
QVERIFY(r.ok);
|
||||
const QList<JavaRuntimeEntry> jdks = service.entries(JavaRuntimeKind::Jdk);
|
||||
QVERIFY(!jdks.isEmpty());
|
||||
for (const JavaRuntimeEntry &entry : jdks)
|
||||
QCOMPARE(entry.architecture, QStringLiteral("x64"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaRuntimeService)
|
||||
#include "tst_javaruntimeservice.moc"
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "testenv.h"
|
||||
#include "fakemojang.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
#include "modloaderinstaller.h"
|
||||
#include "modloaderversionservice.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
#include "zlibreference.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kFabricList = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||
const QString kFabricProfile = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1/0.16.10/profile/json");
|
||||
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
|
||||
const QString kForgeInstaller = QStringLiteral(
|
||||
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar");
|
||||
|
||||
QByteArray fabricProfile(const QString &inherits = QStringLiteral("1.20.1"))
|
||||
{
|
||||
// Профиль Fabric: дочерняя версия с собственной библиотекой из Maven Fabric.
|
||||
const QJsonObject json{
|
||||
{"id", "fabric-loader-0.16.10-1.20.1"},
|
||||
{"inheritsFrom", inherits},
|
||||
{"type", "release"},
|
||||
{"mainClass", "net.fabricmc.loader.impl.launch.knot.KnotClient"},
|
||||
{"arguments", QJsonObject{{"game", QJsonArray{}}, {"jvm", QJsonArray{"-DFabricMcEmu= net.minecraft.client.main.Main "}}}},
|
||||
{"libraries", QJsonArray{QJsonObject{{"name", "net.fabricmc:fabric-loader:0.16.10"},
|
||||
{"url", "https://maven.fabricmc.net/"}}}}};
|
||||
return QJsonDocument(json).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestModLoaderInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_game;
|
||||
|
||||
struct Rig
|
||||
{
|
||||
VersionManifestService manifest;
|
||||
ModLoaderVersionService meta;
|
||||
VersionInstaller versions{&manifest};
|
||||
ModLoaderInstaller installer{&meta, &versions};
|
||||
QSignalSpy finished{&installer, &ModLoaderInstaller::finished};
|
||||
QSignalSpy failed{&installer, &ModLoaderInstaller::failed};
|
||||
QSignalSpy canceled{&installer, &ModLoaderInstaller::canceled};
|
||||
QSignalSpy log{&installer, &ModLoaderInstaller::log};
|
||||
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constFirst().at(1).toString(); }
|
||||
};
|
||||
|
||||
void routeForge()
|
||||
{
|
||||
m_net.route(kForgeMeta, FakeResponse::bytes(Fixtures::read("loaders/forge-maven-metadata.xml")));
|
||||
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
|
||||
m_net.route(kForgeInstaller, FakeResponse::bytes("PK fake forge installer"));
|
||||
}
|
||||
|
||||
// Подставная java, которая ведёт себя как установщик Forge: кладёт профиль версии.
|
||||
QString forgeLikeJava(const QStringList &extra = {})
|
||||
{
|
||||
const QString id = QStringLiteral("1.20.1-forge-47.4.0");
|
||||
const QString json = QStringLiteral(
|
||||
"{\"id\":\"%1\",\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"cpw.mods.bootstraplauncher.BootstrapLauncher\"}").arg(id);
|
||||
QStringList config{"args_file=" + m_game + "/installer-args.txt",
|
||||
"dump_env=LD_PRELOAD",
|
||||
"stdout=Extracting json",
|
||||
"stdout=The client installed successfully"};
|
||||
config << "create=" + m_game + "/versions/" + id + u'/' + id + ".json\t" + json;
|
||||
config += extra;
|
||||
return Fixtures::installFakeJava(m_game + "/jdk17", "17.0.8", config);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
m_net.route(kFabricList, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
|
||||
m_net.route("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar",
|
||||
FakeResponse::bytes("fabric-loader-jar"));
|
||||
}
|
||||
|
||||
void fabricInstallsBaseThenProfile()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile()));
|
||||
Rig rig;
|
||||
QSignalSpy started(&rig.installer, &ModLoaderInstaller::started);
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.installer.isRunning());
|
||||
QCOMPARE(started.count(), 1);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
|
||||
const QList<QVariant> done = rig.finished.constFirst();
|
||||
QCOMPARE(done.at(0).toString(), QStringLiteral("fabric"));
|
||||
QCOMPARE(done.at(1).toString(), QStringLiteral("1.20.1"));
|
||||
QCOMPARE(done.at(2).toString(), QStringLiteral("0.16.10"));
|
||||
QCOMPARE(done.at(3).toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
|
||||
// Ваниль докачана, профиль сохранён, библиотека загрузчика на месте.
|
||||
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
|
||||
QVERIFY(QFile::exists(m_game + "/versions/fabric-loader-0.16.10-1.20.1/fabric-loader-0.16.10-1.20.1.json"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/libraries/net/fabricmc/fabric-loader/0.16.10/fabric-loader-0.16.10.jar"),
|
||||
QByteArray("fabric-loader-jar"));
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.16.10-1.20.1", {}, nullptr);
|
||||
QVERIFY(v.isValid());
|
||||
}
|
||||
|
||||
void fabricProfileForWrongGameIsRejected()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(fabricProfile("1.19.4")));
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionWrongMinecraft")
|
||||
.arg(loaderTitle(ModLoader::Fabric), "1.19.4", "1.20.1"));
|
||||
QVERIFY(!QDir(m_game + "/versions/fabric-loader-0.16.10-1.20.1").exists());
|
||||
}
|
||||
|
||||
void fabricProfileWithUnsafeIdIsRejected()
|
||||
{
|
||||
m_net.route(kFabricProfile, FakeResponse::json(QJsonObject{{"id", "../evil"}, {"inheritsFrom", "1.20.1"}}));
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.descriptionBadId").arg(loaderTitle(ModLoader::Fabric)));
|
||||
}
|
||||
|
||||
void validationErrors()
|
||||
{
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", " ", QString());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.noVersionSelected").arg(loaderTitle(ModLoader::Fabric)));
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
}
|
||||
|
||||
void secondInstallWhileBusy()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(fabricProfile());
|
||||
slow.delayMs = 500;
|
||||
m_net.route(kFabricProfile, slow);
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Fabric, "1.20.1", "0.16.10", QString());
|
||||
rig.installer.install(m_game, ModLoader::Quilt, "1.20.1", "0.26.3", QString());
|
||||
QCOMPARE(rig.failed.count(), 1);
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.installInProgress"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(rig.finished.count(), 1, 30000);
|
||||
}
|
||||
|
||||
void forgeRunsInstallerWithJava()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY2(rig.failed.isEmpty(), qPrintable(rig.failure()));
|
||||
QCOMPARE(rig.finished.constFirst().at(3).toString(), QStringLiteral("1.20.1-forge-47.4.0"));
|
||||
|
||||
// Установщик запущен как java -jar <installer> --installClient <папка игры>.
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
|
||||
.split(u'\n', Qt::SkipEmptyParts);
|
||||
QCOMPARE(args.value(0), QStringLiteral("-jar"));
|
||||
QCOMPARE(QFileInfo(args.value(1)).fileName(), QStringLiteral("forge-47.4.0-installer.jar"));
|
||||
QCOMPARE(args.value(2), QStringLiteral("--installClient"));
|
||||
QCOMPARE(args.value(3), QDir::toNativeSeparators(m_game));
|
||||
// Заглушка launcher_profiles.json, без которой установщик Forge отказывается работать.
|
||||
QVERIFY(Fixtures::readJsonFile(m_game + "/launcher_profiles.json").contains("profiles"));
|
||||
// Вывод установщика идёт в журнал.
|
||||
QStringList lines;
|
||||
for (const auto &call : rig.log)
|
||||
lines << call.constFirst().toString();
|
||||
QVERIFY(lines.contains("The client installed successfully"));
|
||||
QVERIFY(QFile::exists(LauncherPaths::loaderDownloadDir() + "/forge-47.4.0-install.log"));
|
||||
}
|
||||
|
||||
void forgeInstallerFailureCleansUp()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava({"exit=1", "stdout=Processor failed, invalid outputs"});
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
const QString failure = rig.failure();
|
||||
QVERIFY(failure.startsWith(Loc::text("loader.error.installerExitCode").arg(loaderTitle(ModLoader::Forge)).arg(1)));
|
||||
QVERIFY(failure.contains("Processor failed")); // хвост лога в сообщении
|
||||
// Версию, которую успел создать упавший установщик, убираем.
|
||||
QVERIFY(!QDir(m_game + "/versions/1.20.1-forge-47.4.0").exists());
|
||||
QVERIFY(QDir(m_game + "/versions/1.20.1").exists());
|
||||
}
|
||||
|
||||
void forgeInstallerProducingNothing()
|
||||
{
|
||||
routeForge();
|
||||
const QString java = Fixtures::installFakeJava(m_game + "/jdk17-empty", "17.0.8", {"stdout=did nothing"});
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
QVERIFY(rig.failure().startsWith(Loc::text("loader.error.noVersionProduced").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void forgeUnknownVersionIsIncompatible()
|
||||
{
|
||||
routeForge();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "1.0.0", QString());
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.failure(), Loc::text("loader.error.incompatible").arg(loaderTitle(ModLoader::Forge), "1.0.0", "1.20.1"));
|
||||
}
|
||||
|
||||
void zlibNgEnvironmentOnLinux()
|
||||
{
|
||||
#if !defined(Q_OS_LINUX)
|
||||
QSKIP("LD_PRELOAD с эталонным zlib — только Linux");
|
||||
#else
|
||||
routeForge();
|
||||
const QString java = forgeLikeJava();
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", java);
|
||||
QVERIFY(rig.waitEnd());
|
||||
const QStringList args = QString::fromLocal8Bit(Fixtures::readFile(m_game + "/installer-args.txt"))
|
||||
.split(u'\n', Qt::SkipEmptyParts);
|
||||
const QString preload = args.filter("env:LD_PRELOAD=").value(0).mid(int(sizeof("env:LD_PRELOAD=") - 1));
|
||||
if (ZlibReference::systemIsZlibNg() && !ZlibReference::bundledPath().isEmpty())
|
||||
QVERIFY(preload.startsWith(ZlibReference::bundledPath()));
|
||||
else
|
||||
QCOMPARE(preload, qEnvironmentVariable("LD_PRELOAD"));
|
||||
#endif
|
||||
}
|
||||
|
||||
void cancelDuringInstallerDownload()
|
||||
{
|
||||
routeForge();
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
m_net.route(kForgeInstaller, hang);
|
||||
Rig rig;
|
||||
rig.installer.install(m_game, ModLoader::Forge, "1.20.1", "47.4.0", QString());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(m_net.hits(kForgeInstaller), 1, 30000);
|
||||
rig.installer.cancel();
|
||||
QVERIFY(rig.waitEnd());
|
||||
QCOMPARE(rig.canceled.count(), 1);
|
||||
QVERIFY(rig.failed.isEmpty());
|
||||
QVERIFY(!rig.installer.isRunning());
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestModLoaderInstaller)
|
||||
#include "tst_modloaderinstaller.moc"
|
||||
@@ -0,0 +1,292 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "modloaderversionservice.h"
|
||||
|
||||
#include <QSignalSpy>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
|
||||
namespace {
|
||||
const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1");
|
||||
const QString kQuilt = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/1.20.1");
|
||||
const QString kForgeMeta = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||
const QString kForgePromos = QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");
|
||||
const QString kNeoMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml");
|
||||
const QString kNeoLegacyMeta = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml");
|
||||
|
||||
FakeResponse xml(const QByteArray &body)
|
||||
{
|
||||
FakeResponse response = FakeResponse::bytes(body);
|
||||
response.contentType = "application/xml";
|
||||
return response;
|
||||
}
|
||||
|
||||
QStringList loaderVersions(const QList<LoaderVersionEntry> &list)
|
||||
{
|
||||
QStringList out;
|
||||
for (const LoaderVersionEntry &entry : list)
|
||||
out << entry.loaderVersion;
|
||||
return out;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class TestModLoaderVersionService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
void routeAll()
|
||||
{
|
||||
m_net.route(kFabric, FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json")));
|
||||
m_net.route(kQuilt, FakeResponse::json(Fixtures::read("loaders/quilt-1.20.1.json")));
|
||||
m_net.route(kForgeMeta, xml(Fixtures::read("loaders/forge-maven-metadata.xml")));
|
||||
m_net.route(kForgePromos, FakeResponse::json(Fixtures::read("loaders/forge-promotions_slim.json")));
|
||||
m_net.route(kNeoMeta, xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")));
|
||||
m_net.route(kNeoLegacyMeta, xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")));
|
||||
}
|
||||
|
||||
static bool load(ModLoaderVersionService &service, ModLoader loader, const QString &game,
|
||||
CallResult &r, bool force = false)
|
||||
{
|
||||
service.ensureLoaded(loader, game, r.callback(), force);
|
||||
return QTest::qWaitFor([&] { return r.calls > 0; }, 15000);
|
||||
}
|
||||
|
||||
bool loadNeoForgeInOrder(ModLoaderVersionService &service, CallResult &result,
|
||||
const QString &first, const QString &second)
|
||||
{
|
||||
QStringList completed;
|
||||
QObject observer;
|
||||
auto *network = service.findChild<QNetworkAccessManager *>();
|
||||
if (!network)
|
||||
qFatal("ModLoaderVersionService has no network manager");
|
||||
connect(network, &QNetworkAccessManager::finished, &observer, [&](QNetworkReply *reply) {
|
||||
completed << reply->url().toString();
|
||||
});
|
||||
service.ensureLoaded(ModLoader::NeoForge, "1.20.1", result.callback());
|
||||
if (!QTest::qWaitFor([&] { return completed.contains(first) && m_net.hits(second) == 1; }, 15000))
|
||||
return false;
|
||||
if (!m_net.releaseResponse(second))
|
||||
return false;
|
||||
return QTest::qWaitFor([&] { return result.calls == 1; }, 15000);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
routeAll();
|
||||
}
|
||||
|
||||
void fabric()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
QSignalSpy changed(&service, &ModLoaderVersionService::versionsChanged);
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Fabric, "1.20.1");
|
||||
// Новые первыми: 0.16.10 > 0.16.9 (числовое сравнение, не строковое); пустые версии выброшены.
|
||||
QCOMPARE(loaderVersions(list), QStringList({"0.17.0-beta.1", "0.16.10", "0.16.9"}));
|
||||
QCOMPARE(list.at(1).versionId, QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QCOMPARE(list.at(1).gameVersion, QStringLiteral("1.20.1"));
|
||||
QVERIFY(list.at(1).stable);
|
||||
QVERIFY(!list.at(0).stable);
|
||||
QVERIFY(list.at(1).installerUrl.isEmpty());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QCOMPARE(changed.constFirst().at(0).toString(), QStringLiteral("fabric"));
|
||||
QCOMPARE(changed.constFirst().at(1).toString(), QStringLiteral("1.20.1"));
|
||||
|
||||
QVERIFY(service.find(ModLoader::Fabric, "1.20.1", "0.16.9").has_value());
|
||||
QVERIFY(!service.find(ModLoader::Fabric, "1.20.1", "9.9.9").has_value());
|
||||
QVERIFY(service.versions(ModLoader::Fabric, "1.19.2").isEmpty());
|
||||
QVERIFY(QFile::exists(LauncherPaths::loaderCacheFile("fabric")));
|
||||
}
|
||||
|
||||
void quiltStabilityHeuristic()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Quilt, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
const QList<LoaderVersionEntry> list = service.versions(ModLoader::Quilt, "1.20.1");
|
||||
QCOMPARE(loaderVersions(list), QStringList({"0.26.4-beta.1", "0.26.3"}));
|
||||
QCOMPARE(list.at(0).versionId, QStringLiteral("quilt-loader-0.26.4-beta.1-1.20.1"));
|
||||
QVERIFY(!list.at(0).stable); // поля stable нет — решает «beta» в номере
|
||||
QVERIFY(list.at(1).stable);
|
||||
}
|
||||
|
||||
void forgeMergesPromotions()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
QCOMPARE(m_net.hits(kForgeMeta), 1);
|
||||
QCOMPARE(m_net.hits(kForgePromos), 1);
|
||||
|
||||
const QList<LoaderVersionEntry> v1201 = service.versions(ModLoader::Forge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(v1201), QStringList({"47.4.1", "47.4.0", "47.3.0"}));
|
||||
for (const LoaderVersionEntry &entry : v1201)
|
||||
QCOMPARE(entry.recommended, entry.loaderVersion == QLatin1String("47.4.0"));
|
||||
QCOMPARE(v1201.at(1).installerUrl,
|
||||
QUrl("https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.4.0/forge-1.20.1-47.4.0-installer.jar"));
|
||||
QVERIFY(v1201.at(1).versionId.isEmpty()); // id Forge узнаём только после установки
|
||||
|
||||
// Весь каталог Forge — одним запросом, по всем версиям игры.
|
||||
const QList<LoaderVersionEntry> v1211 = service.versions(ModLoader::Forge, "1.21.1");
|
||||
QCOMPARE(loaderVersions(v1211), QStringList({"52.0.10", "52.0.2-beta"}));
|
||||
QVERIFY(!v1211.at(1).stable);
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::Forge, "1.7.10")),
|
||||
QStringList{"10.13.4.1614-1.7.10"});
|
||||
|
||||
// Другая версия игры у Forge не требует нового запроса.
|
||||
CallResult again;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.21.1", again));
|
||||
QCOMPARE(m_net.hits(kForgeMeta), 1);
|
||||
}
|
||||
|
||||
void forgeWithoutPromotionsStillWorks()
|
||||
{
|
||||
m_net.route(kForgePromos, FakeResponse::error(500));
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", r));
|
||||
QVERIFY(r.ok);
|
||||
for (const LoaderVersionEntry &entry : service.versions(ModLoader::Forge, "1.20.1"))
|
||||
QVERIFY(!entry.recommended);
|
||||
}
|
||||
|
||||
void neoForgeBothSchemes()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21.1")),
|
||||
QStringList({"21.1.248", "21.1.200"}));
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.21")), QStringList{"21.0.10"});
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "1.20.4")),
|
||||
QStringList({"20.4.237", "20.4.80-beta"}));
|
||||
// Новая нумерация (год.выпуск.патч.сборка).
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1")), QStringList{"26.1.0.5"});
|
||||
QCOMPARE(loaderVersions(service.versions(ModLoader::NeoForge, "26.1.1")), QStringList{"26.1.1.3"});
|
||||
QCOMPARE(service.find(ModLoader::NeoForge, "1.21.1", "21.1.248")->installerUrl,
|
||||
QUrl("https://maven.neoforged.net/releases/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-installer.jar"));
|
||||
}
|
||||
|
||||
void neoForgeLegacyArtifactArrivesLast()
|
||||
{
|
||||
// Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge.
|
||||
FakeResponse legacy = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml"));
|
||||
legacy.deferred = true;
|
||||
m_net.route(kNeoLegacyMeta, legacy);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(loadNeoForgeInOrder(service, r, kNeoMeta, kNeoLegacyMeta));
|
||||
QVERIFY(r.ok);
|
||||
const QList<LoaderVersionEntry> versions = service.versions(ModLoader::NeoForge, "1.20.1");
|
||||
QCOMPARE(loaderVersions(versions), QStringList({"47.1.106", "47.1.79"}));
|
||||
QCOMPARE(versions.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 primary = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml"));
|
||||
primary.deferred = true;
|
||||
m_net.route(kNeoMeta, primary);
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(loadNeoForgeInOrder(service, r, kNeoLegacyMeta, kNeoMeta));
|
||||
QVERIFY(r.ok);
|
||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2);
|
||||
QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2);
|
||||
}
|
||||
|
||||
void primaryFailureIsError()
|
||||
{
|
||||
m_net.route(kNeoMeta, FakeResponse::error(503));
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::NeoForge, "1.21.1", r));
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.contains(loaderTitle(ModLoader::NeoForge)));
|
||||
|
||||
m_net.route(kForgeMeta, xml("<metadata><versioning><versions></versions></versioning></metadata>"));
|
||||
CallResult empty;
|
||||
QVERIFY(load(service, ModLoader::Forge, "1.20.1", empty));
|
||||
QVERIFY(!empty.ok);
|
||||
QCOMPARE(empty.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Forge)));
|
||||
|
||||
m_net.route(kFabric, FakeResponse::json("{\"not\": \"an array\"}"));
|
||||
CallResult notArray;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", notArray));
|
||||
QVERIFY(!notArray.ok);
|
||||
QCOMPARE(notArray.warning, Loc::text("loader.error.versionsUnparsable").arg(loaderTitle(ModLoader::Fabric)));
|
||||
}
|
||||
|
||||
void cachePerLoaderAndOfflineFallback()
|
||||
{
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", r));
|
||||
}
|
||||
QCOMPARE(m_net.hits(kFabric), 1);
|
||||
|
||||
ModLoaderVersionService service;
|
||||
CallResult cached;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", cached));
|
||||
QVERIFY(cached.ok);
|
||||
QCOMPARE(m_net.hits(kFabric), 1); // свежий кеш
|
||||
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
|
||||
|
||||
// Сеть легла, принудительное обновление: данные остаются, ok с предупреждением.
|
||||
m_net.route(kFabric, FakeResponse::error(502));
|
||||
CallResult offline;
|
||||
QVERIFY(load(service, ModLoader::Fabric, "1.20.1", offline, true));
|
||||
QVERIFY(offline.ok);
|
||||
QVERIFY(!offline.warning.isEmpty());
|
||||
QCOMPARE(service.versions(ModLoader::Fabric, "1.20.1").size(), 3);
|
||||
}
|
||||
|
||||
void emptyGameVersionIsNoop()
|
||||
{
|
||||
ModLoaderVersionService service;
|
||||
CallResult r;
|
||||
QVERIFY(load(service, ModLoader::Fabric, " ", r));
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void inFlightRequestsAreShared()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(Fixtures::read("loaders/fabric-1.20.1.json"));
|
||||
slow.delayMs = 300;
|
||||
m_net.route(kFabric, slow);
|
||||
ModLoaderVersionService service;
|
||||
CallResult a, b;
|
||||
service.ensureLoaded(ModLoader::Fabric, "1.20.1", a.callback());
|
||||
QVERIFY(service.isRefreshing(ModLoader::Fabric, "1.20.1"));
|
||||
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.19.2"));
|
||||
service.ensureLoaded(ModLoader::Fabric, "1.20.1", b.callback());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1, 15000);
|
||||
QCOMPARE(m_net.hits(kFabric), 1);
|
||||
QVERIFY(!service.isRefreshing(ModLoader::Fabric, "1.20.1"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestModLoaderVersionService)
|
||||
#include "tst_modloaderversionservice.moc"
|
||||
@@ -0,0 +1,265 @@
|
||||
#include "testenv.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
#include "msaauthservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
const QString kToken = QStringLiteral("https://login.live.com/oauth20_token.srf");
|
||||
const QString kXbl = QStringLiteral("https://user.auth.xboxlive.com/user/authenticate");
|
||||
const QString kXsts = QStringLiteral("https://xsts.auth.xboxlive.com/xsts/authorize");
|
||||
const QString kMcLogin = QStringLiteral("https://api.minecraftservices.com/authentication/login_with_xbox");
|
||||
const QString kEntitlements = QStringLiteral("https://api.minecraftservices.com/entitlements/mcstore");
|
||||
const QString kProfile = QStringLiteral("https://api.minecraftservices.com/minecraft/profile");
|
||||
|
||||
QJsonObject jsonBody(const FakeRequest &request)
|
||||
{
|
||||
return QJsonDocument::fromJson(request.body).object();
|
||||
}
|
||||
|
||||
struct Captured
|
||||
{
|
||||
int calls = 0;
|
||||
AuthResult result;
|
||||
auto callback()
|
||||
{
|
||||
return [this](const AuthResult &r) { ++calls; result = r; };
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestMsaAuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
// Полностью рабочая цепочка Microsoft → Xbox → XSTS → Minecraft.
|
||||
void routeHappyPath()
|
||||
{
|
||||
m_net.route(kToken, [](const FakeRequest &request) {
|
||||
const QUrlQuery form = request.formItems();
|
||||
const QString grant = form.queryItemValue("grant_type");
|
||||
if (grant == QLatin1String("authorization_code") && form.queryItemValue("code") == QLatin1String("good-code"))
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-2"}});
|
||||
if (grant == QLatin1String("refresh_token") && form.queryItemValue("refresh_token") == QLatin1String("ms-refresh-1"))
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "ms-access"}, {"refresh_token", "ms-refresh-3"}});
|
||||
return FakeResponse::json(QJsonObject{{"error", "invalid_grant"},
|
||||
{"error_description", "The provided value for the 'code' parameter is not valid."}}, 400);
|
||||
});
|
||||
m_net.route(kXbl, [](const FakeRequest &request) {
|
||||
const QJsonObject props = jsonBody(request).value("Properties").toObject();
|
||||
if (props.value("RpsTicket").toString() != QLatin1String("ms-access"))
|
||||
return FakeResponse::error(400);
|
||||
return FakeResponse::json(QJsonObject{{"Token", "xbl-token"}});
|
||||
});
|
||||
m_net.route(kXsts, [](const FakeRequest &request) {
|
||||
const QJsonObject props = jsonBody(request).value("Properties").toObject();
|
||||
if (props.value("UserTokens").toArray().first().toString() != QLatin1String("xbl-token"))
|
||||
return FakeResponse::error(400);
|
||||
const QJsonObject claims{{"xui", QJsonArray{QJsonObject{{"uhs", "user-hash"}, {"xid", "2535400000000000"}}}}};
|
||||
return FakeResponse::json(QJsonObject{{"Token", "xsts-token"}, {"DisplayClaims", claims}});
|
||||
});
|
||||
m_net.route(kMcLogin, [](const FakeRequest &request) {
|
||||
if (jsonBody(request).value("identityToken").toString() != QLatin1String("XBL3.0 x=user-hash;xsts-token"))
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"access_token", "mc-token"}, {"expires_in", 3600}});
|
||||
});
|
||||
m_net.route(kEntitlements, [](const FakeRequest &request) {
|
||||
if (request.header("authorization") != "Bearer mc-token")
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "game_minecraft"}}}}});
|
||||
});
|
||||
m_net.route(kProfile, [](const FakeRequest &request) {
|
||||
if (request.header("authorization") != "Bearer mc-token")
|
||||
return FakeResponse::error(401);
|
||||
return FakeResponse::json(QJsonObject{{"id", "069a79f4-44e9-4726-a5be-fca90e38aaf5"}, {"name", "Notch"}});
|
||||
});
|
||||
}
|
||||
|
||||
static bool wait(Captured &c) { return QTest::qWaitFor([&] { return c.calls > 0; }, 15000); }
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
routeHappyPath();
|
||||
}
|
||||
|
||||
void fullChainWithCode()
|
||||
{
|
||||
MsaAuthService service;
|
||||
QSignalSpy progress(&service, &MsaAuthService::progress);
|
||||
Captured c;
|
||||
const QDateTime before = QDateTime::currentDateTimeUtc();
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.playerName, QStringLiteral("Notch"));
|
||||
QCOMPARE(c.result.uuid, QStringLiteral("069a79f444e94726a5befca90e38aaf5"));
|
||||
QCOMPARE(c.result.accessToken, QStringLiteral("mc-token"));
|
||||
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-2"));
|
||||
QCOMPARE(c.result.xuid, QStringLiteral("2535400000000000"));
|
||||
QCOMPARE(c.result.userType, QStringLiteral("msa"));
|
||||
QCOMPARE(c.result.clientToken, MsaAuthService::clientId());
|
||||
QVERIFY(!c.result.licenseMissing);
|
||||
// expiresAt = момент ответа login_with_xbox + expires_in; на медленном раннере
|
||||
// вся цепочка может идти дольше секунды, поэтому границы — до и после логина.
|
||||
const QDateTime after = QDateTime::currentDateTimeUtc();
|
||||
QVERIFY(c.result.expiresAt >= before.addSecs(3600));
|
||||
QVERIFY(c.result.expiresAt <= after.addSecs(3600));
|
||||
QVERIFY(progress.count() >= 5);
|
||||
|
||||
// Порядок шагов и формат запроса токена.
|
||||
QCOMPARE(m_net.requestedUrls(), QStringList({kToken, kXbl, kXsts, kMcLogin, kEntitlements, kProfile}));
|
||||
const FakeRequest tokenRequest = m_net.requests().constFirst();
|
||||
QCOMPARE(tokenRequest.header("content-type"), QByteArray("application/x-www-form-urlencoded"));
|
||||
QCOMPARE(tokenRequest.formItems().queryItemValue("client_id"), MsaAuthService::clientId());
|
||||
QCOMPARE(jsonBody(m_net.requests().at(1)).value("RelyingParty").toString(), QStringLiteral("http://auth.xboxlive.com"));
|
||||
QCOMPARE(jsonBody(m_net.requests().at(2)).value("RelyingParty").toString(), QStringLiteral("rp://api.minecraftservices.com/"));
|
||||
QCOMPARE(jsonBody(m_net.requests().at(2)).value("Properties").toObject().value("SandboxId").toString(),
|
||||
QStringLiteral("RETAIL"));
|
||||
}
|
||||
|
||||
void refreshTokenLogin()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithRefreshToken("ms-refresh-1", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY2(c.result.ok, qPrintable(c.result.error));
|
||||
QCOMPARE(c.result.refreshToken, QStringLiteral("ms-refresh-3")); // новый refresh-токен сохраняется
|
||||
QCOMPARE(m_net.requests().constFirst().formItems().queryItemValue("grant_type"), QStringLiteral("refresh_token"));
|
||||
}
|
||||
|
||||
void emptyInputsFailImmediately()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured noCode;
|
||||
service.loginWithCode(QString(), noCode.callback());
|
||||
QCOMPARE(noCode.calls, 1);
|
||||
QCOMPARE(noCode.result.error, Loc::text("auth.msa.error.noAuthCode"));
|
||||
Captured noRefresh;
|
||||
service.loginWithRefreshToken(QString(), noRefresh.callback());
|
||||
QCOMPARE(noRefresh.calls, 1);
|
||||
QCOMPARE(noRefresh.result.error, Loc::text("auth.msa.error.profileNotLinked"));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void badCodeShowsMicrosoftDescription()
|
||||
{
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("bad-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, QStringLiteral("The provided value for the 'code' parameter is not valid."));
|
||||
QCOMPARE(m_net.requests().size(), 1);
|
||||
}
|
||||
|
||||
void xstsErrors_data()
|
||||
{
|
||||
QTest::addColumn<double>("xerr");
|
||||
QTest::addColumn<QString>("message");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("no xbox profile") << 2148916233.0 << "" << Loc::text("auth.msa.error.noXboxProfile");
|
||||
QTest::newRow("country") << 2148916235.0 << "" << Loc::text("auth.msa.error.countryBlocked");
|
||||
QTest::newRow("age 36") << 2148916236.0 << "" << Loc::text("auth.msa.error.ageVerification");
|
||||
QTest::newRow("age 37") << 2148916237.0 << "" << Loc::text("auth.msa.error.ageVerification");
|
||||
QTest::newRow("child") << 2148916238.0 << "" << Loc::text("auth.msa.error.childAccount");
|
||||
QTest::newRow("unknown with message") << 1.0 << "Something odd" << "Something odd";
|
||||
QTest::newRow("unknown without message") << 7.0 << "" << Loc::text("auth.msa.error.xboxRejected").arg(7);
|
||||
}
|
||||
|
||||
void xstsErrors()
|
||||
{
|
||||
QFETCH(double, xerr);
|
||||
QFETCH(QString, message);
|
||||
QFETCH(QString, expected);
|
||||
m_net.route(kXsts, FakeResponse::json(QJsonObject{{"XErr", xerr}, {"Message", message}}, 401));
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, expected);
|
||||
}
|
||||
|
||||
void stepFailures_data()
|
||||
{
|
||||
QTest::addColumn<QString>("url");
|
||||
QTest::addColumn<int>("status");
|
||||
QTest::addColumn<QByteArray>("body");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("token without access_token") << kToken << 200 << QByteArray("{}") << Loc::text("auth.msa.error.noAccessToken");
|
||||
QTest::newRow("xbl rejected") << kXbl << 400 << QByteArray("{}") << Loc::text("auth.msa.error.xboxTokenRejected").arg(400);
|
||||
QTest::newRow("xbl no token") << kXbl << 200 << QByteArray("{}") << Loc::text("auth.msa.error.xboxNoToken");
|
||||
QTest::newRow("xsts 500") << kXsts << 500 << QByteArray("{}") << Loc::text("auth.msa.error.xstsRejected").arg(500);
|
||||
QTest::newRow("xsts no user") << kXsts << 200 << QByteArray("{\"Token\":\"t\"}") << Loc::text("auth.msa.error.xstsNoUser");
|
||||
QTest::newRow("services rejected") << kMcLogin << 429 << QByteArray("{}") << Loc::text("auth.msa.error.servicesRejected").arg(429);
|
||||
QTest::newRow("services no token") << kMcLogin << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoToken");
|
||||
QTest::newRow("profile 500") << kProfile << 500 << QByteArray("{}") << Loc::text("auth.msa.error.profileFetchFailed").arg(500);
|
||||
QTest::newRow("profile empty") << kProfile << 200 << QByteArray("{}") << Loc::text("auth.msa.error.servicesNoProfile");
|
||||
}
|
||||
|
||||
void stepFailures()
|
||||
{
|
||||
QFETCH(QString, url);
|
||||
QFETCH(int, status);
|
||||
QFETCH(QByteArray, body);
|
||||
QFETCH(QString, expected);
|
||||
m_net.route(url, FakeResponse::json(body, status));
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(!c.result.ok);
|
||||
QCOMPARE(c.result.error, expected);
|
||||
}
|
||||
|
||||
void licenseChecks()
|
||||
{
|
||||
// Профиля нет, покупки тоже нет — нет лицензии.
|
||||
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{}}}));
|
||||
m_net.route(kProfile, FakeResponse::json("{}", 404));
|
||||
MsaAuthService service;
|
||||
Captured noLicense;
|
||||
service.loginWithCode("good-code", noLicense.callback());
|
||||
QVERIFY(wait(noLicense));
|
||||
QVERIFY(!noLicense.result.ok);
|
||||
QVERIFY(noLicense.result.licenseMissing);
|
||||
QCOMPARE(noLicense.result.error, Loc::text("auth.msa.error.noLicence"));
|
||||
|
||||
// Покупка есть, но профиль (ник) ещё не создан.
|
||||
m_net.route(kEntitlements, FakeResponse::json(QJsonObject{{"items", QJsonArray{QJsonObject{{"name", "x"}}}}}));
|
||||
Captured noProfile;
|
||||
service.loginWithCode("good-code", noProfile.callback());
|
||||
QVERIFY(wait(noProfile));
|
||||
QVERIFY(noProfile.result.licenseMissing);
|
||||
QCOMPARE(noProfile.result.error, Loc::text("auth.msa.error.noProfileYet"));
|
||||
}
|
||||
|
||||
void transportErrorAtMicrosoft()
|
||||
{
|
||||
FakeResponse drop;
|
||||
drop.drop = true;
|
||||
m_net.route(kToken, drop);
|
||||
MsaAuthService service;
|
||||
Captured c;
|
||||
service.loginWithCode("good-code", c.callback());
|
||||
QVERIFY(wait(c));
|
||||
QVERIFY(c.result.error.startsWith(Loc::text("auth.msa.error.microsoftUnreachable").section(u'%', 0, 0)));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestMsaAuthService)
|
||||
#include "tst_msaauthservice.moc"
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "seasonalbuildservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QUrl kBase(QStringLiteral("https://launcher-builds.galeonworx.com"));
|
||||
const QString kIndex = QStringLiteral("https://launcher-builds.galeonworx.com/index.json");
|
||||
}
|
||||
|
||||
class TestSeasonalBuildService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
}
|
||||
|
||||
void withoutBaseUrlFails()
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("seasonal.error.noServerUrl"));
|
||||
QCOMPARE(service.lastError(), Loc::text("seasonal.error.noServerUrl"));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
}
|
||||
|
||||
void parsesValidatesAndSorts()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
QCOMPARE(service.baseUrl(), kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY2(r.ok, qPrintable(r.warning));
|
||||
|
||||
QStringList ids;
|
||||
for (const SeasonalBuildEntry &entry : service.builds())
|
||||
ids << entry.id;
|
||||
// Открытые сезоны первыми (новые выше), закрытые — после; всё невалидное отброшено.
|
||||
QCOMPARE(ids, QStringList({"season-5", "vanilla-plus", "season-4"}));
|
||||
|
||||
const auto s5 = service.find("season-5");
|
||||
QVERIFY(s5.has_value());
|
||||
QVERIFY(s5->isValid());
|
||||
QCOMPARE(s5->name, QStringLiteral("Сезон 5: Пустоши"));
|
||||
QCOMPARE(s5->revision, 3);
|
||||
QCOMPARE(s5->minecraftVersion, QStringLiteral("1.21.1"));
|
||||
QCOMPARE(s5->loader, QStringLiteral("fabric"));
|
||||
QCOMPARE(s5->loaderVersion, QStringLiteral("0.16.10"));
|
||||
QCOMPARE(s5->javaMajor, 21);
|
||||
QCOMPARE(s5->modCount, 120);
|
||||
QCOMPARE(s5->description, QStringLiteral("Пустоши")); // обрезаны пробелы
|
||||
QCOMPARE(s5->seasonStart, QDate(2025, 7, 1));
|
||||
QVERIFY(!s5->seasonEnd.isValid());
|
||||
QCOMPARE(s5->archiveSize, qint64(123456));
|
||||
QCOMPARE(s5->archiveSha256, QString("abcdef" + QString(58, u'0'))); // к нижнему регистру
|
||||
QCOMPARE(s5->archiveUrl, QUrl("https://launcher-builds.galeonworx.com/packs/s5.zip"));
|
||||
QCOMPARE(service.find("season-4")->seasonEnd, QDate(2025, 6, 1));
|
||||
QVERIFY(service.find("vanilla-plus")->loader.isEmpty());
|
||||
|
||||
QVERIFY(QFile::exists(LauncherPaths::seasonalCatalogFile()));
|
||||
}
|
||||
|
||||
void baseUrlPathIsKept()
|
||||
{
|
||||
m_net.route("https://launcher-builds.galeonworx.com/beta/index.json",
|
||||
FakeResponse::json(QJsonObject{{"formatVersion", 1}, {"builds", QJsonArray{}}}));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(QUrl("https://launcher-builds.galeonworx.com/beta"));
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits("https://launcher-builds.galeonworx.com/beta/index.json"), 1);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("seasonal.error.catalogEmpty"));
|
||||
}
|
||||
|
||||
void formatVersionChecks_data()
|
||||
{
|
||||
QTest::addColumn<int>("format");
|
||||
QTest::addColumn<QString>("error");
|
||||
QTest::newRow("too new") << 2 << Loc::text("seasonal.error.formatTooNew").arg(2);
|
||||
QTest::newRow("unknown") << 0 << Loc::text("seasonal.error.formatUnknown").arg(0);
|
||||
}
|
||||
|
||||
void formatVersionChecks()
|
||||
{
|
||||
QFETCH(int, format);
|
||||
QFETCH(QString, error);
|
||||
QJsonObject catalog = Fixtures::readJson("seasonal/index.json");
|
||||
catalog["formatVersion"] = format;
|
||||
m_net.route(kIndex, FakeResponse::json(catalog));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, error);
|
||||
QCOMPARE(service.lastError(), error);
|
||||
}
|
||||
|
||||
void cacheIsBoundToBaseUrl()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
// Тот же адрес — свежий кеш, без сети.
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QCOMPARE(m_net.hits(kIndex), 1);
|
||||
}
|
||||
// Другой адрес — чужой кеш не подхватывается.
|
||||
SeasonalBuildService other;
|
||||
other.setBaseUrl(QUrl("https://mirror.example"));
|
||||
m_net.route("https://mirror.example/index.json", FakeResponse::error(503));
|
||||
CallResult r;
|
||||
other.ensureLoaded(r.callback());
|
||||
QVERIFY(!other.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("seasonal.error.catalogFetchFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
|
||||
void setBaseUrlResetsData()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(service.hasData());
|
||||
|
||||
QSignalSpy changed(&service, &SeasonalBuildService::buildsChanged);
|
||||
service.setBaseUrl(kBase); // тот же адрес — ничего не происходит
|
||||
QCOMPARE(changed.count(), 0);
|
||||
service.setBaseUrl(QUrl("https://mirror.example"));
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QVERIFY(!service.hasData());
|
||||
QVERIFY(!service.find("season-5").has_value());
|
||||
}
|
||||
|
||||
void offlineWithCacheKeepsBuilds()
|
||||
{
|
||||
m_net.route(kIndex, FakeResponse::json(Fixtures::read("seasonal/index.json")));
|
||||
{
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
m_net.route(kIndex, FakeResponse::error(502));
|
||||
SeasonalBuildService service;
|
||||
service.setBaseUrl(kBase);
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback(), /*forceRefresh=*/true);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.builds().size(), 3);
|
||||
QCOMPARE(service.lastError(), r.warning);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestSeasonalBuildService)
|
||||
#include "tst_seasonalbuildservice.moc"
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "localization.h"
|
||||
#include "seasonalpackdownloader.h"
|
||||
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QString kPack = QStringLiteral("https://launcher-builds.galeonworx.com/packs/season-5-r3.zip");
|
||||
}
|
||||
|
||||
class TestSeasonalPackDownloader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_dir;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(SeasonalPackDownloader &d)
|
||||
: started(&d, &SeasonalPackDownloader::started)
|
||||
, finished(&d, &SeasonalPackDownloader::finished)
|
||||
, failed(&d, &SeasonalPackDownloader::failed)
|
||||
, canceled(&d, &SeasonalPackDownloader::canceled)
|
||||
{}
|
||||
bool waitEnd()
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() > 0; }, 20000);
|
||||
}
|
||||
QSignalSpy started, finished, failed, canceled;
|
||||
};
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
m_net.reset();
|
||||
m_dir = TestEnv::freshDir(QStringLiteral("packs"));
|
||||
}
|
||||
|
||||
void downloadsAndVerifies()
|
||||
{
|
||||
const QByteArray data(300 * 1024, 'p');
|
||||
m_net.route(kPack, FakeResponse::bytes(data));
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
const QString target = m_dir + "/sub/season-5.zip";
|
||||
downloader.download(QUrl(kPack), target, Fixtures::sha256(data).toUpper(), data.size(), "Season 5");
|
||||
QVERIFY(downloader.isRunning());
|
||||
QCOMPARE(downloader.label(), QStringLiteral("Season 5"));
|
||||
QCOMPARE(downloader.currentFile(), QStringLiteral("season-5.zip"));
|
||||
QCOMPARE(downloader.bytesTotal(), qint64(data.size()));
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.finished.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().constFirst().toString(), target);
|
||||
QCOMPARE(Fixtures::readFile(target), data);
|
||||
QVERIFY(!downloader.isRunning());
|
||||
QCOMPARE(downloader.fraction(), -1.0);
|
||||
}
|
||||
|
||||
void checksumMismatchLeavesNoFile()
|
||||
{
|
||||
m_net.route(kPack, FakeResponse::bytes("tampered"));
|
||||
SeasonalPackDownloader downloader;
|
||||
Run run(downloader);
|
||||
const QString target = m_dir + "/season.zip";
|
||||
downloader.download(QUrl(kPack), target, QString(64, u'a'), 8, "S");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QCOMPARE(run.failed.constFirst().at(0).toString(), QStringLiteral("S"));
|
||||
QCOMPARE(run.failed.constFirst().at(1).toString(), Loc::text("seasonal.error.checksumMismatch"));
|
||||
QVERIFY(!QFile::exists(target));
|
||||
}
|
||||
|
||||
void httpErrorAndTruncation()
|
||||
{
|
||||
SeasonalPackDownloader downloader;
|
||||
{
|
||||
m_net.route(kPack, FakeResponse::error(404));
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/a.zip", QString(64, u'a'), 0, "A");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.constFirst().at(1).toString().startsWith(
|
||||
Loc::text("seasonal.error.downloadFailed").section(u'%', 0, 0)));
|
||||
}
|
||||
{
|
||||
FakeResponse cut = FakeResponse::bytes(QByteArray(4096, 'x'));
|
||||
cut.truncateTo = 100;
|
||||
m_net.route(kPack, cut);
|
||||
Run run(downloader);
|
||||
downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B");
|
||||
QVERIFY2(run.waitEnd(), qPrintable(m_net.diagnostics()));
|
||||
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"
|
||||
@@ -0,0 +1,383 @@
|
||||
#include "testenv.h"
|
||||
#include "fakemojang.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "gamelauncher.h"
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
#include "versioninstaller.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestVersionInstaller : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
QString m_game;
|
||||
|
||||
struct Run
|
||||
{
|
||||
explicit Run(VersionInstaller &installer)
|
||||
: started(&installer, &VersionInstaller::started)
|
||||
, finished(&installer, &VersionInstaller::finished)
|
||||
, failed(&installer, &VersionInstaller::failed)
|
||||
, canceled(&installer, &VersionInstaller::canceled)
|
||||
, progress(&installer, &VersionInstaller::progressChanged)
|
||||
{}
|
||||
bool waitEnd(int expectedEvents = 1)
|
||||
{
|
||||
return QTest::qWaitFor([&] { return finished.count() + failed.count() + canceled.count() >= expectedEvents; },
|
||||
30000);
|
||||
}
|
||||
QString failure() const { return failed.isEmpty() ? QString() : failed.constLast().at(1).toString(); }
|
||||
QSignalSpy started, finished, failed, canceled, progress;
|
||||
};
|
||||
|
||||
void verifyInstalled(const FakeMojang &mojang, const QString &id)
|
||||
{
|
||||
const auto expected = mojang.expectedFiles(id);
|
||||
QVERIFY(!expected.isEmpty());
|
||||
for (auto it = expected.cbegin(); it != expected.cend(); ++it) {
|
||||
const QString path = m_game + u'/' + it.key();
|
||||
QVERIFY2(QFile::exists(path), qPrintable("нет файла " + it.key()));
|
||||
QCOMPARE(Fixtures::readFile(path), it.value());
|
||||
}
|
||||
}
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void installsModernVersionCompletely()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(installer.isRunning());
|
||||
QCOMPARE(installer.versionId(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(run.started.count(), 1);
|
||||
QCOMPARE(run.finished.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(run.progress.count() > 0);
|
||||
QVERIFY(m_net.unmatched().isEmpty());
|
||||
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
// Результат пригоден для запуска: GameLauncher не видит недостающих файлов.
|
||||
QString error;
|
||||
const MinecraftVersion version = VersionLoader::load(m_game, "1.20.1", {}, &error);
|
||||
QVERIFY2(version.isValid(), qPrintable(error));
|
||||
LaunchOptions options;
|
||||
options.gameDir = m_game;
|
||||
QVERIFY(GameLauncher::missingFiles(options, version).isEmpty());
|
||||
}
|
||||
|
||||
void secondInstallSkipsExistingFiles()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
{
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
}
|
||||
const int firstRequests = m_net.requests().size();
|
||||
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
// Повторно скачивается только json версии; файлы нужного размера пропускаются.
|
||||
const int extra = m_net.requests().size() - firstRequests;
|
||||
QVERIFY2(extra <= 1, qPrintable(QString::number(extra)));
|
||||
}
|
||||
|
||||
void legacyAssetsAreCopiedToVirtual()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.5.2", .legacyAssets = true, .javaMajor = 8});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.5.2");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "1.5.2"); // включая assets/virtual/legacy/*
|
||||
}
|
||||
|
||||
void inheritedVersionPullsParentFirst()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.publish({.id = "fabric-loader-0.16.10-1.20.1", .inheritsFrom = "1.20.1"});
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "fabric-loader-0.16.10-1.20.1");
|
||||
QVERIFY(run.waitEnd(2));
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
// started приходит на каждую версию, которую очередь берёт «с нуля».
|
||||
QCOMPARE(run.started.constFirst().constFirst().toString(), QStringLiteral("fabric-loader-0.16.10-1.20.1"));
|
||||
QStringList finishedIds;
|
||||
for (const auto &call : run.finished)
|
||||
finishedIds << call.constFirst().toString();
|
||||
QCOMPARE(finishedIds, QStringList({"1.20.1", "fabric-loader-0.16.10-1.20.1"}));
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
verifyInstalled(mojang, "fabric-loader-0.16.10-1.20.1");
|
||||
}
|
||||
|
||||
void localJsonUsedWhenNotInManifest()
|
||||
{
|
||||
// Версия не из манифеста (например, профиль модлоадера), но json уже лежит локально.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "custom-1", .listed = false});
|
||||
Fixtures::write(m_game + "/versions/custom-1/custom-1.json", mojang.versionJson("custom-1"));
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "custom-1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "custom-1");
|
||||
QCOMPARE(m_net.hits(mojang.versionJsonUrl("custom-1")), 0);
|
||||
}
|
||||
|
||||
void unknownVersionFails()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "9.9.9");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.notInManifest").arg("9.9.9"));
|
||||
}
|
||||
|
||||
void invalidNamesRejected_data()
|
||||
{
|
||||
QTest::addColumn<QString>("id");
|
||||
QTest::newRow("traversal") << "../evil";
|
||||
QTest::newRow("slash") << "a/b";
|
||||
QTest::newRow("dot") << ".";
|
||||
QTest::newRow("colon") << "c:x";
|
||||
}
|
||||
|
||||
void invalidNamesRejected()
|
||||
{
|
||||
QFETCH(QString, id);
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, id);
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.invalidName").arg(id));
|
||||
QVERIFY(m_net.requests().isEmpty());
|
||||
|
||||
installer.install(m_game, " "); // пустой id молча игнорируется
|
||||
QCOMPARE(run.failed.count(), 1);
|
||||
}
|
||||
|
||||
void versionJsonChecksumMismatch()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
m_net.route(mojang.versionJsonUrl("1.20.1"), FakeResponse::json("{\"tampered\":true}"));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.failure(), Loc::text("version.error.descriptionChecksum").arg("1.20.1"));
|
||||
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
|
||||
}
|
||||
|
||||
void transientErrorIsRetried()
|
||||
{
|
||||
// Сбой не на последнем файле: пока ждём повтора, остальные ещё качаются.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
|
||||
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
|
||||
slowJar.delayMs = 1500;
|
||||
mojang.overrideFile(jar, slowJar);
|
||||
int attempts = 0;
|
||||
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
|
||||
return ++attempts == 1 ? FakeResponse::error(503) : FakeResponse::bytes("brigadier");
|
||||
});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(attempts, 2);
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
}
|
||||
|
||||
void retryOfLastFileIsNotLost()
|
||||
{
|
||||
// Сбой на последнем файле: остальные уже скачаны, повтор стоит в таймере.
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
int attempts = 0;
|
||||
m_net.route(mojang.fileUrl(lib), [&attempts](const FakeRequest &) {
|
||||
if (++attempts > 1)
|
||||
return FakeResponse::bytes("brigadier");
|
||||
FakeResponse late = FakeResponse::error(503);
|
||||
late.delayMs = 1000; // все прочие файлы успевают докачаться
|
||||
return late;
|
||||
});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
// retryOrFail() откладывает повтор на 400 мс, а handleReplyFinished() тут же
|
||||
// зовёт pump(): очередь и активные загрузки пусты — установка «завершается»
|
||||
// и падает на проверке файлов, повтор так и не происходит.
|
||||
QEXPECT_FAIL("", "повтор последнего файла теряется: pump() завершает установку раньше таймера", Abort);
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(attempts, 2);
|
||||
}
|
||||
|
||||
void checksumMismatchRetriesThenFails()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
const QString jar = QStringLiteral("versions/1.20.1/1.20.1.jar");
|
||||
// client.jar держим долго, чтобы все повторы библиотеки шли при живой установке.
|
||||
FakeResponse slowJar = FakeResponse::bytes(mojang.expectedFiles("1.20.1").value(jar));
|
||||
slowJar.delayMs = 4000;
|
||||
mojang.overrideFile(jar, slowJar);
|
||||
mojang.overrideFile(lib, FakeResponse::bytes("corrupted"));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failure().startsWith(Loc::text("common.error.downloadFailed")
|
||||
.arg("com.mojang:brigadier:1.1.8", Loc::text("common.error.checksumMismatch"))),
|
||||
qPrintable(run.failure()));
|
||||
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 3); // kMaxAttempts
|
||||
QVERIFY(!QFile::exists(m_game + u'/' + lib)); // битый файл не сохранён
|
||||
}
|
||||
|
||||
void permanentErrorFailsImmediately()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
const QString lib = QStringLiteral("libraries/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar");
|
||||
mojang.overrideFile(lib, FakeResponse::error(404));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(!run.failed.isEmpty());
|
||||
QCOMPARE(m_net.hits(mojang.fileUrl(lib)), 1);
|
||||
}
|
||||
|
||||
void cancelStopsDownloads()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
FakeResponse hang;
|
||||
hang.hang = true;
|
||||
mojang.overrideFile("versions/1.20.1/1.20.1.jar", hang);
|
||||
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_net.hits(mojang.fileUrl("versions/1.20.1/1.20.1.jar")) == 1, 15000);
|
||||
installer.cancel();
|
||||
QVERIFY(run.waitEnd());
|
||||
QCOMPARE(run.canceled.count(), 1);
|
||||
QCOMPARE(run.canceled.constFirst().constFirst().toString(), QStringLiteral("1.20.1"));
|
||||
QVERIFY(run.finished.isEmpty());
|
||||
QVERIFY(!installer.isRunning());
|
||||
QVERIFY(!QFile::exists(m_game + "/versions/1.20.1/1.20.1.jar"));
|
||||
}
|
||||
|
||||
void queueRunsSequentiallyAndDeduplicates()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
mojang.publish({.id = "1.19.4", .releaseTime = "2023-03-14T12:00:00+00:00"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
installer.install(m_game, "1.19.4");
|
||||
installer.install(m_game, "1.19.4"); // дубль в очереди
|
||||
installer.install(m_game, "1.20.1"); // уже ставится
|
||||
QVERIFY(installer.isQueued("1.19.4"));
|
||||
QVERIFY(!installer.isQueued("1.20.1"));
|
||||
QVERIFY(run.waitEnd(2));
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
QCOMPARE(run.finished.count(), 2);
|
||||
QCOMPARE(run.started.count(), 2);
|
||||
verifyInstalled(mojang, "1.19.4");
|
||||
}
|
||||
|
||||
void offlineWithLocalJsonStillInstalls()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", mojang.versionJson("1.20.1"));
|
||||
m_net.route(FakeMojang::manifestUrl(), FakeResponse::error(503));
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
Run run(installer);
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY2(run.failed.isEmpty(), qPrintable(run.failure()));
|
||||
verifyInstalled(mojang, "1.20.1");
|
||||
}
|
||||
|
||||
void fractionReportsProgress()
|
||||
{
|
||||
FakeMojang mojang(m_net);
|
||||
mojang.publish({.id = "1.20.1"});
|
||||
VersionManifestService manifest;
|
||||
VersionInstaller installer(&manifest);
|
||||
QCOMPARE(installer.fraction(), -1.0);
|
||||
Run run(installer);
|
||||
double maxFraction = -1;
|
||||
connect(&installer, &VersionInstaller::progressChanged, this, [&] {
|
||||
maxFraction = std::max(maxFraction, installer.fraction());
|
||||
QVERIFY(installer.fraction() <= 1.0);
|
||||
});
|
||||
installer.install(m_game, "1.20.1");
|
||||
QVERIFY(run.waitEnd());
|
||||
QVERIFY(run.failed.isEmpty());
|
||||
QVERIFY(maxFraction > 0.0);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestVersionInstaller)
|
||||
#include "tst_versioninstaller.moc"
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
#include "nettest.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
#include "versionmanifestservice.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
const QString kManifest = QStringLiteral("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json");
|
||||
}
|
||||
|
||||
class TestVersionManifestService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
private slots:
|
||||
void initTestCase() { BILAUNCHER_START_FAKE_INTERNET(m_net); }
|
||||
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_net.reset();
|
||||
}
|
||||
|
||||
void loadsSortsAndFilters()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
QSignalSpy changed(&service, &VersionManifestService::versionsChanged);
|
||||
QSignalSpy refreshing(&service, &VersionManifestService::refreshingChanged);
|
||||
CallResult result;
|
||||
service.ensureLoaded(result.callback());
|
||||
QVERIFY(service.isRefreshing());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(result.calls, 1, 15000);
|
||||
QVERIFY(result.ok);
|
||||
QVERIFY(result.warning.isEmpty());
|
||||
QVERIFY(!service.isRefreshing());
|
||||
QCOMPARE(changed.count(), 1);
|
||||
QCOMPARE(refreshing.count(), 2);
|
||||
|
||||
// Новые первыми; без id и с http-ссылкой — выброшены.
|
||||
QStringList ids;
|
||||
for (const RemoteVersionEntry &entry : service.versions())
|
||||
ids << entry.id;
|
||||
QCOMPARE(ids, QStringList({"25w33a", "1.21.8", "1.20.1", "b1.7.3"}));
|
||||
|
||||
const auto entry = service.find("1.20.1");
|
||||
QVERIFY(entry.has_value());
|
||||
QCOMPARE(entry->type, QStringLiteral("release"));
|
||||
QCOMPARE(entry->sha1, QStringLiteral("aaaa"));
|
||||
QCOMPARE(entry->url, QUrl("https://piston-meta.mojang.com/v1/packages/aaaa/1.20.1.json"));
|
||||
QCOMPARE(entry->releaseTime, QDateTime(QDate(2023, 6, 12), QTime(13, 25, 51), QTimeZone::UTC));
|
||||
QVERIFY(!service.find("insecure").has_value());
|
||||
QVERIFY(service.fetchedAt().isValid());
|
||||
|
||||
// Кеш на диске записан.
|
||||
const QJsonObject cache = Fixtures::readJsonFile(LauncherPaths::versionManifestFile());
|
||||
QCOMPARE(cache.value("versions").toArray().size(), 4);
|
||||
QVERIFY(!cache.value("fetchedAt").toString().isEmpty());
|
||||
}
|
||||
|
||||
void freshCacheAvoidsNetwork()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
{
|
||||
VersionManifestService first;
|
||||
CallResult r;
|
||||
first.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
}
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
|
||||
// Новый экземпляр (как после перезапуска лаунчера) берёт свежий кеш.
|
||||
VersionManifestService second;
|
||||
CallResult r;
|
||||
second.ensureLoaded(r.callback());
|
||||
QVERIFY(second.hasData()); // данные из кеша уже доступны синхронно
|
||||
QCOMPARE(r.calls, 0); // колбэк — только через цикл событий
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 5000);
|
||||
QVERIFY(r.ok);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
|
||||
// forceRefresh идёт в сеть даже при свежем кеше.
|
||||
CallResult forced;
|
||||
second.ensureLoaded(forced.callback(), true);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(forced.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits(kManifest), 2);
|
||||
}
|
||||
|
||||
void staleCacheIsRefreshed()
|
||||
{
|
||||
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
|
||||
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
|
||||
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
}
|
||||
|
||||
void networkErrorWithoutCacheFails()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::error(503));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QVERIFY(r.warning.startsWith(Loc::text("version.error.manifestFetchFailed").section(u'%', 0, 0)));
|
||||
QVERIFY(!service.hasData());
|
||||
}
|
||||
|
||||
void networkErrorWithCacheSucceedsWithWarning()
|
||||
{
|
||||
QJsonObject cache = Fixtures::readJson("mojang/version_manifest_v2.json");
|
||||
cache["fetchedAt"] = QDateTime::currentDateTimeUtc().addDays(-2).toString(Qt::ISODate);
|
||||
Fixtures::writeJson(LauncherPaths::versionManifestFile(), cache);
|
||||
m_net.route(kManifest, FakeResponse::error(500));
|
||||
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok); // офлайн, но список есть
|
||||
QVERIFY(!r.warning.isEmpty());
|
||||
QCOMPARE(service.versions().size(), 4);
|
||||
}
|
||||
|
||||
void garbageResponseIsRejected()
|
||||
{
|
||||
m_net.route(kManifest, FakeResponse::json("{\"versions\": []}"));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(!r.ok);
|
||||
QCOMPARE(r.warning, Loc::text("version.error.manifestUnparsable"));
|
||||
QVERIFY(!QFile::exists(LauncherPaths::versionManifestFile()));
|
||||
}
|
||||
|
||||
void concurrentCallersShareOneRequest()
|
||||
{
|
||||
FakeResponse slow = FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json"));
|
||||
slow.delayMs = 300;
|
||||
m_net.route(kManifest, slow);
|
||||
VersionManifestService service;
|
||||
CallResult a, b, c;
|
||||
service.ensureLoaded(a.callback());
|
||||
service.ensureLoaded(b.callback());
|
||||
service.ensureLoaded(c.callback());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(a.calls == 1 && b.calls == 1 && c.calls == 1, 15000);
|
||||
QVERIFY(a.ok && b.ok && c.ok);
|
||||
QCOMPARE(m_net.hits(kManifest), 1);
|
||||
}
|
||||
|
||||
void corruptCacheIsIgnored()
|
||||
{
|
||||
Fixtures::write(LauncherPaths::versionManifestFile(), "\x00\x01garbage");
|
||||
m_net.route(kManifest, FakeResponse::json(Fixtures::read("mojang/version_manifest_v2.json")));
|
||||
VersionManifestService service;
|
||||
CallResult r;
|
||||
service.ensureLoaded(r.callback());
|
||||
QVERIFY(!service.hasData());
|
||||
QTRY_COMPARE_WITH_TIMEOUT(r.calls, 1, 15000);
|
||||
QVERIFY(r.ok);
|
||||
QVERIFY(service.hasData());
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestVersionManifestService)
|
||||
#include "tst_versionmanifestservice.moc"
|
||||
@@ -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)
|
||||
@@ -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 {} }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Qt Quick Test для QML-компонентов лаунчера.
|
||||
//
|
||||
// Модуль BiLauncher у приложения вшит в исполняемый файл и плагина не имеет,
|
||||
// поэтому qmltestrunner его не импортирует. Здесь тот же модуль собран
|
||||
// статической библиотекой (bilauncher_testlib) и подключён через
|
||||
// Q_IMPORT_QML_PLUGIN — тесты пишут обычный «import BiLauncher».
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakeinternet.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickStyle>
|
||||
#include <QtQml/qqmlextensionplugin.h>
|
||||
#include <QtQuickTest/quicktest.h>
|
||||
|
||||
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
|
||||
|
||||
class Setup : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public slots:
|
||||
void applicationAvailable()
|
||||
{
|
||||
TestEnv::finishSetup();
|
||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||
seedData();
|
||||
// Диалоги создают настоящий LauncherBackend — наружу он не должен ходить.
|
||||
QString error;
|
||||
if (!m_net.start(&error))
|
||||
qFatal("FakeInternet: %s", qPrintable(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"
|
||||
@@ -0,0 +1,109 @@
|
||||
// Дымовой тест главного окна: Main.qml поднимается так же, как в main.cpp,
|
||||
// без единого предупреждения QML, и ни одного запроса в настоящую сеть.
|
||||
// Ловит то, что не видно компилятору: опечатки в именах свойств и ключах Loc.t,
|
||||
// сломанные биндинги, несуществующие картинки в ресурсах.
|
||||
|
||||
#include "testenv.h"
|
||||
#include "fakeinternet.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQuickStyle>
|
||||
#include <QQuickWindow>
|
||||
#include <QtQml/qqmlextensionplugin.h>
|
||||
|
||||
Q_IMPORT_QML_PLUGIN(BiLauncherPlugin)
|
||||
|
||||
namespace {
|
||||
|
||||
QStringList g_problems;
|
||||
QtMessageHandler g_previous = nullptr;
|
||||
|
||||
void collect(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||||
{
|
||||
// Предупреждения QML приходят с файлом qrc:/… или с категорией qt.qml.*.
|
||||
const QString file = QString::fromUtf8(context.file ? context.file : "");
|
||||
const QString category = QString::fromUtf8(context.category ? context.category : "");
|
||||
const bool fromQml = file.startsWith(QLatin1String("qrc:")) || file.endsWith(QLatin1String(".qml"))
|
||||
|| category.startsWith(QLatin1String("qt.qml"))
|
||||
|| message.contains(QLatin1String(".qml:"));
|
||||
if (type != QtDebugMsg && type != QtInfoMsg && fromQml)
|
||||
g_problems << message;
|
||||
if (g_previous)
|
||||
g_previous(type, context, message);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestQmlSmoke : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
FakeInternet m_net;
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
QString error;
|
||||
QVERIFY2(m_net.start(&error), qPrintable(error));
|
||||
m_net.setFallback(FakeResponse::error(503));
|
||||
const QString game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"gameDir", game}, {"language", "en"}});
|
||||
QQuickStyle::setStyle(QStringLiteral("Basic"));
|
||||
g_previous = qInstallMessageHandler(collect);
|
||||
}
|
||||
|
||||
void cleanupTestCase() { qInstallMessageHandler(g_previous); }
|
||||
|
||||
void init() { g_problems.clear(); }
|
||||
|
||||
void mainWindowLoadsCleanly()
|
||||
{
|
||||
// Один движок на весь тест, как в приложении: Localization держит указатель
|
||||
// на JS-движок, создавший синглтон, и второй QQmlApplicationEngine после
|
||||
// уничтожения первого оставил бы его висячим.
|
||||
QQmlApplicationEngine engine;
|
||||
QStringList warnings;
|
||||
connect(&engine, &QQmlEngine::warnings, this, [&](const QList<QQmlError> &list) {
|
||||
for (const QQmlError &e : list)
|
||||
warnings << e.toString();
|
||||
});
|
||||
engine.loadFromModule("BiLauncher", "Main");
|
||||
QCOMPARE(engine.rootObjects().size(), 1);
|
||||
auto *window = qobject_cast<QQuickWindow *>(engine.rootObjects().constFirst());
|
||||
QVERIFY(window);
|
||||
QVERIFY(QTest::qWaitForWindowExposed(window));
|
||||
|
||||
// Даём отработать отложенным таймерам, загрузке каталогов и анимациям.
|
||||
QTest::qWait(2500);
|
||||
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
|
||||
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
|
||||
|
||||
// Смена языка на лету перестраивает Loc.t — биндинги не должны ломаться.
|
||||
const QString titleEn = window->title();
|
||||
Localization::instance().setLanguage(QStringLiteral("ru"));
|
||||
QTest::qWait(500);
|
||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||
QTest::qWait(300);
|
||||
QCOMPARE(window->title(), titleEn);
|
||||
QVERIFY2(warnings.isEmpty(), qPrintable(warnings.join(u'\n')));
|
||||
QVERIFY2(g_problems.isEmpty(), qPrintable(g_problems.join(u'\n')));
|
||||
|
||||
// Наружу — только через FakeInternet, и только на известные адреса лаунчера.
|
||||
static const QStringList known{"mojang.com", "galeonworx.com", "adoptium.net", "fabricmc.net",
|
||||
"quiltmc.org", "minecraftforge.net", "neoforged.net"};
|
||||
for (const QString &url : m_net.requestedUrls()) {
|
||||
const QString host = QUrl(url).host();
|
||||
const bool ok = std::any_of(known.cbegin(), known.cend(),
|
||||
[&](const QString &domain) { return host.endsWith(domain); });
|
||||
QVERIFY2(ok, qPrintable("неожиданный запрос: " + url));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_GUI_TEST_MAIN(TestQmlSmoke)
|
||||
#include "tst_qml_smoke.moc"
|
||||
@@ -0,0 +1,4 @@
|
||||
53
|
||||
# Число предупреждений qmllint по модулю BiLauncher, которое считается нормой.
|
||||
# tests/scripts/static_checks.py падает, если их стало больше. Исправили часть —
|
||||
# опустите число, чтобы улучшение закрепилось.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Дымовая проверка установленного дерева (результат cmake --install) на Windows.
|
||||
#
|
||||
# powershell -File tests\scripts\smoke_installed.ps1 -Prefix C:\r\bl-smoke-stage
|
||||
#
|
||||
# Проверяет, что windeployqt положил всё нужное (DLL Qt, плагины платформ и QML),
|
||||
# лаунчер запускается без окна и через 10 секунд ещё жив, а в логе нет ошибок
|
||||
# загрузки модулей.
|
||||
#
|
||||
# QDir::homePath() на Windows берёт профиль у системы и USERPROFILE не читает,
|
||||
# поэтому лаунчер запускается с BILAUNCHER_HOME на временную папку (см.
|
||||
# launcherpaths.h) — настоящая %APPDATA%\BiLauncher раннера не трогается.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Prefix,
|
||||
[int]$RunSeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
function Fail($message) { Write-Host "SMOKE FAIL: $message"; exit 1 }
|
||||
|
||||
$bin = Join-Path $Prefix "bin\BiLauncher.exe"
|
||||
if (-not (Test-Path $bin)) { Fail "нет $bin" }
|
||||
foreach ($required in @("bin\Qt6Core.dll", "bin\Qt6Quick.dll", "bin\Qt6Network.dll",
|
||||
"bin\platforms\qwindows.dll", "bin\qml\QtQuick\Controls")) {
|
||||
if (-not (Test-Path (Join-Path $Prefix $required))) { Fail "нет $required" }
|
||||
}
|
||||
if (-not (Get-ChildItem (Join-Path $Prefix "bin\tls") -Filter "*.dll" -ErrorAction SilentlyContinue)) {
|
||||
Fail "нет TLS-плагина (bin\tls) — лаунчер не сможет ходить по https"
|
||||
}
|
||||
|
||||
$realProfile = Join-Path $env:APPDATA "BiLauncher"
|
||||
$realExisted = Test-Path $realProfile
|
||||
|
||||
$sandbox = Join-Path $env:TEMP ("bilauncher-smoke-" + [guid]::NewGuid().ToString("N"))
|
||||
$log = Join-Path $sandbox "log"
|
||||
New-Item -ItemType Directory -Force $log | Out-Null
|
||||
$stdout = Join-Path $log "stdout.log"
|
||||
$stderr = Join-Path $log "stderr.log"
|
||||
$profileRoot = Join-Path $sandbox "AppData\Roaming\BiLauncher"
|
||||
|
||||
$env:BILAUNCHER_HOME = $sandbox
|
||||
$env:QT_QPA_PLATFORM = "offscreen"
|
||||
$env:QT_FORCE_STDERR_LOGGING = "1"
|
||||
$env:QTWEBENGINE_CHROMIUM_FLAGS = "--disable-gpu"
|
||||
|
||||
try {
|
||||
$process = Start-Process -FilePath $bin -PassThru -NoNewWindow `
|
||||
-RedirectStandardOutput $stdout -RedirectStandardError $stderr
|
||||
Start-Sleep -Seconds $RunSeconds
|
||||
$alive = -not $process.HasExited
|
||||
if ($alive) { Stop-Process -Id $process.Id -Force; $process.WaitForExit(5000) | Out-Null }
|
||||
|
||||
$text = ""
|
||||
if (Test-Path $stderr) { $text += Get-Content $stderr -Raw }
|
||||
if (Test-Path $stdout) { $text += Get-Content $stdout -Raw }
|
||||
Write-Host "----- лог запуска -----"
|
||||
Write-Host $text
|
||||
|
||||
if (-not $alive) { Fail "лаунчер завершился раньше чем через $RunSeconds с (код $($process.ExitCode))" }
|
||||
if ($text -match "is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed") {
|
||||
Fail "в логе ошибки загрузки модулей"
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $profileRoot "settings.json"))) { Fail "лаунчер не создал $profileRoot\settings.json" }
|
||||
if (-not $realExisted -and (Test-Path $realProfile)) { Fail "лаунчер создал настоящую $realProfile мимо BILAUNCHER_HOME" }
|
||||
Write-Host "SMOKE OK: $bin"
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Recurse -Force $sandbox -ErrorAction SilentlyContinue
|
||||
}
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Дымовая проверка установленного дерева (результат cmake --install) на Linux и macOS.
|
||||
#
|
||||
# tests/scripts/smoke_installed.sh <prefix>
|
||||
#
|
||||
# Linux: <prefix>/bin/BiLauncher (+ lib/BiLauncher/libz.so.1, плагины Qt рядом)
|
||||
# macOS: <prefix>/BiLauncher.app
|
||||
#
|
||||
# Проверяет, что деплой Qt положил всё нужное: бинарник не ссылается на
|
||||
# библиотеки вне дерева (ldd/otool), запускается без окна и через 10 секунд
|
||||
# ещё жив, а в логе нет «module is not installed», «Cannot load library» и т. п.
|
||||
# Запуск идёт с подменённым HOME — настоящие данные лаунчера на машине не трогаются.
|
||||
set -euo pipefail
|
||||
|
||||
PREFIX="$(cd "${1:?нужен путь к установленному дереву}" && pwd)"
|
||||
RUN_SECONDS="${RUN_SECONDS:-10}"
|
||||
fail() { echo "SMOKE FAIL: $*" >&2; exit 1; }
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
APP="$PREFIX/BiLauncher.app"
|
||||
BIN="$APP/Contents/MacOS/BiLauncher"
|
||||
[ -x "$BIN" ] || fail "нет $BIN"
|
||||
[ -f "$APP/Contents/Resources/icon.icns" ] || fail "нет иконки в бандле"
|
||||
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist" >/dev/null \
|
||||
|| fail "в Info.plist нет версии"
|
||||
# Ссылки на Qt должны вести внутрь бандла, а не в папку Qt сборочной машины.
|
||||
if otool -L "$BIN" | tail -n +2 | grep -E '/Qt/|/Users/|/home/' ; then
|
||||
fail "бинарник ссылается на библиотеки вне бандла"
|
||||
fi
|
||||
[ -d "$APP/Contents/Frameworks/QtQuick.framework" ] || fail "нет QtQuick.framework в бандле"
|
||||
[ -d "$APP/Contents/PlugIns/platforms" ] || fail "нет плагинов платформ"
|
||||
# Деплой идёт с -hardened-runtime: без подписи dyld откажется грузить фреймворки
|
||||
# («different Team IDs»). Подписываем ad-hoc ровно как release.yml перед упаковкой.
|
||||
codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || fail "ad-hoc подпись не удалась"
|
||||
codesign --verify --deep --strict "$APP" || fail "подпись бандла не проходит проверку"
|
||||
;;
|
||||
Linux)
|
||||
BIN="$PREFIX/bin/BiLauncher"
|
||||
[ -x "$BIN" ] || fail "нет $BIN"
|
||||
[ -f "$PREFIX/lib/BiLauncher/libz.so.1" ] || fail "нет эталонного zlib (lib/BiLauncher/libz.so.1)"
|
||||
[ -f "$PREFIX/share/applications/bilauncher.desktop" ] || fail "нет .desktop-файла"
|
||||
[ -f "$PREFIX/share/icons/hicolor/256x256/apps/bilauncher.png" ] || fail "нет иконки hicolor"
|
||||
[ -f "$PREFIX/share/doc/BiLauncher/LICENSE" ] || fail "нет LICENSE в share/doc"
|
||||
# Все разделяемые библиотеки — бинарника, Qt и плагинов — должны находиться.
|
||||
missing=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if ldd "$file" 2>/dev/null | grep -q 'not found'; then
|
||||
echo "не хватает библиотек для $file:" >&2
|
||||
ldd "$file" | grep 'not found' >&2
|
||||
missing=1
|
||||
fi
|
||||
done < <(find "$PREFIX" \( -name '*.so*' -o -path '*/bin/BiLauncher' \) -type f -print0)
|
||||
[ "$missing" = 0 ] || fail "ldd: есть ненайденные зависимости"
|
||||
;;
|
||||
*) fail "неизвестная система $(uname -s)" ;;
|
||||
esac
|
||||
|
||||
# Платформа Qt. Проверяем то, что реально задеплоено: macdeployqt кладёт только
|
||||
# cocoa — на macOS окно на 10 секунд действительно откроется (раннеру нужна
|
||||
# GUI-сессия). На Linux берём offscreen, если деплой его положил, иначе xcb
|
||||
# под xvfb-run (пакет xvfb, см. ci/setup-runner-linux.sh).
|
||||
LAUNCH=()
|
||||
PLATFORM=""
|
||||
if [ "$(uname -s)" = Linux ]; then
|
||||
if find "$PREFIX" -name 'libqoffscreen.so' | grep -q .; then
|
||||
PLATFORM=offscreen
|
||||
elif command -v xvfb-run >/dev/null; then
|
||||
LAUNCH=(xvfb-run -a)
|
||||
elif [ -z "${DISPLAY:-}" ]; then
|
||||
fail "нет ни плагина offscreen в деплое, ни xvfb-run, ни DISPLAY"
|
||||
fi
|
||||
fi
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
LOG="$SANDBOX/stderr.log"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
|
||||
env HOME="$SANDBOX" \
|
||||
${PLATFORM:+QT_QPA_PLATFORM=$PLATFORM} \
|
||||
QT_FORCE_STDERR_LOGGING=1 \
|
||||
QTWEBENGINE_DISABLE_SANDBOX=1 \
|
||||
QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu" \
|
||||
${LAUNCH[@]+"${LAUNCH[@]}"} "$BIN" >"$LOG" 2>&1 &
|
||||
PID=$!
|
||||
|
||||
sleep "$RUN_SECONDS"
|
||||
if ! kill -0 "$PID" 2>/dev/null; then
|
||||
wait "$PID" || true
|
||||
echo "----- лог -----" >&2; cat "$LOG" >&2
|
||||
fail "лаунчер завершился раньше чем через ${RUN_SECONDS} с"
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
echo "----- лог запуска -----"; cat "$LOG"
|
||||
if grep -E 'is not installed|Cannot load library|could not find the Qt platform plugin|QQmlApplicationEngine failed|module ".*" plugin ".*" not found|Каталог переводов' "$LOG"; then
|
||||
fail "в логе ошибки загрузки модулей"
|
||||
fi
|
||||
|
||||
# Лаунчер создал свою папку в подменённом HOME, а не где-то ещё.
|
||||
if [ "$(uname -s)" = Darwin ]; then ROOT="$SANDBOX/Library/Application Support/BiLauncher"; else ROOT="$SANDBOX/BiLauncher"; fi
|
||||
[ -f "$ROOT/settings.json" ] || fail "лаунчер не создал $ROOT/settings.json"
|
||||
|
||||
echo "SMOKE OK: $BIN"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/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 создаст релиз с пустым описанием (только предупреждение).
|
||||
4. Скрипты .ps1 с не-ASCII символами сохранены в UTF-8 с BOM: без BOM Windows
|
||||
PowerShell 5.1 читает их в ANSI-кодировке и падает на разборе кириллицы.
|
||||
"""
|
||||
import glob, 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"(-rc[0-9]+)?\]", changelog, re.M):
|
||||
print(f"версия {version}: раздел в CHANGELOG.md есть")
|
||||
else:
|
||||
print(f"ПРЕДУПРЕЖДЕНИЕ: в CHANGELOG.md нет раздела ## [{version}] — описание релиза будет пустым")
|
||||
return True
|
||||
|
||||
|
||||
def check_ps1_bom():
|
||||
step("Кодировка .ps1")
|
||||
ok = True
|
||||
for path in glob.glob(os.path.join(ROOT, "**", "*.ps1"), recursive=True):
|
||||
if os.path.relpath(path, ROOT).startswith("build"): # папки сборки
|
||||
continue
|
||||
data = open(path, "rb").read()
|
||||
if not data.startswith(b"\xef\xbb\xbf") and any(b > 0x7F for b in data):
|
||||
print(f"ОШИБКА: {os.path.relpath(path, ROOT)} содержит не-ASCII, но сохранён без UTF-8 BOM")
|
||||
ok = False
|
||||
if ok:
|
||||
print("все .ps1 читаются Windows PowerShell 5.1")
|
||||
return ok
|
||||
|
||||
|
||||
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(), check_ps1_bom()]
|
||||
print("\nИТОГ:", "OK" if all(results) else "ЕСТЬ ОШИБКИ")
|
||||
return 0 if all(results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
# Модульные тесты: без сети, только файлы в песочнице и подставная 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
|
||||
tst_gamedirimport)
|
||||
bilauncher_add_test(${test} LABEL unit SOURCES ${test}.cpp)
|
||||
endforeach()
|
||||
@@ -0,0 +1,67 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "authservice.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QRegularExpression>
|
||||
#include <QUuid>
|
||||
|
||||
class TestAuthOffline : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void emptyNicknameFails()
|
||||
{
|
||||
const AuthResult result = AuthService::offline(QString());
|
||||
QVERIFY(!result.ok);
|
||||
QCOMPARE(result.error, Loc::text("auth.offline.error.noNickname"));
|
||||
}
|
||||
|
||||
void uuidMatchesVanillaOfflineScheme()
|
||||
{
|
||||
// Так же UUID офлайн-игрока считает сервер в offline-mode:
|
||||
// UUID v3 от MD5("OfflinePlayer:<ник>").
|
||||
const AuthResult result = AuthService::offline(QStringLiteral("Notch"));
|
||||
QVERIFY(result.ok);
|
||||
QCOMPARE(result.playerName, QStringLiteral("Notch"));
|
||||
QCOMPARE(result.uuid, QStringLiteral("b50ad385829d3141a2167e7d7539ba7f"));
|
||||
QCOMPARE(result.accessToken, QStringLiteral("0"));
|
||||
QCOMPARE(result.clientToken, QStringLiteral("0"));
|
||||
QCOMPARE(result.userType, QStringLiteral("legacy"));
|
||||
}
|
||||
|
||||
void uuidIsStableAndWellFormed_data()
|
||||
{
|
||||
QTest::addColumn<QString>("nick");
|
||||
QTest::newRow("ascii") << "Steve";
|
||||
QTest::newRow("digits") << "player_123";
|
||||
QTest::newRow("cyrillic") << QStringLiteral("Игрок");
|
||||
}
|
||||
|
||||
void uuidIsStableAndWellFormed()
|
||||
{
|
||||
QFETCH(QString, nick);
|
||||
const AuthResult a = AuthService::offline(nick);
|
||||
const AuthResult b = AuthService::offline(nick);
|
||||
QCOMPARE(a.uuid, b.uuid);
|
||||
static const QRegularExpression hex(QStringLiteral("^[0-9a-f]{32}$"));
|
||||
QVERIFY(hex.match(a.uuid).hasMatch());
|
||||
QCOMPARE(a.uuid.at(12), QChar(u'3')); // версия 3
|
||||
QVERIFY(QStringLiteral("89ab").contains(a.uuid.at(16))); // вариант RFC 4122
|
||||
QVERIFY(AuthService::offline(nick + u'x').uuid != a.uuid);
|
||||
}
|
||||
|
||||
void clientTokenIsRandomUuid()
|
||||
{
|
||||
const QString a = AuthService::generateClientToken();
|
||||
const QString b = AuthService::generateClientToken();
|
||||
QVERIFY(a != b);
|
||||
QVERIFY(!QUuid::fromString(a).isNull());
|
||||
QVERIFY(!a.startsWith(u'{'));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestAuthOffline)
|
||||
#include "tst_authoffline.moc"
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "buildarchiveworker.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestBuildArchiveWorker : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
QString m_work;
|
||||
|
||||
void populateGame()
|
||||
{
|
||||
Fixtures::write(m_game + "/options.txt", "fov:90");
|
||||
Fixtures::write(m_game + "/mods/a.jar", "mod-a");
|
||||
Fixtures::write(m_game + "/mods/sub/b.jar", "mod-b");
|
||||
Fixtures::write(m_game + "/config/x/y.toml", "y=1");
|
||||
Fixtures::write(m_game + "/saves/World/level.dat", "level"); // общее — исключается
|
||||
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}"); // общее — исключается
|
||||
}
|
||||
|
||||
// Последний вызов finished(): (ok, error)
|
||||
static QPair<bool, QString> result(const QSignalSpy &spy)
|
||||
{
|
||||
if (spy.isEmpty())
|
||||
return {false, QStringLiteral("finished не пришёл")};
|
||||
return {spy.constLast().at(0).toBool(), spy.constLast().at(1).toString()};
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init()
|
||||
{
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
m_work = TestEnv::freshDir(QStringLiteral("archives"));
|
||||
}
|
||||
|
||||
void archiveClearRestoreRoundTrip()
|
||||
{
|
||||
populateGame();
|
||||
const QStringList shared{"saves", "versions"};
|
||||
const QString zip = m_work + "/build.zip";
|
||||
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
QSignalSpy progress(&worker, &BuildArchiveWorker::progress);
|
||||
|
||||
worker.archive(m_game, zip, shared);
|
||||
QCOMPARE(result(finished), qMakePair(true, QString()));
|
||||
QCOMPARE(Fixtures::zipEntries(zip),
|
||||
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar", "options.txt"}));
|
||||
// progress: (0,total) в начале и (total,total) в конце.
|
||||
QCOMPARE(progress.constFirst().at(0).toInt(), 0);
|
||||
QCOMPARE(progress.constLast().at(0).toInt(), 4);
|
||||
QCOMPARE(progress.constLast().at(1).toInt(), 4);
|
||||
|
||||
worker.clear(m_game, shared);
|
||||
QCOMPARE(result(finished), qMakePair(true, QString()));
|
||||
QCOMPARE(Fixtures::listTree(m_game),
|
||||
QStringList({"saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
|
||||
|
||||
worker.restore(zip, m_game);
|
||||
QCOMPARE(result(finished), qMakePair(true, QString()));
|
||||
QCOMPARE(Fixtures::listTree(m_game),
|
||||
QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar",
|
||||
"options.txt", "saves/World/level.dat", "versions/1.20.1/1.20.1.json"}));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/sub/b.jar"), QByteArray("mod-b"));
|
||||
}
|
||||
|
||||
void dotFilesSurviveRoundTrip()
|
||||
{
|
||||
// В .minecraft живут .fabric/, .mixin.out/, на macOS ещё .DS_Store.
|
||||
Fixtures::write(m_game + "/.fabric/remappedJars/x.jar", "cache");
|
||||
Fixtures::write(m_game + "/mods/a.jar", "a");
|
||||
const QString zip = m_work + "/dots.zip";
|
||||
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.archive(m_game, zip, {});
|
||||
QVERIFY(result(finished).first);
|
||||
worker.clear(m_game, {});
|
||||
worker.restore(zip, m_game);
|
||||
QVERIFY(result(finished).first);
|
||||
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
|
||||
|
||||
// QZipReader при чтении срезает ведущие «.» и «/» в именах записей, поэтому
|
||||
// после переключения сборок .fabric/ возвращается как fabric/.
|
||||
QEXPECT_FAIL("", "QZipReader срезает ведущую точку: .fabric/ восстанавливается как fabric/", Continue);
|
||||
QVERIFY(QFile::exists(m_game + "/.fabric/remappedJars/x.jar"));
|
||||
}
|
||||
|
||||
void archiveMissingGameDir()
|
||||
{
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
const QString missing = m_game + "/nope";
|
||||
worker.archive(missing, m_work + "/x.zip", {});
|
||||
QCOMPARE(result(finished),
|
||||
qMakePair(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(missing))));
|
||||
}
|
||||
|
||||
void archiveCancelRemovesTempZip()
|
||||
{
|
||||
populateGame();
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.requestCancel();
|
||||
worker.archive(m_game, m_work + "/cancel.zip", {});
|
||||
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.archivingCancelled")));
|
||||
QVERIFY(!QFile::exists(m_work + "/cancel.zip"));
|
||||
|
||||
worker.clearCancel();
|
||||
worker.archive(m_game, m_work + "/ok.zip", {});
|
||||
QVERIFY(result(finished).first);
|
||||
}
|
||||
|
||||
void clearMissingDirIsOk()
|
||||
{
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.clear(m_game + "/nope", {});
|
||||
QVERIFY(result(finished).first);
|
||||
}
|
||||
|
||||
void restoreRejectsUnreadableAndUnsafeArchives()
|
||||
{
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
|
||||
const QString missing = m_work + "/missing.zip";
|
||||
worker.restore(missing, m_game);
|
||||
QCOMPARE(result(finished),
|
||||
qMakePair(false, Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(missing))));
|
||||
|
||||
// Zip-slip: запись с «..» не должна вылезти из папки игры.
|
||||
const QString evil = m_work + "/evil.zip";
|
||||
// Ведущие «../» QZipReader срезает сам, а «..» в середине пути — нет.
|
||||
QVERIFY(Fixtures::makeZip(evil, {{"ok.txt", "ok"}, {"mods/../../escaped.txt", "pwned"}}));
|
||||
QVERIFY(Fixtures::zipEntries(evil).contains("mods/../../escaped.txt"));
|
||||
worker.restore(evil, m_game);
|
||||
QCOMPARE(result(finished),
|
||||
qMakePair(false, Loc::text("switch.error.archiveBadPath").arg("mods/../../escaped.txt")));
|
||||
QVERIFY(!QFile::exists(QFileInfo(m_game).absolutePath() + "/escaped.txt"));
|
||||
}
|
||||
|
||||
void restoreCancel()
|
||||
{
|
||||
const QString zip = m_work + "/r.zip";
|
||||
QVERIFY(Fixtures::makeZip(zip, {{"a.txt", "a"}}));
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.requestCancel();
|
||||
worker.restore(zip, m_game);
|
||||
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.extractCancelled")));
|
||||
}
|
||||
|
||||
void applyPackRemovesThenWrites()
|
||||
{
|
||||
populateGame();
|
||||
Fixtures::write(m_game + "/mods/old/legacy.jar", "old");
|
||||
Fixtures::write(m_game + "/mods/keep.jar", "keep");
|
||||
|
||||
const QString pack = m_work + "/pack.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"},
|
||||
{"config/x/y.toml", "y=2"},
|
||||
{"options.txt", "fov:70"}}));
|
||||
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
QSignalSpy entries(&worker, &BuildArchiveWorker::packEntries);
|
||||
worker.applyPack(pack, m_game,
|
||||
{"mods/old/legacy.jar", "mods/missing.jar",
|
||||
"../outside.txt", "versions/1.20.1/1.20.1.json"},
|
||||
{"versions", "saves"});
|
||||
QCOMPARE(result(finished), qMakePair(true, QString()));
|
||||
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/old/legacy.jar"));
|
||||
QVERIFY(!QDir(m_game + "/mods/old").exists()); // опустевшая папка убрана
|
||||
QVERIFY(QFile::exists(m_game + "/mods/keep.jar"));
|
||||
// Удаление в запретной зоне молча пропускается.
|
||||
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/config/x/y.toml"), QByteArray("y=2"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("fov:70"));
|
||||
|
||||
QCOMPARE(entries.count(), 1);
|
||||
QStringList applied = entries.constFirst().constFirst().toStringList();
|
||||
applied.sort();
|
||||
QCOMPARE(applied, QStringList({"config/x/y.toml", "mods/new.jar", "options.txt"}));
|
||||
}
|
||||
|
||||
void applyPackRefusesLauncherFolders()
|
||||
{
|
||||
populateGame();
|
||||
const QString pack = m_work + "/bad.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"versions/hack/hack.json", "{}"}}));
|
||||
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.applyPack(pack, m_game, {"mods/a.jar"}, {"versions"});
|
||||
QCOMPARE(result(finished),
|
||||
qMakePair(false, Loc::text("switch.error.packTouchesLauncher").arg("versions/hack/hack.json")));
|
||||
// Проверка до любых изменений: ничего не удалено и не записано.
|
||||
QVERIFY(QFile::exists(m_game + "/mods/a.jar"));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/new.jar"));
|
||||
}
|
||||
|
||||
void applyPackRefusesUnsafePaths()
|
||||
{
|
||||
const QString pack = m_work + "/slip.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"mods/../../../etc/x", "x"}}));
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.applyPack(pack, m_game, {}, {});
|
||||
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.packBadPath").arg("mods/../../../etc/x")));
|
||||
}
|
||||
|
||||
void applyPackCancel()
|
||||
{
|
||||
const QString pack = m_work + "/c.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"a.txt", "a"}}));
|
||||
BuildArchiveWorker worker;
|
||||
QSignalSpy finished(&worker, &BuildArchiveWorker::finished);
|
||||
worker.requestCancel();
|
||||
worker.applyPack(pack, m_game, {}, {});
|
||||
QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.installCancelled")));
|
||||
QVERIFY(!QFile::exists(m_game + "/a.txt"));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestBuildArchiveWorker)
|
||||
#include "tst_buildarchiveworker.moc"
|
||||
@@ -0,0 +1,302 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "buildswitcher.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestBuildSwitcher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
|
||||
static QJsonObject index()
|
||||
{
|
||||
return Fixtures::readJsonFile(LauncherPaths::buildStorageDir() + "/index.json");
|
||||
}
|
||||
|
||||
static QStringList zipsOf(int buildId)
|
||||
{
|
||||
return QDir(LauncherPaths::buildDir(buildId)).entryList({"*.zip"}, QDir::Files | QDir::Hidden);
|
||||
}
|
||||
|
||||
// Ждёт finished или failed; возвращает true при finished(expectedId).
|
||||
static bool waitDone(QSignalSpy &finished, QSignalSpy &failed, int expectedId)
|
||||
{
|
||||
if (!QTest::qWaitFor([&] { return finished.count() + failed.count() > 0; }, 20000)) {
|
||||
qWarning("ни finished, ни failed за 20 с");
|
||||
return false;
|
||||
}
|
||||
if (!failed.isEmpty()) {
|
||||
qWarning("failed: %s", qPrintable(failed.constFirst().at(1).toString()));
|
||||
return false;
|
||||
}
|
||||
return finished.constFirst().constFirst().toInt() == expectedId;
|
||||
}
|
||||
|
||||
void writeShared()
|
||||
{
|
||||
Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}");
|
||||
Fixtures::write(m_game + "/libraries/x/y.jar", "lib");
|
||||
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
|
||||
Fixtures::write(m_game + "/launcher_profiles.json", "{}");
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void sharedTopLevelIsStable()
|
||||
{
|
||||
QStringList shared = BuildSwitcher::sharedTopLevel();
|
||||
shared.sort();
|
||||
QCOMPARE(shared, QStringList({"assets", "launcher_profiles.json", "libraries", "runtime", "versions"}));
|
||||
}
|
||||
|
||||
void switchBackAndForthKeepsEachBuildContent()
|
||||
{
|
||||
writeShared();
|
||||
Fixtures::write(m_game + "/mods/alpha.jar", "alpha");
|
||||
Fixtures::write(m_game + "/saves/A/level.dat", "A");
|
||||
Fixtures::write(m_game + "/options.txt", "alpha-options");
|
||||
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
|
||||
// 1 → 2: у сборки 2 архива ещё нет, папка игры очищается до общих файлов.
|
||||
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
|
||||
QVERIFY(switcher.isRunning());
|
||||
QVERIFY(waitDone(finished, failed, 2));
|
||||
QVERIFY(!switcher.isRunning());
|
||||
QCOMPARE(Fixtures::listTree(m_game),
|
||||
QStringList({"assets/indexes/5.json", "launcher_profiles.json",
|
||||
"libraries/x/y.jar", "versions/1.20.1/1.20.1.json"}));
|
||||
QVERIFY(switcher.hasArchive(1));
|
||||
QVERIFY(!switcher.hasArchive(2));
|
||||
const QStringList zips = zipsOf(1);
|
||||
QCOMPARE(zips.size(), 1);
|
||||
QVERIFY(zips.constFirst().endsWith("_Alpha_build.zip")); // имя из названия сборки
|
||||
|
||||
QJsonObject idx = index();
|
||||
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
|
||||
QCOMPARE(idx.value("active").toInt(), 2);
|
||||
QCOMPARE(idx.value("builds").toObject().value("1").toObject().value("name").toString(),
|
||||
QStringLiteral("Alpha build"));
|
||||
|
||||
// Наполняем сборку 2 и возвращаемся в 1.
|
||||
Fixtures::write(m_game + "/mods/beta.jar", "beta");
|
||||
finished.clear();
|
||||
switcher.switchTo(2, QStringLiteral("Beta"), 1, QStringLiteral("Alpha build"), m_game);
|
||||
QVERIFY(waitDone(finished, failed, 1));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/alpha.jar"), QByteArray("alpha"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/saves/A/level.dat"), QByteArray("A"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("alpha-options"));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/beta.jar"));
|
||||
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); // общее не тронуто
|
||||
QVERIFY(switcher.hasArchive(2));
|
||||
|
||||
// И снова в 2 — его моды на месте.
|
||||
finished.clear();
|
||||
switcher.switchTo(1, QStringLiteral("Alpha build"), 2, QStringLiteral("Beta"), m_game);
|
||||
QVERIFY(waitDone(finished, failed, 2));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/beta.jar"), QByteArray("beta"));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/alpha.jar"));
|
||||
// У сборки хранится один актуальный архив, временных не остаётся.
|
||||
QCOMPARE(zipsOf(1).size(), 1);
|
||||
QVERIFY(zipsOf(1).filter(".tmp-").isEmpty());
|
||||
}
|
||||
|
||||
void switchWithoutSourceBuildOnlyClears()
|
||||
{
|
||||
writeShared();
|
||||
Fixtures::write(m_game + "/mods/stray.jar", "x");
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
switcher.switchTo(-1, QString(), 5, QStringLiteral("Five"), m_game);
|
||||
QVERIFY(waitDone(finished, failed, 5));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/stray.jar"));
|
||||
QVERIFY(QFile::exists(m_game + "/libraries/x/y.jar"));
|
||||
QVERIFY(QDir(LauncherPaths::buildStorageDir()).entryList({"*.zip"}).isEmpty());
|
||||
}
|
||||
|
||||
void secondSwitchWhileBusyIsRejected()
|
||||
{
|
||||
writeShared();
|
||||
for (int i = 0; i < 50; ++i)
|
||||
Fixtures::write(m_game + QStringLiteral("/mods/m%1.jar").arg(i), QByteArray(4096, 'x'));
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
switcher.switchTo(1, "A", 2, "B", m_game);
|
||||
switcher.switchTo(1, "A", 3, "C", m_game);
|
||||
QCOMPARE(failed.count(), 1);
|
||||
QCOMPARE(failed.constFirst().at(0).toInt(), 3);
|
||||
QCOMPARE(failed.constFirst().at(1).toString(), Loc::text("common.error.switchInProgress"));
|
||||
QCOMPARE(failed.constFirst().at(2).toBool(), true);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
|
||||
}
|
||||
|
||||
void interruptedSwitchIsReportedAndResumed()
|
||||
{
|
||||
writeShared();
|
||||
Fixtures::write(m_game + "/mods/half-cleared.jar", "x");
|
||||
// Архив сборки 7 записан, переключение 3 → 7 оборвалось посреди очистки.
|
||||
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(7) + "/2026-01-01_Seven.zip",
|
||||
{{"mods/seven.jar", "seven"}, {"options.txt", "seven-options"}}));
|
||||
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
|
||||
QJsonObject{{"state", "switching"}, {"from", 3}, {"to", 7},
|
||||
{"builds", QJsonObject{{"7", QJsonObject{{"name", "Seven"},
|
||||
{"archive", "2026-01-01_Seven.zip"}}}}}});
|
||||
// Мусор от прерванной архивации подметается конструктором.
|
||||
Fixtures::write(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip", "partial");
|
||||
|
||||
BuildSwitcher switcher;
|
||||
QVERIFY(!QFile::exists(LauncherPaths::buildDir(3) + "/.tmp-deadbeef.zip"));
|
||||
const QString warning = switcher.interruptedSwitchWarning();
|
||||
QCOMPARE(warning, Loc::text("switch.status.switchInterrupted")
|
||||
.arg(Loc::text("switch.status.targetSuffix").arg("Seven")));
|
||||
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
switcher.resumeInterrupted(m_game);
|
||||
QVERIFY(waitDone(finished, failed, 7));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/half-cleared.jar"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/seven.jar"), QByteArray("seven"));
|
||||
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
|
||||
QVERIFY(switcher.interruptedSwitchWarning().isEmpty());
|
||||
}
|
||||
|
||||
void applyPackAndRecordNote()
|
||||
{
|
||||
writeShared();
|
||||
Fixtures::write(m_game + "/mods/old.jar", "old");
|
||||
const QString pack = LauncherPaths::buildDir(9) + "/season-pack.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"config/c.toml", "c"}}));
|
||||
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
const QJsonObject note{{"seasonalId", "season-5"}, {"revision", 3}};
|
||||
switcher.applyPack(9, QStringLiteral("Season 5"), pack, {"mods/old.jar"}, note, m_game);
|
||||
QVERIFY(waitDone(finished, failed, 9));
|
||||
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/old.jar"));
|
||||
QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new"));
|
||||
QVERIFY(!QFile::exists(pack)); // пакет удаляется после установки
|
||||
QStringList entries = switcher.lastPackEntries();
|
||||
entries.sort();
|
||||
QCOMPARE(entries, QStringList({"config/c.toml", "mods/new.jar"}));
|
||||
QCOMPARE(switcher.lastPackNote(), note);
|
||||
const QJsonObject idx = index();
|
||||
QCOMPARE(idx.value("state").toString(), QStringLiteral("idle"));
|
||||
QCOMPARE(idx.value("active").toInt(), 9);
|
||||
QVERIFY(!idx.contains("pack"));
|
||||
}
|
||||
|
||||
void applyPackErrors()
|
||||
{
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
const QString missing = LauncherPaths::buildDir(4) + "/nope.zip";
|
||||
switcher.applyPack(4, "Four", missing, {}, {}, m_game);
|
||||
QCOMPARE(failed.count(), 1);
|
||||
QCOMPARE(failed.constFirst().at(1).toString(),
|
||||
Loc::text("switch.error.archiveMissing").arg(QDir::toNativeSeparators(missing)));
|
||||
|
||||
// Пакет лезет в общие папки лаунчера — отказ, папка игры цела.
|
||||
const QString bad = LauncherPaths::buildDir(4) + "/bad.zip";
|
||||
QVERIFY(Fixtures::makeZip(bad, {{"versions/evil/evil.json", "{}"}}));
|
||||
failed.clear();
|
||||
switcher.applyPack(4, "Four", bad, {}, {}, m_game);
|
||||
QTRY_COMPARE_WITH_TIMEOUT(failed.count(), 1, 20000);
|
||||
QCOMPARE(failed.constFirst().at(1).toString(),
|
||||
Loc::text("switch.error.packTouchesLauncher").arg("versions/evil/evil.json"));
|
||||
QCOMPARE(failed.constFirst().at(2).toBool(), true);
|
||||
}
|
||||
|
||||
void interruptedApplyIsResumedOrCleaned()
|
||||
{
|
||||
writeShared();
|
||||
const QString pack = LauncherPaths::buildDir(11) + "/pack.zip";
|
||||
QVERIFY(Fixtures::makeZip(pack, {{"mods/resumed.jar", "r"}}));
|
||||
const QJsonObject note{{"k", "v"}};
|
||||
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
|
||||
QJsonObject{{"state", "applying"}, {"to", 11}, {"pack", "pack.zip"},
|
||||
{"packName", "Eleven"}, {"packRemove", QJsonArray{"mods/gone.jar"}},
|
||||
{"packNote", note}});
|
||||
Fixtures::write(m_game + "/mods/gone.jar", "g");
|
||||
|
||||
{
|
||||
BuildSwitcher switcher;
|
||||
QCOMPARE(switcher.interruptedSwitchWarning(),
|
||||
Loc::text("switch.status.applyInterrupted")
|
||||
.arg(Loc::text("switch.status.nameSuffix").arg("Eleven")));
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
switcher.resumeInterrupted(m_game);
|
||||
QVERIFY(waitDone(finished, failed, 11));
|
||||
QVERIFY(QFile::exists(m_game + "/mods/resumed.jar"));
|
||||
QVERIFY(!QFile::exists(m_game + "/mods/gone.jar"));
|
||||
QCOMPARE(switcher.lastPackNote(), note);
|
||||
}
|
||||
|
||||
// Пакет пропал — индекс просто приводится в порядок, без сигналов.
|
||||
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
|
||||
QJsonObject{{"state", "applying"}, {"to", 12}, {"pack", "lost.zip"}});
|
||||
BuildSwitcher switcher;
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy failed(&switcher, &BuildSwitcher::failed);
|
||||
switcher.resumeInterrupted(m_game);
|
||||
QCOMPARE(index().value("state").toString(), QStringLiteral("idle"));
|
||||
QVERIFY(!index().contains("pack"));
|
||||
QCOMPARE(finished.count() + failed.count(), 0);
|
||||
}
|
||||
|
||||
void forgetBuild()
|
||||
{
|
||||
QVERIFY(Fixtures::makeZip(LauncherPaths::buildDir(5) + "/a.zip", {{"x", "y"}}));
|
||||
Fixtures::writeJson(LauncherPaths::buildStorageDir() + "/index.json",
|
||||
QJsonObject{{"state", "idle"}, {"active", 5},
|
||||
{"builds", QJsonObject{{"5", QJsonObject{{"name", "Five"}}},
|
||||
{"6", QJsonObject{{"name", "Six"}}}}}});
|
||||
BuildSwitcher switcher;
|
||||
QVERIFY(switcher.hasArchive(5));
|
||||
QVERIFY(switcher.forgetBuild(5));
|
||||
QVERIFY(!QDir(LauncherPaths::buildDir(5)).exists());
|
||||
QVERIFY(!switcher.hasArchive(5));
|
||||
const QJsonObject idx = index();
|
||||
QVERIFY(!idx.contains("active"));
|
||||
QVERIFY(!idx.value("builds").toObject().contains("5"));
|
||||
QVERIFY(idx.value("builds").toObject().contains("6"));
|
||||
QVERIFY(switcher.forgetBuild(-1));
|
||||
}
|
||||
|
||||
void fractionAndStage()
|
||||
{
|
||||
BuildSwitcher switcher;
|
||||
QCOMPARE(switcher.fraction(), -1.0);
|
||||
QVERIFY(switcher.stage().isEmpty());
|
||||
writeShared();
|
||||
Fixtures::write(m_game + "/mods/a.jar", "a");
|
||||
QSignalSpy finished(&switcher, &BuildSwitcher::finished);
|
||||
QSignalSpy progress(&switcher, &BuildSwitcher::progressChanged);
|
||||
switcher.switchTo(1, "A", 2, "B", m_game);
|
||||
QCOMPARE(switcher.stage(), Loc::text("switch.progress.saving").arg("A"));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 20000);
|
||||
QVERIFY(progress.count() > 2);
|
||||
QCOMPARE(switcher.fraction(), -1.0);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestBuildSwitcher)
|
||||
#include "tst_buildswitcher.moc"
|
||||
@@ -0,0 +1,243 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "gamedirimport.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
|
||||
class TestGameDirImport : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
|
||||
void writeVersion(const QString &id, const QJsonObject &json)
|
||||
{
|
||||
QVERIFY(Fixtures::writeJson(m_game + "/versions/" + id + "/" + id + ".json", json));
|
||||
}
|
||||
|
||||
void writeVanilla(const QString &id) { writeVersion(id, {{"id", id}, {"type", "release"}}); }
|
||||
|
||||
void writeLoaderProfile(const QString &id, const QString &game, const QString &library)
|
||||
{
|
||||
QJsonArray libraries;
|
||||
if (!library.isEmpty())
|
||||
libraries.append(QJsonObject{{"name", library}});
|
||||
writeVersion(id, {{"id", id}, {"inheritsFrom", game}, {"libraries", libraries}});
|
||||
}
|
||||
|
||||
void writeProfiles(const QJsonObject &profiles)
|
||||
{
|
||||
QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", profiles}}));
|
||||
}
|
||||
|
||||
static QJsonObject profile(const QString &versionId, const QString &lastUsed)
|
||||
{
|
||||
return {{"type", "custom"}, {"lastVersionId", versionId}, {"lastUsed", lastUsed}};
|
||||
}
|
||||
|
||||
// Мтайм ставим явно: файлы, записанные подряд, легко получают одну секунду.
|
||||
void touch(const QString &id, int secondsAgo)
|
||||
{
|
||||
QFile file(m_game + "/versions/" + id + "/" + id + ".json");
|
||||
QVERIFY(file.open(QIODevice::ReadWrite));
|
||||
QVERIFY(file.setFileTime(QDateTime::currentDateTime().addSecs(-secondsAgo),
|
||||
QFileDevice::FileModificationTime));
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); }
|
||||
|
||||
void missingOrEmptyFolderGivesNothing()
|
||||
{
|
||||
QVERIFY(!GameDirImport::detect(m_game + "/nope"));
|
||||
QVERIFY(!GameDirImport::detect(m_game));
|
||||
QVERIFY(!GameDirImport::detect(QString()));
|
||||
}
|
||||
|
||||
void sharedFoldersAndLauncherJunkAreNotABuild()
|
||||
{
|
||||
Fixtures::write(m_game + "/libraries/x/y.jar", "lib");
|
||||
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
|
||||
Fixtures::write(m_game + "/launcher_log.txt", "log");
|
||||
Fixtures::write(m_game + "/webcache2/a", "a");
|
||||
QVERIFY(Fixtures::writeJson(m_game + "/launcher_profiles.json", {{"profiles", QJsonObject{}}}));
|
||||
QVERIFY(QDir().mkpath(m_game + "/saves")); // пустая папка — не признак
|
||||
QVERIFY(!GameDirImport::detect(m_game));
|
||||
}
|
||||
|
||||
void contentWithoutVersionGivesEmptyBuild()
|
||||
{
|
||||
Fixtures::write(m_game + "/saves/World/level.dat", "w");
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->source, QStringLiteral("content"));
|
||||
QVERIFY(found->minecraftVersion.isEmpty());
|
||||
QVERIFY(found->resolvedVersionId.isEmpty());
|
||||
QVERIFY(found->loader.isEmpty());
|
||||
}
|
||||
|
||||
void lastUsedProfileWins()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
writeVanilla("1.21.1");
|
||||
writeVanilla("1.19.4");
|
||||
touch("1.19.4", 0); // самая свежая по мтайму, но профили важнее
|
||||
writeProfiles({{"a", profile("1.20.1", "2026-01-01T10:00:00.000Z")},
|
||||
{"b", profile("1.21.1", "2026-03-01T10:00:00.000Z")},
|
||||
// Версии нет на диске — пропускается, даже будучи последней.
|
||||
{"c", profile("1.8.9", "2026-05-01T10:00:00.000Z")},
|
||||
// latest-release без lastVersionId не разрешить без манифеста.
|
||||
{"d", QJsonObject{{"type", "latest-release"}, {"lastUsed", "2026-06-01T10:00:00Z"}}}});
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->source, QStringLiteral("launcher_profiles"));
|
||||
QCOMPARE(found->minecraftVersion, QStringLiteral("1.21.1"));
|
||||
QVERIFY(found->resolvedVersionId.isEmpty());
|
||||
QVERIFY(found->loader.isEmpty());
|
||||
}
|
||||
|
||||
void withoutProfilesNewestVersionWins()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
writeVanilla("1.21.1");
|
||||
touch("1.20.1", 0);
|
||||
touch("1.21.1", 3600);
|
||||
Fixtures::write(m_game + "/launcher_profiles.json", "{ broken");
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->source, QStringLiteral("versions"));
|
||||
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||
}
|
||||
|
||||
void brokenVersionJsonIsSkipped()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
Fixtures::write(m_game + "/versions/1.21.1/1.21.1.json", "not json");
|
||||
touch("1.20.1", 3600);
|
||||
touch("1.21.1", 0);
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||
}
|
||||
|
||||
void modsPreferLoaderProfile()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0");
|
||||
// Последней запускали ванилу, но моды лежат — значит, играли с Fabric.
|
||||
writeProfiles({{"vanilla", profile("1.20.1", "2026-03-01T10:00:00.000Z")}});
|
||||
Fixtures::write(m_game + "/mods/sodium.jar", "jar");
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->source, QStringLiteral("versions"));
|
||||
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||
QCOMPARE(found->resolvedVersionId, QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||
QCOMPARE(found->loader, QStringLiteral("fabric"));
|
||||
QCOMPARE(found->loaderVersion, QStringLiteral("0.15.0"));
|
||||
|
||||
// Без модов тот же набор даёт ванилу из профилей.
|
||||
QVERIFY(QFile::remove(m_game + "/mods/sodium.jar"));
|
||||
QCOMPARE(GameDirImport::detect(m_game)->resolvedVersionId, QString());
|
||||
}
|
||||
|
||||
void fabricFixtureIsRecognised()
|
||||
{
|
||||
const QJsonObject json = Fixtures::readJson("versions/fabric-loader-0.15.0-1.20.1.json");
|
||||
const auto loader = GameDirImport::loaderOf(json);
|
||||
QCOMPARE(loader.key, QStringLiteral("fabric"));
|
||||
QCOMPARE(loader.version, QStringLiteral("0.15.0"));
|
||||
}
|
||||
|
||||
void loaderFromLibraries_data()
|
||||
{
|
||||
QTest::addColumn<QString>("game");
|
||||
QTest::addColumn<QString>("library");
|
||||
QTest::addColumn<QString>("key");
|
||||
QTest::addColumn<QString>("version");
|
||||
|
||||
QTest::newRow("quilt") << "1.20.1" << "org.quiltmc:quilt-loader:0.26.0" << "quilt" << "0.26.0";
|
||||
QTest::newRow("forge-1.20.1") << "1.20.1" << "net.minecraftforge:forge:1.20.1-47.2.0:universal"
|
||||
<< "forge" << "47.2.0";
|
||||
QTest::newRow("forge-1.12.2") << "1.12.2" << "net.minecraftforge:forge:1.12.2-14.23.5.2860"
|
||||
<< "forge" << "14.23.5.2860";
|
||||
// Как в каталоге модлоадеров: хвост "-1.7.10" остаётся.
|
||||
QTest::newRow("forge-1.7.10") << "1.7.10" << "net.minecraftforge:forge:1.7.10-10.13.4.1614-1.7.10"
|
||||
<< "forge" << "10.13.4.1614-1.7.10";
|
||||
QTest::newRow("neoforge") << "1.21.1" << "net.neoforged:neoforge:21.1.77:universal"
|
||||
<< "neoforge" << "21.1.77";
|
||||
QTest::newRow("neoforge-1.20.1") << "1.20.1" << "net.neoforged:forge:1.20.1-47.1.106:universal"
|
||||
<< "neoforge" << "47.1.106";
|
||||
QTest::newRow("optifine") << "1.20.1" << "optifine:OptiFine:1.20.1_HD_U_I6" << "" << "";
|
||||
}
|
||||
|
||||
void loaderFromLibraries()
|
||||
{
|
||||
QFETCH(QString, game);
|
||||
QFETCH(QString, library);
|
||||
QFETCH(QString, key);
|
||||
QFETCH(QString, version);
|
||||
const QJsonObject json{{"id", "custom"}, {"inheritsFrom", game},
|
||||
{"libraries", QJsonArray{QJsonObject{{"name", "com.google:guava:31.0"}},
|
||||
QJsonObject{{"name", library}}}}};
|
||||
const auto loader = GameDirImport::loaderOf(json);
|
||||
QCOMPARE(loader.key, key);
|
||||
QCOMPARE(loader.version, version);
|
||||
}
|
||||
|
||||
void loaderFromIdWhenLibrariesSilent_data()
|
||||
{
|
||||
QTest::addColumn<QString>("id");
|
||||
QTest::addColumn<QString>("game");
|
||||
QTest::addColumn<QString>("key");
|
||||
QTest::addColumn<QString>("version");
|
||||
|
||||
QTest::newRow("fabric") << "fabric-loader-0.16.10-1.21.1" << "1.21.1" << "fabric" << "0.16.10";
|
||||
QTest::newRow("quilt") << "quilt-loader-0.26.0-1.20.1" << "1.20.1" << "quilt" << "0.26.0";
|
||||
QTest::newRow("forge") << "1.20.1-forge-47.2.0" << "1.20.1" << "forge" << "47.2.0";
|
||||
QTest::newRow("neoforge") << "neoforge-21.1.77" << "1.21.1" << "neoforge" << "21.1.77";
|
||||
QTest::newRow("unknown") << "1.20.1-OptiFine_HD_U_I6" << "1.20.1" << "" << "";
|
||||
}
|
||||
|
||||
void loaderFromIdWhenLibrariesSilent()
|
||||
{
|
||||
QFETCH(QString, id);
|
||||
QFETCH(QString, game);
|
||||
QFETCH(QString, key);
|
||||
QFETCH(QString, version);
|
||||
const auto loader = GameDirImport::loaderOf({{"id", id}, {"inheritsFrom", game}});
|
||||
QCOMPARE(loader.key, key);
|
||||
QCOMPARE(loader.version, version);
|
||||
}
|
||||
|
||||
void unknownInheritorKeepsResolvedId()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
writeLoaderProfile("1.20.1-OptiFine_HD_U_I6", "1.20.1", "optifine:OptiFine:1.20.1_HD_U_I6");
|
||||
writeProfiles({{"of", profile("1.20.1-OptiFine_HD_U_I6", "2026-03-01T10:00:00Z")}});
|
||||
const auto found = GameDirImport::detect(m_game);
|
||||
QVERIFY(found);
|
||||
QCOMPARE(found->minecraftVersion, QStringLiteral("1.20.1"));
|
||||
QCOMPARE(found->resolvedVersionId, QStringLiteral("1.20.1-OptiFine_HD_U_I6"));
|
||||
QVERIFY(found->loader.isEmpty());
|
||||
}
|
||||
|
||||
void detectionDoesNotTouchGameDir()
|
||||
{
|
||||
writeVanilla("1.20.1");
|
||||
writeLoaderProfile("fabric-loader-0.15.0-1.20.1", "1.20.1", "net.fabricmc:fabric-loader:0.15.0");
|
||||
writeProfiles({{"f", profile("fabric-loader-0.15.0-1.20.1", "2026-03-01T10:00:00Z")}});
|
||||
Fixtures::write(m_game + "/mods/a.jar", "a");
|
||||
Fixtures::write(m_game + "/saves/W/level.dat", "w");
|
||||
const QStringList before = Fixtures::listTree(m_game);
|
||||
QVERIFY(GameDirImport::detect(m_game));
|
||||
QCOMPARE(Fixtures::listTree(m_game), before);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestGameDirImport)
|
||||
|
||||
#include "tst_gamedirimport.moc"
|
||||
@@ -0,0 +1,454 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "gamelauncher.h"
|
||||
#include "javalocator.h"
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSignalSpy>
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(Q_OS_WIN)
|
||||
const QChar kSep = u';';
|
||||
const char *kNativeName = "lwjgl.dll";
|
||||
#elif defined(Q_OS_MACOS)
|
||||
const QChar kSep = u':';
|
||||
const char *kNativeName = "liblwjgl.dylib";
|
||||
#else
|
||||
const QChar kSep = u':';
|
||||
const char *kNativeName = "liblwjgl.so";
|
||||
#endif
|
||||
|
||||
QString nat(const QString &path)
|
||||
{
|
||||
return QDir::toNativeSeparators(path);
|
||||
}
|
||||
|
||||
MinecraftLibrary lib(const QString &name, const QString &path, bool native = false)
|
||||
{
|
||||
MinecraftLibrary library;
|
||||
library.name = name;
|
||||
library.path = path;
|
||||
library.native = native;
|
||||
return library;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestGameLauncher : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
|
||||
MinecraftVersion modernVersion() const
|
||||
{
|
||||
MinecraftVersion v;
|
||||
v.id = QStringLiteral("1.20.1");
|
||||
v.mainClass = QStringLiteral("net.minecraft.client.main.Main");
|
||||
v.type = QStringLiteral("release");
|
||||
v.assetIndexId = QStringLiteral("5");
|
||||
v.assetsKind = QStringLiteral("5");
|
||||
v.clientJarPath = m_game + QStringLiteral("/versions/1.20.1/1.20.1.jar");
|
||||
v.javaMajor = 17;
|
||||
v.jvmArgs = QStringList{"-Djava.library.path=${natives_directory}",
|
||||
"-Dminecraft.launcher.brand=${launcher_name}",
|
||||
"-Dminecraft.launcher.version=${launcher_version}",
|
||||
"-Dunknown=${not_a_variable}",
|
||||
"-cp", "${classpath}"};
|
||||
v.gameArgs = QStringList{"--username", "${auth_player_name}", "--version", "${version_name}",
|
||||
"--gameDir", "${game_directory}", "--assetsDir", "${assets_root}",
|
||||
"--assetIndex", "${assets_index_name}", "--uuid", "${auth_uuid}",
|
||||
"--accessToken", "${auth_access_token}", "--clientId", "${clientid}",
|
||||
"--xuid", "${auth_xuid}", "--userType", "${user_type}",
|
||||
"--versionType", "${version_type}", "--mystery", "${unknown_thing}"};
|
||||
v.libraries << lib("com.mojang:brigadier:1.1.8", "com/mojang/brigadier-1.1.8.jar")
|
||||
<< lib("org.lwjgl:lwjgl:3.3.1", "org/lwjgl/lwjgl-3.3.1.jar")
|
||||
<< lib("org.lwjgl:lwjgl:3.3.1:natives", "org/lwjgl/lwjgl-3.3.1-natives.jar", true);
|
||||
return v;
|
||||
}
|
||||
|
||||
LaunchOptions options() const
|
||||
{
|
||||
LaunchOptions o;
|
||||
o.gameDir = m_game;
|
||||
o.versionId = QStringLiteral("1.20.1");
|
||||
o.playerName = QStringLiteral("Steve");
|
||||
o.uuid = QStringLiteral("0123456789abcdef0123456789abcdef");
|
||||
o.accessToken = QStringLiteral("secret-token");
|
||||
o.userType = QStringLiteral("msa");
|
||||
o.minMemoryMb = 1024;
|
||||
o.maxMemoryMb = 6144;
|
||||
return o;
|
||||
}
|
||||
|
||||
static QString valueAfter(const QStringList &args, const QString &flag)
|
||||
{
|
||||
const qsizetype i = args.indexOf(flag);
|
||||
return (i >= 0 && i + 1 < args.size()) ? args.at(i + 1) : QString();
|
||||
}
|
||||
|
||||
void createGameFiles(const MinecraftVersion &v)
|
||||
{
|
||||
Fixtures::write(v.clientJarPath, "client");
|
||||
Fixtures::write(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar", "lib");
|
||||
Fixtures::write(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar", "lib");
|
||||
Fixtures::makeZip(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar",
|
||||
{{QStringLiteral("META-INF/MANIFEST.MF"), "x"},
|
||||
{QStringLiteral("linux/x64/org/lwjgl/") + kNativeName, "native-bytes"},
|
||||
{QStringLiteral("skip/") + kNativeName + ".sha1", "hash"},
|
||||
// Служебные файлы LWJGL рядом с .so: не должны приниматься
|
||||
// за версионированную библиотеку (libfoo.so.1) ни на одной ОС.
|
||||
{QStringLiteral("skip/liblwjgl.so.sha1"), "hash"},
|
||||
{QStringLiteral("skip/liblwjgl.so.git"), "git"}});
|
||||
Fixtures::write(m_game + "/assets/indexes/5.json", "{}");
|
||||
QDir().mkpath(m_game + "/assets/objects");
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
// --- buildArguments ---------------------------------------------------------
|
||||
|
||||
void substitutesVariables()
|
||||
{
|
||||
const MinecraftVersion v = modernVersion();
|
||||
const QString natives = m_game + "/versions/1.20.1/natives";
|
||||
const QStringList args = GameLauncher::buildArguments(options(), v, natives);
|
||||
|
||||
QCOMPARE(args.at(0), QStringLiteral("-Xms1024M"));
|
||||
QCOMPARE(args.at(1), QStringLiteral("-Xmx6144M"));
|
||||
QVERIFY(args.contains("-Djava.library.path=" + nat(natives)));
|
||||
QVERIFY(args.contains("-Dminecraft.launcher.brand=BiLauncher"));
|
||||
QVERIFY(args.contains(QStringLiteral("-Dminecraft.launcher.version=") + QStringLiteral(LAUNCHER_VERSION)));
|
||||
// Неизвестный плейсхолдер — аргумент выбрасывается целиком.
|
||||
QVERIFY(!args.join(' ').contains("not_a_variable"));
|
||||
QVERIFY(!args.contains("${unknown_thing}"));
|
||||
QVERIFY(args.contains("--mystery")); // сам флаг остаётся, как у официального лаунчера
|
||||
|
||||
// classpath: обычные библиотеки, без нативов, клиентский jar последним.
|
||||
const QStringList cp = valueAfter(args, "-cp").split(kSep);
|
||||
QCOMPARE(cp, QStringList({nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar"),
|
||||
nat(m_game + "/libraries/org/lwjgl/lwjgl-3.3.1.jar"),
|
||||
nat(v.clientJarPath)}));
|
||||
|
||||
// mainClass — после jvm-аргументов, перед игровыми.
|
||||
const qsizetype main = args.indexOf(v.mainClass);
|
||||
QVERIFY(main > args.indexOf("-cp"));
|
||||
QVERIFY(main < args.indexOf("--username"));
|
||||
|
||||
QCOMPARE(valueAfter(args, "--username"), QStringLiteral("Steve"));
|
||||
QCOMPARE(valueAfter(args, "--version"), QStringLiteral("1.20.1"));
|
||||
QCOMPARE(valueAfter(args, "--gameDir"), nat(m_game));
|
||||
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets"));
|
||||
QCOMPARE(valueAfter(args, "--assetIndex"), QStringLiteral("5"));
|
||||
QCOMPARE(valueAfter(args, "--uuid"), QStringLiteral("0123456789abcdef0123456789abcdef"));
|
||||
QCOMPARE(valueAfter(args, "--accessToken"), QStringLiteral("secret-token"));
|
||||
QCOMPARE(valueAfter(args, "--clientId"), QStringLiteral("0")); // пустой → «0»
|
||||
QCOMPARE(valueAfter(args, "--xuid"), QStringLiteral("0"));
|
||||
QCOMPARE(valueAfter(args, "--userType"), QStringLiteral("msa"));
|
||||
QCOMPARE(valueAfter(args, "--versionType"), QStringLiteral("release"));
|
||||
QVERIFY(!args.contains("--width"));
|
||||
QVERIFY(!args.contains("--fullscreen"));
|
||||
QVERIFY(!args.contains("--server"));
|
||||
}
|
||||
|
||||
void legacyAssetsAndSession()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
v.assetsKind = QStringLiteral("legacy");
|
||||
v.gameArgs = QStringList{"--session", "${auth_session}", "--assetsDir", "${game_assets}"};
|
||||
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
|
||||
QCOMPARE(valueAfter(args, "--session"),
|
||||
QStringLiteral("token:secret-token:0123456789abcdef0123456789abcdef"));
|
||||
QCOMPARE(valueAfter(args, "--assetsDir"), nat(m_game + "/assets/virtual/legacy"));
|
||||
|
||||
v.assetsKind = QStringLiteral("pre-1.6");
|
||||
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
|
||||
nat(m_game + "/assets/virtual/legacy"));
|
||||
v.assetsKind = QStringLiteral("5");
|
||||
QCOMPARE(valueAfter(GameLauncher::buildArguments(options(), v, m_game + "/n"), "--assetsDir"),
|
||||
nat(m_game + "/assets"));
|
||||
}
|
||||
|
||||
void memoryAndExtraJvmArgs()
|
||||
{
|
||||
LaunchOptions o = options();
|
||||
o.minMemoryMb = 0;
|
||||
o.maxMemoryMb = 0;
|
||||
o.extraJvmArgs = QStringList{"-XX:+UseG1GC", "-Dfoo=bar"};
|
||||
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
|
||||
QVERIFY(!args.join(' ').contains("-Xms"));
|
||||
QVERIFY(!args.join(' ').contains("-Xmx"));
|
||||
QCOMPARE(args.at(0), QStringLiteral("-XX:+UseG1GC"));
|
||||
QCOMPARE(args.at(1), QStringLiteral("-Dfoo=bar"));
|
||||
}
|
||||
|
||||
void authlibInjectorGoesFirst()
|
||||
{
|
||||
LaunchOptions o = options();
|
||||
o.authlibInjectorPath = m_game + "/authlib-injector.jar";
|
||||
const QStringList args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
|
||||
QCOMPARE(args.at(0), "-javaagent:" + nat(o.authlibInjectorPath) + "=ely.by");
|
||||
QCOMPARE(args.at(1), QStringLiteral("-Dauthlibinjector.side=client"));
|
||||
QCOMPARE(args.at(2), QStringLiteral("-Xms1024M"));
|
||||
}
|
||||
|
||||
void windowSizeAndFullscreen()
|
||||
{
|
||||
LaunchOptions o = options();
|
||||
o.windowWidth = 1280;
|
||||
o.windowHeight = 720;
|
||||
o.fullscreen = true;
|
||||
MinecraftVersion v = modernVersion();
|
||||
QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
|
||||
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
|
||||
QCOMPARE(valueAfter(args, "--height"), QStringLiteral("720"));
|
||||
QCOMPARE(args.count("--fullscreen"), 1);
|
||||
|
||||
// У версии свои аргументы разрешения — лаунчер свои не дублирует.
|
||||
v.hasCustomResolutionArgs = true;
|
||||
v.gameArgs << "--width" << "${resolution_width}" << "--height" << "${resolution_height}";
|
||||
args = GameLauncher::buildArguments(o, v, m_game + "/n");
|
||||
QCOMPARE(args.count("--width"), 1);
|
||||
QCOMPARE(valueAfter(args, "--width"), QStringLiteral("1280"));
|
||||
|
||||
// Только ширина — не передаём ничего.
|
||||
o.windowHeight = 0;
|
||||
o.fullscreen = false;
|
||||
args = GameLauncher::buildArguments(o, modernVersion(), m_game + "/n");
|
||||
QVERIFY(!args.contains("--width"));
|
||||
QVERIFY(!args.contains("--fullscreen"));
|
||||
}
|
||||
|
||||
void serverAutoJoin_data()
|
||||
{
|
||||
QTest::addColumn<bool>("quickPlay");
|
||||
QTest::addColumn<QString>("address");
|
||||
QTest::addColumn<QStringList>("expectedTail");
|
||||
|
||||
QTest::newRow("quickplay") << true << "play.example.com:25570"
|
||||
<< QStringList{"--quickPlayMultiplayer", "play.example.com:25570"};
|
||||
QTest::newRow("legacy with port") << false << "play.example.com:25570"
|
||||
<< QStringList{"--server", "play.example.com", "--port", "25570"};
|
||||
QTest::newRow("legacy default port") << false << "play.example.com"
|
||||
<< QStringList{"--server", "play.example.com", "--port", "25565"};
|
||||
}
|
||||
|
||||
void serverAutoJoin()
|
||||
{
|
||||
QFETCH(bool, quickPlay);
|
||||
QFETCH(QString, address);
|
||||
QFETCH(QStringList, expectedTail);
|
||||
LaunchOptions o = options();
|
||||
o.serverAddress = address;
|
||||
MinecraftVersion v = modernVersion();
|
||||
v.supportsQuickPlay = quickPlay;
|
||||
const QStringList args = GameLauncher::buildArguments(o, v, m_game + "/n");
|
||||
QCOMPARE(args.mid(args.size() - expectedTail.size()), expectedTail);
|
||||
}
|
||||
|
||||
void forgeIgnoreListGetsClientJar()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
v.jvmArgs.prepend("-DignoreList=bootstraplauncher,securejarhandler,${version_name}.jar");
|
||||
QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
|
||||
// ${version_name} = 1.20.1 совпадает с именем jar — дописывать нечего.
|
||||
QCOMPARE(args.filter("-DignoreList=").constFirst(),
|
||||
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1.jar"));
|
||||
|
||||
// Профиль Forge со своим id, а jar родительский — имя jar дописывается.
|
||||
v.id = QStringLiteral("1.20.1-forge-47.4.0");
|
||||
args = GameLauncher::buildArguments(options(), v, m_game + "/n");
|
||||
QCOMPARE(args.filter("-DignoreList=").constFirst(),
|
||||
QStringLiteral("-DignoreList=bootstraplauncher,securejarhandler,1.20.1-forge-47.4.0.jar,1.20.1.jar"));
|
||||
}
|
||||
|
||||
void loggingArgumentOnlyWhenConfigExists()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
v.loggingArgument = QStringLiteral("-Dlog4j.configurationFile=${path}");
|
||||
v.loggingConfigPath = m_game + "/assets/log_configs/client-1.12.xml";
|
||||
QVERIFY(GameLauncher::buildArguments(options(), v, m_game + "/n").filter("log4j").isEmpty());
|
||||
|
||||
Fixtures::write(v.loggingConfigPath, "<xml/>");
|
||||
const QStringList args = GameLauncher::buildArguments(options(), v, m_game + "/n");
|
||||
QCOMPARE(args.filter("log4j"), QStringList{"-Dlog4j.configurationFile=" + nat(v.loggingConfigPath)});
|
||||
QVERIFY(args.indexOf(args.filter("log4j").constFirst()) < args.indexOf(v.mainClass));
|
||||
}
|
||||
|
||||
// --- missingFiles / extractNatives -------------------------------------------
|
||||
|
||||
void missingFiles()
|
||||
{
|
||||
const MinecraftVersion v = modernVersion();
|
||||
LaunchOptions o = options();
|
||||
|
||||
o.gameDir = m_game + "/does-not-exist";
|
||||
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(o.gameDir)});
|
||||
|
||||
o = options();
|
||||
QStringList missing = GameLauncher::missingFiles(o, v);
|
||||
QVERIFY(missing.contains(nat(v.clientJarPath)));
|
||||
QVERIFY(missing.contains(nat(m_game + "/libraries/com/mojang/brigadier-1.1.8.jar")));
|
||||
QVERIFY(missing.contains(nat(m_game + "/assets/indexes/5.json")));
|
||||
QCOMPARE(GameLauncher::missingFiles(o, v, 2).size(), 2);
|
||||
|
||||
createGameFiles(v);
|
||||
QVERIFY(GameLauncher::missingFiles(o, v).isEmpty());
|
||||
|
||||
QDir(m_game + "/assets/objects").removeRecursively();
|
||||
QCOMPARE(GameLauncher::missingFiles(o, v), QStringList{nat(m_game + "/assets/objects")});
|
||||
}
|
||||
|
||||
void extractNatives()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
createGameFiles(v);
|
||||
const QString natives = m_game + "/versions/1.20.1/natives";
|
||||
|
||||
QString error;
|
||||
QVERIFY2(GameLauncher::extractNatives(options(), v, natives, &error), qPrintable(error));
|
||||
// Только сама библиотека: без META-INF и без .sha1, в плоскую папку.
|
||||
QCOMPARE(Fixtures::listTree(natives), QStringList{QString::fromLatin1(kNativeName)});
|
||||
QCOMPARE(Fixtures::readFile(natives + u'/' + kNativeName), QByteArray("native-bytes"));
|
||||
|
||||
// Повторный запуск поверх готовой папки — успех.
|
||||
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
|
||||
|
||||
// extractExclude по префиксу пути внутри архива.
|
||||
QDir(natives).removeRecursively();
|
||||
v.libraries[2].extractExclude = QStringList{"linux/"};
|
||||
QVERIFY(GameLauncher::extractNatives(options(), v, natives, &error));
|
||||
QVERIFY(Fixtures::listTree(natives).isEmpty());
|
||||
|
||||
// Архива нет вовсе.
|
||||
const QString jar = m_game + "/libraries/org/lwjgl/lwjgl-3.3.1-natives.jar";
|
||||
QFile::remove(jar);
|
||||
error.clear();
|
||||
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
|
||||
QCOMPARE(error, Loc::text("launch.error.nativeReadFailed").arg(nat(jar)));
|
||||
|
||||
// Битый архив: QZipReader::isReadable() проверяет только, что файл открылся,
|
||||
// поэтому испорченный jar «распаковывается» в ничто без ошибки, и игра
|
||||
// падает уже на загрузке LWJGL. Фиксируем текущее поведение.
|
||||
Fixtures::write(jar, "not a zip");
|
||||
error.clear();
|
||||
QEXPECT_FAIL("", "битый jar с нативами не распознаётся как ошибка (QZipReader::isReadable)", Continue);
|
||||
QVERIFY(!GameLauncher::extractNatives(options(), v, natives, &error));
|
||||
}
|
||||
|
||||
// --- launch (с подставной java) ----------------------------------------------
|
||||
|
||||
void launchRunsJavaAndReportsExit()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
createGameFiles(v);
|
||||
const QString argsFile = m_game + "/java-args.txt";
|
||||
const QString java = Fixtures::installFakeJava(m_game + "/jdk", "17.0.8",
|
||||
{"args_file=" + argsFile, "stdout=[main] Setting user: Steve",
|
||||
"stdout=Game crashed? no", "exit=0"});
|
||||
LaunchOptions o = options();
|
||||
o.javaPath = java;
|
||||
|
||||
GameLauncher launcher;
|
||||
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
|
||||
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
|
||||
QSignalSpy output(&launcher, &GameLauncher::output);
|
||||
QSignalSpy progress(&launcher, &GameLauncher::progress);
|
||||
|
||||
QString error;
|
||||
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
|
||||
QCOMPARE(started.count(), 1);
|
||||
// Токен скрыт в показанной строке запуска.
|
||||
const QString shown = started.constFirst().constFirst().toString();
|
||||
QVERIFY(!shown.contains("secret-token"));
|
||||
QVERIFY(shown.contains("--accessToken"));
|
||||
|
||||
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
|
||||
QCOMPARE(finished.constFirst().at(0).toInt(), 0);
|
||||
QCOMPARE(finished.constFirst().at(1).toBool(), false);
|
||||
QVERIFY(!launcher.isRunning());
|
||||
QVERIFY(progress.count() >= 3);
|
||||
|
||||
QStringList lines;
|
||||
for (const QList<QVariant> &call : output)
|
||||
lines << call.constFirst().toString();
|
||||
QVERIFY(lines.contains("[main] Setting user: Steve"));
|
||||
|
||||
// Java получила те же аргументы, что строит buildArguments, и в рабочей папке игры.
|
||||
const QStringList received = QString::fromLocal8Bit(Fixtures::readFile(argsFile)).split(u'\n', Qt::SkipEmptyParts);
|
||||
QCOMPARE(received, GameLauncher::buildArguments(o, v, m_game + "/versions/1.20.1/natives"));
|
||||
QVERIFY(QFile::exists(m_game + "/versions/1.20.1/natives/" + kNativeName));
|
||||
}
|
||||
|
||||
void launchReportsCrash()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
createGameFiles(v);
|
||||
LaunchOptions o = options();
|
||||
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-crash", "17.0.8", {"crash=1"});
|
||||
|
||||
GameLauncher launcher;
|
||||
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
|
||||
QString error;
|
||||
QVERIFY2(launcher.launch(o, v, &error), qPrintable(error));
|
||||
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
|
||||
QCOMPARE(finished.constFirst().at(1).toBool(), true);
|
||||
}
|
||||
|
||||
void launchRefusesWhileRunningAndTerminate()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
createGameFiles(v);
|
||||
LaunchOptions o = options();
|
||||
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk-slow", "17.0.8", {"sleep_ms=30000"});
|
||||
|
||||
GameLauncher launcher;
|
||||
QSignalSpy finished(&launcher, &GameLauncher::gameFinished);
|
||||
QString error;
|
||||
QVERIFY(launcher.launch(o, v, &error));
|
||||
QVERIFY(launcher.isRunning());
|
||||
|
||||
QVERIFY(!launcher.launch(o, v, &error));
|
||||
QCOMPARE(error, Loc::text("common.error.gameRunning"));
|
||||
|
||||
launcher.terminate();
|
||||
QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 15000);
|
||||
QVERIFY(!launcher.isRunning());
|
||||
}
|
||||
|
||||
void launchFailsOnMissingFilesAndOldJava()
|
||||
{
|
||||
MinecraftVersion v = modernVersion();
|
||||
LaunchOptions o = options();
|
||||
GameLauncher launcher;
|
||||
QSignalSpy started(&launcher, &GameLauncher::gameStarted);
|
||||
|
||||
QString error;
|
||||
QVERIFY(!launcher.launch(o, v, &error));
|
||||
QVERIFY(error.startsWith(Loc::text("launch.error.filesMissing").section(u'%', 0, 0)));
|
||||
|
||||
createGameFiles(v);
|
||||
// На машине может стоять своя Java 17+: тогда лаунчер законно выберет её.
|
||||
for (const QString &candidate : JavaLocator::findAll(m_game)) {
|
||||
if (JavaLocator::majorVersion(candidate) >= 17)
|
||||
QSKIP("в системе есть Java 17+, проверка «слишком старой Java» невозможна");
|
||||
}
|
||||
o.javaPath = Fixtures::installFakeJava(m_game + "/jdk8", "1.8.0_392");
|
||||
error.clear();
|
||||
QVERIFY(!launcher.launch(o, v, &error));
|
||||
QCOMPARE(error, Loc::text("java.error.tooOld").arg(17).arg(8).arg(nat(o.javaPath)));
|
||||
QCOMPARE(started.count(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestGameLauncher)
|
||||
#include "tst_gamelauncher.moc"
|
||||
@@ -0,0 +1,145 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "javalocator.h"
|
||||
#include "javaruntime.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
|
||||
class TestJavaLocator : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
int m_unique = 0;
|
||||
|
||||
// majorVersion() кеширует ответ по пути, поэтому каждая подставная java — в своей папке.
|
||||
QString fakeJava(const QString &version)
|
||||
{
|
||||
return Fixtures::installFakeJava(m_game + QStringLiteral("/jdk-%1").arg(++m_unique), version);
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
m_game = TestEnv::freshDir(QStringLiteral("game"));
|
||||
}
|
||||
|
||||
void majorVersion_data()
|
||||
{
|
||||
QTest::addColumn<QString>("version");
|
||||
QTest::addColumn<int>("major");
|
||||
QTest::newRow("java8") << "1.8.0_392" << 8;
|
||||
QTest::newRow("java11") << "11.0.21" << 11;
|
||||
QTest::newRow("java17") << "17.0.8" << 17;
|
||||
QTest::newRow("java21 short") << "21" << 21;
|
||||
QTest::newRow("java25 ea") << "25-ea" << 25;
|
||||
}
|
||||
|
||||
void majorVersion()
|
||||
{
|
||||
QFETCH(QString, version);
|
||||
QFETCH(int, major);
|
||||
QCOMPARE(JavaLocator::majorVersion(fakeJava(version)), major);
|
||||
}
|
||||
|
||||
void majorVersionOfNonJava()
|
||||
{
|
||||
QCOMPARE(JavaLocator::majorVersion(m_game + QStringLiteral("/nope/java")), 0);
|
||||
}
|
||||
|
||||
void windowlessVariant()
|
||||
{
|
||||
const QString java = fakeJava("17.0.8");
|
||||
#if defined(Q_OS_WIN)
|
||||
QCOMPARE(JavaLocator::windowlessVariant(java), QFileInfo(java).absolutePath() + "/javaw.exe");
|
||||
QFile::remove(QFileInfo(java).absolutePath() + "/javaw.exe");
|
||||
QCOMPARE(JavaLocator::windowlessVariant(java), java);
|
||||
#else
|
||||
QCOMPARE(JavaLocator::windowlessVariant(java), java);
|
||||
#endif
|
||||
QCOMPARE(JavaLocator::windowlessVariant(QStringLiteral("/opt/custom/launcher")),
|
||||
QStringLiteral("/opt/custom/launcher"));
|
||||
}
|
||||
|
||||
void findAllSeesManagedAndGameRuntimes()
|
||||
{
|
||||
// Java, поставленная лаунчером (JavaRuntimeStore).
|
||||
InstalledJavaRuntime managed;
|
||||
managed.id = QStringLiteral("temurin-jdk-21");
|
||||
managed.kind = QStringLiteral("jdk");
|
||||
managed.major = 21;
|
||||
managed.version = QStringLiteral("21.0.2");
|
||||
managed.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(managed.id), "21.0.2");
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(managed, nullptr));
|
||||
|
||||
// Java официального лаунчера внутри .minecraft/runtime.
|
||||
const QString mojang = Fixtures::installFakeJava(
|
||||
m_game + "/runtime/java-runtime-gamma/linux/java-runtime-gamma", "17.0.8");
|
||||
|
||||
const QStringList all = JavaLocator::findAll(m_game);
|
||||
QVERIFY(all.size() >= 2);
|
||||
QCOMPARE(QFileInfo(all.at(0)).canonicalFilePath(), QFileInfo(managed.javaPath).canonicalFilePath());
|
||||
bool hasMojang = false;
|
||||
for (const QString &path : all)
|
||||
hasMojang |= QFileInfo(path).canonicalFilePath() == QFileInfo(mojang).canonicalFilePath();
|
||||
QVERIFY(hasMojang);
|
||||
QStringList unique = all;
|
||||
QCOMPARE(unique.removeDuplicates(), 0);
|
||||
}
|
||||
|
||||
void selectPrefersPreferredWhenSuitable()
|
||||
{
|
||||
const QString java = fakeJava("21.0.2");
|
||||
QString error;
|
||||
QCOMPARE(JavaLocator::select(m_game, 17, java, &error), QFileInfo(java).absoluteFilePath());
|
||||
QVERIFY(error.isEmpty());
|
||||
|
||||
// Папка JDK тоже годится: берётся bin/java.
|
||||
const QString fromDir = JavaLocator::select(m_game, 17, QFileInfo(java).absolutePath() + "/..", &error);
|
||||
QCOMPARE(QFileInfo(fromDir).canonicalFilePath(), QFileInfo(java).canonicalFilePath());
|
||||
}
|
||||
|
||||
void selectReportsMissingPreferred()
|
||||
{
|
||||
QString error;
|
||||
const QString missing = m_game + "/no-such-java";
|
||||
QVERIFY(JavaLocator::select(m_game, 17, missing, &error).isEmpty());
|
||||
QCOMPARE(error, Loc::text("java.error.notAtPath").arg(QDir::toNativeSeparators(missing)));
|
||||
}
|
||||
|
||||
void selectFallsBackToManagedRuntime()
|
||||
{
|
||||
InstalledJavaRuntime managed;
|
||||
managed.id = QStringLiteral("temurin-jdk-21b");
|
||||
managed.kind = QStringLiteral("jdk");
|
||||
managed.major = 21;
|
||||
managed.version = QStringLiteral("21.0.2");
|
||||
managed.javaPath = Fixtures::installFakeJava(JavaRuntimeStore::dirFor(managed.id), "21.0.2");
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(managed, nullptr));
|
||||
|
||||
const QString old = fakeJava("1.8.0_392");
|
||||
QString error;
|
||||
const QString chosen = JavaLocator::select(m_game, 21, old, &error);
|
||||
QCOMPARE(QFileInfo(chosen).canonicalFilePath(), QFileInfo(managed.javaPath).canonicalFilePath());
|
||||
}
|
||||
|
||||
void selectExplainsTooOld()
|
||||
{
|
||||
for (const QString &candidate : JavaLocator::findAll(m_game)) {
|
||||
if (JavaLocator::majorVersion(candidate) >= 99)
|
||||
QSKIP("в системе есть Java 99+");
|
||||
}
|
||||
const QString old = fakeJava("17.0.8");
|
||||
QString error;
|
||||
QVERIFY(JavaLocator::select(m_game, 99, old, &error).isEmpty());
|
||||
// Сообщение называет лучшую из найденных — она не ниже нашей 17.
|
||||
QVERIFY(!error.isEmpty());
|
||||
QVERIFY(error.contains(QStringLiteral("99")));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaLocator)
|
||||
#include "tst_javalocator.moc"
|
||||
@@ -0,0 +1,233 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "javaruntime.h"
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
class TestJavaRuntime : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
static QString fakeBinary(const QString &root, const QString &relativeBinDir)
|
||||
{
|
||||
const QString path = root + u'/' + relativeBinDir + u'/' + Fixtures::javaExecutableName();
|
||||
Fixtures::write(path, "#!/bin/sh\n");
|
||||
return path;
|
||||
}
|
||||
|
||||
static InstalledJavaRuntime makeRuntime(const QString &id, int major, const QString &version)
|
||||
{
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = id;
|
||||
runtime.kind = QStringLiteral("jdk");
|
||||
runtime.major = major;
|
||||
runtime.version = version;
|
||||
runtime.size = 12345;
|
||||
runtime.javaPath = fakeBinary(JavaRuntimeStore::dirFor(id), QStringLiteral("jdk/bin"));
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init() { TestEnv::resetLauncherRoot(); }
|
||||
|
||||
void kindKeysRoundTrip()
|
||||
{
|
||||
for (JavaRuntimeKind kind : {JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk, JavaRuntimeKind::Jre}) {
|
||||
const auto back = javaKindFromKey(javaKindKey(kind));
|
||||
QVERIFY(back.has_value());
|
||||
QCOMPARE(*back, kind);
|
||||
QVERIFY(!javaKindTitle(kind).isEmpty());
|
||||
}
|
||||
QCOMPARE(javaKindKey(JavaRuntimeKind::Mojang), QStringLiteral("java"));
|
||||
QVERIFY(!javaKindFromKey(QStringLiteral("graalvm")).has_value());
|
||||
QVERIFY(!javaKindFromKey(QString()).has_value());
|
||||
}
|
||||
|
||||
void entryValidity()
|
||||
{
|
||||
JavaRuntimeEntry entry;
|
||||
QVERIFY(!entry.isValid());
|
||||
entry.id = QStringLiteral("temurin-jdk-21");
|
||||
entry.major = 21;
|
||||
QVERIFY(!entry.isValid());
|
||||
entry.url = QUrl(QStringLiteral("https://example.org/jdk.zip"));
|
||||
QVERIFY(entry.isValid());
|
||||
}
|
||||
|
||||
void sanitizeId_data()
|
||||
{
|
||||
QTest::addColumn<QString>("input");
|
||||
QTest::addColumn<QString>("expected");
|
||||
QTest::newRow("plain") << "temurin-jdk-21.0.2_13" << "temurin-jdk-21.0.2_13";
|
||||
QTest::newRow("plus") << "jdk-21.0.2+13" << "jdk-21.0.2_13";
|
||||
QTest::newRow("slashes") << "../../etc/passwd" << "_.._etc_passwd";
|
||||
QTest::newRow("backslash") << "a\\b" << "a_b";
|
||||
QTest::newRow("leadingDots") << "...hidden" << "hidden";
|
||||
QTest::newRow("onlyDots") << ".." << "";
|
||||
QTest::newRow("empty") << "" << "";
|
||||
}
|
||||
|
||||
void sanitizeId()
|
||||
{
|
||||
QFETCH(QString, input);
|
||||
QFETCH(QString, expected);
|
||||
QCOMPARE(JavaRuntimeStore::sanitizeId(input), expected);
|
||||
}
|
||||
|
||||
void dirForStaysInsideJavaDir()
|
||||
{
|
||||
QCOMPARE(JavaRuntimeStore::dirFor(QStringLiteral("jdk-17")),
|
||||
LauncherPaths::javaDir() + QStringLiteral("/jdk-17"));
|
||||
QVERIFY(JavaRuntimeStore::dirFor(QStringLiteral("..")).isEmpty());
|
||||
QVERIFY(JavaRuntimeStore::dirFor(QStringLiteral("../x")).startsWith(LauncherPaths::javaDir() + u'/'));
|
||||
}
|
||||
|
||||
void locateBinaryPrefersShortestBinPath()
|
||||
{
|
||||
const QString root = TestEnv::freshDir(QStringLiteral("locate"));
|
||||
QVERIFY(JavaRuntimeStore::locateBinary(root).isEmpty());
|
||||
QVERIFY(JavaRuntimeStore::locateBinary(root + QStringLiteral("/missing")).isEmpty());
|
||||
QVERIFY(JavaRuntimeStore::locateBinary(QString()).isEmpty());
|
||||
|
||||
// java вне папки bin не считается
|
||||
Fixtures::write(root + u'/' + Fixtures::javaExecutableName(), "x");
|
||||
QVERIFY(JavaRuntimeStore::locateBinary(root).isEmpty());
|
||||
|
||||
const QString deep = fakeBinary(root, QStringLiteral("jdk-21/lib/legal/jre/bin"));
|
||||
QCOMPARE(JavaRuntimeStore::locateBinary(root), deep);
|
||||
const QString macLayout = fakeBinary(root, QStringLiteral("jdk-21/Contents/Home/bin"));
|
||||
QCOMPARE(JavaRuntimeStore::locateBinary(root), macLayout);
|
||||
const QString flat = fakeBinary(root, QStringLiteral("jdk-21/bin"));
|
||||
QCOMPARE(JavaRuntimeStore::locateBinary(root), flat);
|
||||
}
|
||||
|
||||
void writeMetaFindInstalledRemove()
|
||||
{
|
||||
QString error;
|
||||
const InstalledJavaRuntime older = makeRuntime(QStringLiteral("temurin-jdk-17.0.9"), 17, QStringLiteral("17.0.9"));
|
||||
const InstalledJavaRuntime newer = makeRuntime(QStringLiteral("temurin-jdk-21.0.2"), 21, QStringLiteral("21.0.2"));
|
||||
const InstalledJavaRuntime newerPatch = makeRuntime(QStringLiteral("temurin-jdk-21.0.5"), 21, QStringLiteral("21.0.5"));
|
||||
QVERIFY2(JavaRuntimeStore::writeMeta(older, &error), qPrintable(error));
|
||||
QVERIFY2(JavaRuntimeStore::writeMeta(newer, &error), qPrintable(error));
|
||||
QVERIFY2(JavaRuntimeStore::writeMeta(newerPatch, &error), qPrintable(error));
|
||||
|
||||
// Путь хранится относительным: папку можно переносить.
|
||||
const QJsonObject meta = Fixtures::readJsonFile(JavaRuntimeStore::dirFor(newer.id) + "/runtime.json");
|
||||
QCOMPARE(meta.value("javaPath").toString(), "jdk/bin/" + Fixtures::javaExecutableName());
|
||||
|
||||
const auto found = JavaRuntimeStore::find(newer.id);
|
||||
QVERIFY(found.has_value());
|
||||
QCOMPARE(found->major, 21);
|
||||
QCOMPARE(found->version, QStringLiteral("21.0.2"));
|
||||
QCOMPARE(found->kind, QStringLiteral("jdk"));
|
||||
QCOMPARE(found->size, qint64(12345));
|
||||
QCOMPARE(QFileInfo(found->javaPath).canonicalFilePath(), QFileInfo(newer.javaPath).canonicalFilePath());
|
||||
|
||||
// Сортировка: свежая major первой, внутри — по версии по убыванию.
|
||||
const QList<InstalledJavaRuntime> all = JavaRuntimeStore::installed();
|
||||
QCOMPARE(all.size(), 3);
|
||||
QCOMPARE(all.at(0).id, newerPatch.id);
|
||||
QCOMPARE(all.at(1).id, newer.id);
|
||||
QCOMPARE(all.at(2).id, older.id);
|
||||
|
||||
QVERIFY(JavaRuntimeStore::remove(older.id, &error));
|
||||
QVERIFY(!QDir(JavaRuntimeStore::dirFor(older.id)).exists());
|
||||
QVERIFY(!JavaRuntimeStore::find(older.id).has_value());
|
||||
// Удаление несуществующего — не ошибка.
|
||||
QVERIFY(JavaRuntimeStore::remove(older.id, &error));
|
||||
QCOMPARE(JavaRuntimeStore::installed().size(), 2);
|
||||
}
|
||||
|
||||
void findRejectsBrokenMeta()
|
||||
{
|
||||
const InstalledJavaRuntime runtime = makeRuntime(QStringLiteral("jdk-x"), 21, QStringLiteral("21"));
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(runtime, nullptr));
|
||||
QVERIFY(JavaRuntimeStore::find(runtime.id).has_value());
|
||||
|
||||
// Бинарник пропал — запись больше не считается установленной.
|
||||
QFile::remove(runtime.javaPath);
|
||||
QVERIFY(!JavaRuntimeStore::find(runtime.id).has_value());
|
||||
|
||||
// id в файле не совпадает с папкой.
|
||||
const InstalledJavaRuntime other = makeRuntime(QStringLiteral("jdk-y"), 17, QStringLiteral("17"));
|
||||
QVERIFY(JavaRuntimeStore::writeMeta(other, nullptr));
|
||||
QJsonObject meta = Fixtures::readJsonFile(JavaRuntimeStore::dirFor(other.id) + "/runtime.json");
|
||||
meta["id"] = "jdk-z";
|
||||
Fixtures::writeJson(JavaRuntimeStore::dirFor(other.id) + "/runtime.json", meta);
|
||||
QVERIFY(!JavaRuntimeStore::find(other.id).has_value());
|
||||
|
||||
// Мусор вместо json.
|
||||
Fixtures::write(JavaRuntimeStore::dirFor(other.id) + "/runtime.json", "{not json");
|
||||
QVERIFY(!JavaRuntimeStore::find(other.id).has_value());
|
||||
QVERIFY(JavaRuntimeStore::installed().isEmpty());
|
||||
}
|
||||
|
||||
void invalidIdsAreRejected()
|
||||
{
|
||||
QString error;
|
||||
InstalledJavaRuntime runtime;
|
||||
runtime.id = QStringLiteral("..");
|
||||
QVERIFY(!JavaRuntimeStore::writeMeta(runtime, &error));
|
||||
QCOMPARE(error, Loc::text("java.error.invalidRuntimeId"));
|
||||
error.clear();
|
||||
QVERIFY(!JavaRuntimeStore::remove(QStringLiteral(".."), &error));
|
||||
QCOMPARE(error, Loc::text("java.error.invalidRuntimeId"));
|
||||
QVERIFY(!JavaRuntimeStore::find(QString()).has_value());
|
||||
}
|
||||
|
||||
void minimumJavaFor_data()
|
||||
{
|
||||
QTest::addColumn<QString>("version");
|
||||
QTest::addColumn<int>("java");
|
||||
QTest::newRow("empty") << "" << 0;
|
||||
QTest::newRow("spaces") << " " << 0;
|
||||
QTest::newRow("1.7.10") << "1.7.10" << 8;
|
||||
QTest::newRow("1.12.2") << "1.12.2" << 8;
|
||||
QTest::newRow("1.16.5") << "1.16.5" << 8;
|
||||
QTest::newRow("1.17") << "1.17" << 16;
|
||||
QTest::newRow("1.17.1") << "1.17.1" << 16;
|
||||
QTest::newRow("1.18") << "1.18" << 17;
|
||||
QTest::newRow("1.19.4") << "1.19.4" << 17;
|
||||
QTest::newRow("1.20.1") << "1.20.1" << 17;
|
||||
QTest::newRow("1.20.4") << "1.20.4" << 17;
|
||||
QTest::newRow("1.20.5") << "1.20.5" << 21;
|
||||
QTest::newRow("1.20.6") << "1.20.6" << 21;
|
||||
QTest::newRow("1.21") << "1.21" << 21;
|
||||
QTest::newRow("1.21.8") << "1.21.8" << 21;
|
||||
QTest::newRow("forge-suffix") << "1.20.1-forge-47.4.0" << 17;
|
||||
QTest::newRow("newEpoch") << "26.1" << 21;
|
||||
QTest::newRow("snapshot20") << "20w45a" << 8;
|
||||
QTest::newRow("snapshot21") << "21w19a" << 16;
|
||||
QTest::newRow("snapshot22") << "22w45a" << 17;
|
||||
QTest::newRow("snapshot24") << "24w14a" << 21;
|
||||
QTest::newRow("snapshot25") << "25w33a" << 21;
|
||||
QTest::newRow("beta") << "b1.7.3" << 8;
|
||||
QTest::newRow("trimmed") << " 1.18.2 " << 17;
|
||||
}
|
||||
|
||||
void minimumJavaFor()
|
||||
{
|
||||
QFETCH(QString, version);
|
||||
QFETCH(int, java);
|
||||
QCOMPARE(JavaRequirement::minimumFor(version), java);
|
||||
}
|
||||
|
||||
void coverage()
|
||||
{
|
||||
QCOMPARE(JavaRequirement::coverage(25), Loc::text("java.range.modern"));
|
||||
QCOMPARE(JavaRequirement::coverage(21), Loc::text("java.range.modern"));
|
||||
QCOMPARE(JavaRequirement::coverage(17), Loc::text("java.range.mid"));
|
||||
QCOMPARE(JavaRequirement::coverage(16), Loc::text("java.range.seventeen"));
|
||||
QCOMPARE(JavaRequirement::coverage(11), Loc::text("java.range.unsuitable"));
|
||||
QCOMPARE(JavaRequirement::coverage(8), Loc::text("java.range.legacy"));
|
||||
QVERIFY(JavaRequirement::coverage(0).isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestJavaRuntime)
|
||||
#include "tst_javaruntime.moc"
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QScopeGuard>
|
||||
|
||||
class TestLauncherPaths : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void init() { TestEnv::resetLauncherRoot(); }
|
||||
|
||||
void rootIsInsideContainer()
|
||||
{
|
||||
QVERIFY(LauncherPaths::rootDir().startsWith(LauncherPaths::containerDir() + u'/'));
|
||||
QCOMPARE(QFileInfo(LauncherPaths::rootDir()).fileName(), QStringLiteral("BiLauncher"));
|
||||
}
|
||||
|
||||
void containerPerPlatform()
|
||||
{
|
||||
// Не QDir::homePath(): на Windows он смотрит в настоящий профиль, а пути
|
||||
// лаунчера в тестах идут от BILAUNCHER_HOME (песочницы).
|
||||
const QString home = QDir::cleanPath(TestEnv::homeDir());
|
||||
#if defined(Q_OS_WIN)
|
||||
QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/AppData/Roaming"));
|
||||
QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/.minecraft");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
QCOMPARE(LauncherPaths::containerDir(), home + QStringLiteral("/Library/Application Support"));
|
||||
QCOMPARE(LauncherPaths::defaultMinecraftDir(), LauncherPaths::containerDir() + "/minecraft");
|
||||
#else
|
||||
QCOMPARE(LauncherPaths::containerDir(), home);
|
||||
QCOMPARE(LauncherPaths::defaultMinecraftDir(), home + "/.minecraft");
|
||||
#endif
|
||||
}
|
||||
|
||||
void homeOverrideFromEnvironment()
|
||||
{
|
||||
const QString saved = qEnvironmentVariable("BILAUNCHER_HOME");
|
||||
const auto restore = qScopeGuard([&] { TestEnv::setEnvPath("BILAUNCHER_HOME", saved); });
|
||||
|
||||
QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(TestEnv::homeDir()));
|
||||
|
||||
const QString other = TestEnv::freshDir("other-home");
|
||||
TestEnv::setEnvPath("BILAUNCHER_HOME", QDir::toNativeSeparators(other));
|
||||
QCOMPARE(LauncherPaths::homeOverride(), QDir::cleanPath(other));
|
||||
QVERIFY(LauncherPaths::rootDir().startsWith(QDir::cleanPath(other) + u'/'));
|
||||
|
||||
// Относительный путь зависел бы от текущей папки — игнорируется, как и пустой.
|
||||
for (const char *ignored : {"relative/home", ""}) {
|
||||
qputenv("BILAUNCHER_HOME", ignored);
|
||||
QVERIFY2(LauncherPaths::homeOverride().isEmpty(), ignored);
|
||||
}
|
||||
}
|
||||
|
||||
void filesLayout_data()
|
||||
{
|
||||
QTest::addColumn<QString>("actual");
|
||||
QTest::addColumn<QString>("relative");
|
||||
|
||||
QTest::newRow("settings") << LauncherPaths::settingsFile() << "settings.json";
|
||||
QTest::newRow("profiles") << LauncherPaths::profilesFile() << "profiles.json";
|
||||
QTest::newRow("customBuilds") << LauncherPaths::customBuildsFile() << "customBuilds.json";
|
||||
QTest::newRow("legacyBuilds") << LauncherPaths::legacyCustomBuildsFile() << "versions.json";
|
||||
QTest::newRow("buildStorage") << LauncherPaths::buildStorageDir() << "builds";
|
||||
QTest::newRow("buildDir") << LauncherPaths::buildDir(7) << "builds/7";
|
||||
QTest::newRow("season") << LauncherPaths::seasonalStateFile(7) << "builds/7/season.json";
|
||||
QTest::newRow("runtime") << LauncherPaths::runtimeDir() << "runtime";
|
||||
QTest::newRow("java") << LauncherPaths::javaDir() << "java";
|
||||
QTest::newRow("cache") << LauncherPaths::cacheDir() << "cache";
|
||||
QTest::newRow("javaCatalog") << LauncherPaths::javaCatalogFile() << "cache/java_runtimes.json";
|
||||
QTest::newRow("javaDownload") << LauncherPaths::javaDownloadDir() << "cache/java";
|
||||
QTest::newRow("manifest") << LauncherPaths::versionManifestFile() << "cache/version_manifest.json";
|
||||
QTest::newRow("seasonal") << LauncherPaths::seasonalCatalogFile() << "cache/seasonal_builds.json";
|
||||
QTest::newRow("loaderCache") << LauncherPaths::loaderCacheFile("forge") << "cache/loader_forge.json";
|
||||
QTest::newRow("loaderDownload") << LauncherPaths::loaderDownloadDir() << "cache/loaders";
|
||||
}
|
||||
|
||||
void filesLayout()
|
||||
{
|
||||
QFETCH(QString, actual);
|
||||
QFETCH(QString, relative);
|
||||
QCOMPARE(actual, LauncherPaths::rootDir() + u'/' + relative);
|
||||
}
|
||||
|
||||
void ensureRootCreatesTree()
|
||||
{
|
||||
QString error;
|
||||
QVERIFY2(LauncherPaths::ensureRootExists(&error), qPrintable(error));
|
||||
for (const QString &dir : {LauncherPaths::rootDir(), LauncherPaths::runtimeDir(),
|
||||
LauncherPaths::cacheDir(), LauncherPaths::loaderDownloadDir(),
|
||||
LauncherPaths::buildStorageDir(), LauncherPaths::javaDir(),
|
||||
LauncherPaths::javaDownloadDir()}) {
|
||||
QVERIFY2(QFileInfo(dir).isDir(), qPrintable(dir));
|
||||
}
|
||||
// Повторный вызов на готовом дереве — тоже успех.
|
||||
QVERIFY(LauncherPaths::ensureRootExists(&error));
|
||||
}
|
||||
|
||||
void ensureRootFailsWhenFileBlocksFolder()
|
||||
{
|
||||
QDir().mkpath(LauncherPaths::rootDir());
|
||||
QFile blocker(LauncherPaths::cacheDir());
|
||||
QVERIFY(blocker.open(QIODevice::WriteOnly));
|
||||
blocker.close();
|
||||
|
||||
QString error;
|
||||
QVERIFY(!LauncherPaths::ensureRootExists(&error));
|
||||
QVERIFY(!error.isEmpty());
|
||||
QVERIFY(error.contains(QDir::toNativeSeparators(LauncherPaths::cacheDir())));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestLauncherPaths)
|
||||
#include "tst_launcherpaths.moc"
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QSignalSpy>
|
||||
|
||||
class TestLocalization : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Localization &loc() { return Localization::instance(); }
|
||||
|
||||
// Значения каталога напрямую из файла — эталон для сравнения.
|
||||
static QJsonObject catalogEntry(const QString &key)
|
||||
{
|
||||
return Fixtures::readJsonFile(QStringLiteral(BILAUNCHER_SOURCE_DIR "/i18n/translations.json"))
|
||||
.value(QStringLiteral("strings"))
|
||||
.toObject()
|
||||
.value(key)
|
||||
.toObject();
|
||||
}
|
||||
|
||||
private slots:
|
||||
void init()
|
||||
{
|
||||
TestEnv::resetLauncherRoot();
|
||||
loc().setLanguage(QStringLiteral("en"));
|
||||
}
|
||||
|
||||
void textFollowsLanguage()
|
||||
{
|
||||
const QJsonObject entry = catalogEntry(QStringLiteral("app.tooltip.openMods"));
|
||||
QCOMPARE(Loc::text("app.tooltip.openMods"), entry.value("en").toString());
|
||||
|
||||
QSignalSpy spy(&loc(), &Localization::languageChanged);
|
||||
loc().setLanguage(QStringLiteral("ru"));
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QCOMPARE(loc().language(), QStringLiteral("ru"));
|
||||
QCOMPARE(loc().resolvedLanguage(), QStringLiteral("ru"));
|
||||
QCOMPARE(Loc::text("app.tooltip.openMods"), entry.value("ru").toString());
|
||||
|
||||
// Тот же язык ещё раз — без сигнала.
|
||||
loc().setLanguage(QStringLiteral("ru"));
|
||||
QCOMPARE(spy.count(), 1);
|
||||
}
|
||||
|
||||
void unknownLanguageFallsBackToSystem()
|
||||
{
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("klingon")));
|
||||
loc().setLanguage(QStringLiteral("klingon"));
|
||||
QCOMPARE(loc().language(), QStringLiteral("system"));
|
||||
const QString resolved = loc().resolvedLanguage();
|
||||
QVERIFY(resolved == QLatin1String("ru") || resolved == QLatin1String("en"));
|
||||
}
|
||||
|
||||
void missingKeyReturnsKey()
|
||||
{
|
||||
#ifndef QT_NO_DEBUG
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no\\.such\\.key")));
|
||||
#endif
|
||||
QCOMPARE(Loc::text("no.such.key"), QStringLiteral("no.such.key"));
|
||||
#ifndef QT_NO_DEBUG
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no\\.such\\.list")));
|
||||
#endif
|
||||
QCOMPARE(Loc::list("no.such.list"), QStringList{QStringLiteral("no.such.list")});
|
||||
}
|
||||
|
||||
void listsHaveSameShapeInAllLanguages()
|
||||
{
|
||||
const QJsonObject strings =
|
||||
Fixtures::readJsonFile(QStringLiteral(BILAUNCHER_SOURCE_DIR "/i18n/translations.json"))
|
||||
.value(QStringLiteral("strings"))
|
||||
.toObject();
|
||||
int lists = 0;
|
||||
for (auto it = strings.constBegin(); it != strings.constEnd(); ++it) {
|
||||
if (!it.value().toObject().value("en").isArray())
|
||||
continue;
|
||||
++lists;
|
||||
const QByteArray key = it.key().toLatin1();
|
||||
loc().setLanguage(QStringLiteral("en"));
|
||||
const QStringList en = Loc::list(key.constData());
|
||||
loc().setLanguage(QStringLiteral("ru"));
|
||||
const QStringList ru = Loc::list(key.constData());
|
||||
QCOMPARE(en.size(), ru.size());
|
||||
QCOMPARE(en.size(), it.value().toObject().value("en").toArray().size());
|
||||
}
|
||||
if (lists == 0)
|
||||
QSKIP("в каталоге нет списков");
|
||||
}
|
||||
|
||||
void availableLanguages()
|
||||
{
|
||||
const QVariantList languages = loc().availableLanguages();
|
||||
QVERIFY(languages.size() >= 3);
|
||||
QCOMPARE(languages.first().toMap().value("code").toString(), QStringLiteral("system"));
|
||||
QStringList codes;
|
||||
for (const QVariant &item : languages)
|
||||
codes << item.toMap().value("code").toString();
|
||||
QVERIFY(codes.contains("ru"));
|
||||
QVERIFY(codes.contains("en"));
|
||||
for (const QVariant &item : languages)
|
||||
QVERIFY(!item.toMap().value("name").toString().isEmpty());
|
||||
}
|
||||
|
||||
void storedLanguageIsReadOnLoad()
|
||||
{
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"language", "ru"}});
|
||||
QVERIFY(loc().load());
|
||||
QCOMPARE(loc().language(), QStringLiteral("ru"));
|
||||
|
||||
Fixtures::writeJson(LauncherPaths::settingsFile(), QJsonObject{{"language", "xx"}});
|
||||
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("xx")));
|
||||
QVERIFY(loc().load());
|
||||
QCOMPARE(loc().language(), QStringLiteral("system"));
|
||||
|
||||
Fixtures::write(LauncherPaths::settingsFile(), "garbage");
|
||||
QVERIFY(loc().load());
|
||||
QCOMPARE(loc().language(), QStringLiteral("system"));
|
||||
}
|
||||
|
||||
void everyCppKeyExists()
|
||||
{
|
||||
// Тексты, которые код запрашивает через Loc::text("…"), должны быть в каталоге —
|
||||
// иначе пользователь увидит сам ключ. Полная проверка — tools/check_translations.py,
|
||||
// здесь — выборка ключей, которыми пользуются тесты.
|
||||
for (const char *key : {"storage.error.createFailed", "storage.error.notWritable",
|
||||
"java.error.invalidRuntimeId", "version.error.invalidId",
|
||||
"launch.error.filesMissing", "auth.offline.error.noNickname"}) {
|
||||
QVERIFY2(Loc::text(key) != QLatin1String(key), key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestLocalization)
|
||||
#include "tst_localization.moc"
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "testenv.h"
|
||||
#include "fixtures.h"
|
||||
|
||||
#include "localization.h"
|
||||
#include "minecraftversion.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QSysInfo>
|
||||
|
||||
namespace {
|
||||
|
||||
void writeVersion(const QString &gameDir, const QString &id, const QByteArray &json, bool withJar)
|
||||
{
|
||||
const QString dir = gameDir + "/versions/" + id;
|
||||
Fixtures::write(dir + u'/' + id + ".json", json);
|
||||
if (withJar)
|
||||
Fixtures::write(dir + u'/' + id + ".jar", "jar-" + id.toUtf8());
|
||||
}
|
||||
|
||||
QString otherOs()
|
||||
{
|
||||
return Fixtures::otherOsName();
|
||||
}
|
||||
|
||||
// Современный ванильный json (1.19+): arguments, natives-классификаторы, logging, quickPlay.
|
||||
QByteArray modernVanilla()
|
||||
{
|
||||
return Fixtures::readTemplate(QStringLiteral("versions/modern-1.20.1.json"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestMinecraftVersion : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
QString m_game;
|
||||
|
||||
private slots:
|
||||
void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); }
|
||||
|
||||
// --- правила ---------------------------------------------------------------
|
||||
|
||||
void rulesAllow_data()
|
||||
{
|
||||
QTest::addColumn<QByteArray>("rules");
|
||||
QTest::addColumn<QStringList>("features");
|
||||
QTest::addColumn<bool>("allowed");
|
||||
|
||||
QTest::newRow("no rules") << QByteArray("[]") << QStringList{} << true;
|
||||
QTest::newRow("allow all") << QByteArray("[{\"action\":\"allow\"}]") << QStringList{} << true;
|
||||
QTest::newRow("disallow all") << QByteArray("[{\"action\":\"disallow\"}]") << QStringList{} << false;
|
||||
QTest::newRow("allow this os")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"name\":\"%OS%\"}}]") << QStringList{} << true;
|
||||
QTest::newRow("allow other os only")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"name\":\"%OTHER%\"}}]") << QStringList{} << false;
|
||||
QTest::newRow("allow all but this os")
|
||||
<< QByteArray("[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"%OS%\"}}]")
|
||||
<< QStringList{} << false;
|
||||
QTest::newRow("allow all but other os")
|
||||
<< QByteArray("[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"%OTHER%\"}}]")
|
||||
<< QStringList{} << true;
|
||||
QTest::newRow("arch matches")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"arch\":\"%ARCH%\"}}]") << QStringList{} << true;
|
||||
QTest::newRow("arch differs")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"arch\":\"sparc\"}}]") << QStringList{} << false;
|
||||
QTest::newRow("os version regex any")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\".*\"}}]") << QStringList{} << true;
|
||||
QTest::newRow("os version regex none")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\"^nope$\"}}]") << QStringList{} << false;
|
||||
QTest::newRow("os version invalid regex")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"os\":{\"version\":\"(\"}}]") << QStringList{} << false;
|
||||
QTest::newRow("feature missing")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]") << QStringList{} << false;
|
||||
QTest::newRow("feature present")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]")
|
||||
<< QStringList{"is_demo_user"} << true;
|
||||
QTest::newRow("feature must be absent")
|
||||
<< QByteArray("[{\"action\":\"allow\",\"features\":{\"has_custom_resolution\":false}}]")
|
||||
<< QStringList{} << true;
|
||||
QTest::newRow("last match wins")
|
||||
<< QByteArray("[{\"action\":\"disallow\"},{\"action\":\"allow\"}]") << QStringList{} << true;
|
||||
}
|
||||
|
||||
void rulesAllow()
|
||||
{
|
||||
QFETCH(QByteArray, rules);
|
||||
QFETCH(QStringList, features);
|
||||
QFETCH(bool, allowed);
|
||||
rules = Fixtures::applyTemplate(rules);
|
||||
const QSet<QString> set(features.cbegin(), features.cend());
|
||||
QCOMPARE(VersionLoader::rulesAllow(QJsonDocument::fromJson(rules).array(), set), allowed);
|
||||
}
|
||||
|
||||
void platformNames()
|
||||
{
|
||||
const QString os = VersionLoader::osName();
|
||||
QVERIFY(QStringList({"windows", "osx", "linux"}).contains(os));
|
||||
QVERIFY(!VersionLoader::osArch().isEmpty());
|
||||
const QString classifier = VersionLoader::nativeClassifier();
|
||||
QVERIFY(classifier.startsWith(QStringLiteral("natives-")));
|
||||
#if defined(Q_OS_MACOS)
|
||||
QCOMPARE(os, QStringLiteral("osx"));
|
||||
QVERIFY(classifier.startsWith(QStringLiteral("natives-macos")));
|
||||
#elif defined(Q_OS_WIN)
|
||||
QCOMPARE(os, QStringLiteral("windows"));
|
||||
#else
|
||||
QCOMPARE(os, QStringLiteral("linux"));
|
||||
#endif
|
||||
if (QSysInfo::currentCpuArchitecture() == QLatin1String("arm64"))
|
||||
QVERIFY(classifier.endsWith(QStringLiteral("-arm64")));
|
||||
}
|
||||
|
||||
// --- load -------------------------------------------------------------------
|
||||
|
||||
void loadModernVanilla()
|
||||
{
|
||||
writeVersion(m_game, "1.20.1", modernVanilla(), true);
|
||||
Fixtures::write(m_game + "/assets/log_configs/client-1.12.xml", "<xml/>");
|
||||
|
||||
QString error;
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "1.20.1", {}, &error);
|
||||
QVERIFY2(error.isEmpty(), qPrintable(error));
|
||||
QVERIFY(v.isValid());
|
||||
QCOMPARE(v.id, QStringLiteral("1.20.1"));
|
||||
QCOMPARE(v.mainClass, QStringLiteral("net.minecraft.client.main.Main"));
|
||||
QCOMPARE(v.type, QStringLiteral("release"));
|
||||
QCOMPARE(v.assetIndexId, QStringLiteral("5"));
|
||||
QCOMPARE(v.assetsKind, QStringLiteral("5"));
|
||||
QCOMPARE(v.javaMajor, 17);
|
||||
QCOMPARE(v.clientJarPath, m_game + "/versions/1.20.1/1.20.1.jar");
|
||||
QCOMPARE(v.loggingArgument, QStringLiteral("-Dlog4j.configurationFile=${path}"));
|
||||
QCOMPARE(v.loggingConfigPath, m_game + "/assets/log_configs/client-1.12.xml");
|
||||
QVERIFY(v.supportsQuickPlay);
|
||||
QVERIFY(v.hasCustomResolutionArgs);
|
||||
|
||||
// jvm: правило по ОС, своего -cp у версии есть — лаунчер своё не добавляет.
|
||||
QVERIFY(v.jvmArgs.contains("-Dthis.os=yes"));
|
||||
QVERIFY(!v.jvmArgs.contains("-Dother.os=yes"));
|
||||
QCOMPARE(v.jvmArgs.count("-cp"), 1);
|
||||
QVERIFY(!v.jvmArgs.contains("-Dminecraft.launcher.brand=${launcher_name}"));
|
||||
|
||||
// game: без фич — ни --demo, ни --width, ни quickPlay.
|
||||
QVERIFY(v.gameArgs.contains("--username"));
|
||||
QVERIFY(!v.gameArgs.contains("--demo"));
|
||||
QVERIFY(!v.gameArgs.contains("--width"));
|
||||
QVERIFY(!v.gameArgs.contains("--quickPlayMultiplayer"));
|
||||
|
||||
// Библиотеки: чужая ОС отфильтрована, натив только текущей платформы.
|
||||
QStringList names;
|
||||
for (const MinecraftLibrary &lib : v.libraries)
|
||||
names << lib.name;
|
||||
QVERIFY(names.contains("com.mojang:brigadier:1.1.8"));
|
||||
QVERIFY(names.contains("org.lwjgl:lwjgl:3.3.1"));
|
||||
QVERIFY(names.contains("org.lwjgl:lwjgl:3.3.1:" + VersionLoader::nativeClassifier()));
|
||||
QVERIFY(!names.contains("org.lwjgl:lwjgl:3.3.1:natives-other"));
|
||||
QVERIFY(!names.contains("only.other:lib:1.0"));
|
||||
|
||||
for (const MinecraftLibrary &lib : v.libraries) {
|
||||
if (lib.name == "com.mojang:brigadier:1.1.8") {
|
||||
QCOMPARE(lib.path, QStringLiteral("com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"));
|
||||
QCOMPARE(lib.sha1, QStringLiteral("5244ce82c3337bba4a196a3ce858bfaecc74404a"));
|
||||
QCOMPARE(lib.size, qint64(77392));
|
||||
QVERIFY(!lib.native);
|
||||
}
|
||||
if (lib.name.endsWith(VersionLoader::nativeClassifier()))
|
||||
QVERIFY(lib.native);
|
||||
}
|
||||
}
|
||||
|
||||
void featuresEnableArguments()
|
||||
{
|
||||
writeVersion(m_game, "1.20.1", modernVanilla(), true);
|
||||
const QSet<QString> features{"has_custom_resolution", "is_quick_play_multiplayer", "is_demo_user"};
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "1.20.1", features, nullptr);
|
||||
QVERIFY(v.gameArgs.contains("--demo"));
|
||||
const qsizetype width = v.gameArgs.indexOf("--width");
|
||||
QVERIFY(width >= 0);
|
||||
QCOMPARE(v.gameArgs.at(width + 1), QStringLiteral("${resolution_width}"));
|
||||
QVERIFY(v.gameArgs.contains("--quickPlayMultiplayer"));
|
||||
}
|
||||
|
||||
void legacyMinecraftArguments()
|
||||
{
|
||||
writeVersion(m_game, "1.7.10", Fixtures::read(QStringLiteral("versions/legacy-1.7.10.json")), true);
|
||||
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "1.7.10", {}, nullptr);
|
||||
QVERIFY(v.isValid());
|
||||
QCOMPARE(v.gameArgs,
|
||||
QStringList({"--username", "${auth_player_name}", "--session", "${auth_session}",
|
||||
"--gameDir", "${game_directory}"}));
|
||||
QCOMPARE(v.assetIndexId, QStringLiteral("1.7.10")); // нет assetIndex — берётся assets
|
||||
QCOMPARE(v.type, QStringLiteral("release")); // нет type — release
|
||||
QCOMPARE(v.javaMajor, 8); // нет javaVersion — 8
|
||||
QVERIFY(!v.supportsQuickPlay);
|
||||
|
||||
// Нет -cp в jvm — лаунчер добавляет стандартный набор.
|
||||
QVERIFY(v.jvmArgs.contains("-cp"));
|
||||
QVERIFY(v.jvmArgs.contains("${classpath}"));
|
||||
QVERIFY(v.jvmArgs.contains("-Djava.library.path=${natives_directory}"));
|
||||
QVERIFY(v.jvmArgs.contains("-Dminecraft.launcher.brand=${launcher_name}"));
|
||||
|
||||
bool sawNative = false;
|
||||
for (const MinecraftLibrary &lib : v.libraries) {
|
||||
if (lib.name == "net.minecraft:launchwrapper:1.12") {
|
||||
// Без downloads: путь из координат, url = репозиторий + путь.
|
||||
QCOMPARE(lib.path, QStringLiteral("net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar"));
|
||||
QCOMPARE(lib.url, QStringLiteral("https://libraries.example/maven/net/minecraft/launchwrapper/1.12/launchwrapper-1.12.jar"));
|
||||
}
|
||||
if (lib.native) {
|
||||
sawNative = true;
|
||||
QCOMPARE(lib.extractExclude, QStringList{"META-INF/"});
|
||||
#if defined(Q_OS_WIN)
|
||||
QVERIFY(lib.path == "lw/natives-windows-64.jar" || lib.path == "lw/natives-windows-32.jar");
|
||||
#elif defined(Q_OS_MACOS)
|
||||
QCOMPARE(lib.path, QStringLiteral("lw/natives-osx.jar"));
|
||||
#else
|
||||
QCOMPARE(lib.path, QStringLiteral("lw/natives-linux.jar"));
|
||||
#endif
|
||||
}
|
||||
}
|
||||
QVERIFY(sawNative);
|
||||
}
|
||||
|
||||
void inheritanceMergesChain()
|
||||
{
|
||||
writeVersion(m_game, "1.20.1", modernVanilla(), true);
|
||||
writeVersion(m_game, "fabric-loader-0.15.0-1.20.1", Fixtures::read(QStringLiteral("versions/fabric-loader-0.15.0-1.20.1.json")), false);
|
||||
|
||||
QString error;
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "fabric-loader-0.15.0-1.20.1", {}, &error);
|
||||
QVERIFY2(error.isEmpty(), qPrintable(error));
|
||||
QCOMPARE(v.id, QStringLiteral("fabric-loader-0.15.0-1.20.1"));
|
||||
QCOMPARE(v.mainClass, QStringLiteral("net.fabricmc.loader.impl.launch.knot.KnotClient"));
|
||||
QCOMPARE(v.javaMajor, 17); // от родителя
|
||||
QCOMPARE(v.assetIndexId, QStringLiteral("5")); // от родителя
|
||||
QCOMPARE(v.clientJarPath, m_game + "/versions/1.20.1/1.20.1.jar"); // своего jar нет
|
||||
QVERIFY(v.jvmArgs.contains("-DFabricMcEmu= net.minecraft.client.main.Main "));
|
||||
QVERIFY(v.jvmArgs.contains("-Dthis.os=yes")); // jvm родителя
|
||||
QVERIFY(v.gameArgs.contains("--username"));
|
||||
|
||||
// Дочерние библиотеки первыми и вытесняют те же group:artifact родителя.
|
||||
QCOMPARE(v.libraries.first().name, QStringLiteral("net.fabricmc:fabric-loader:0.15.0"));
|
||||
QStringList names;
|
||||
for (const MinecraftLibrary &lib : v.libraries)
|
||||
names << lib.name;
|
||||
QVERIFY(names.contains("com.mojang:brigadier:9.9.9"));
|
||||
QVERIFY(!names.contains("com.mojang:brigadier:1.1.8"));
|
||||
QCOMPARE(v.libraries.first().url,
|
||||
QStringLiteral("https://maven.fabricmc.net/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"));
|
||||
}
|
||||
|
||||
void childJarWinsOverParent()
|
||||
{
|
||||
writeVersion(m_game, "1.20.1", modernVanilla(), true);
|
||||
writeVersion(m_game, "custom", "{\"id\":\"custom\",\"inheritsFrom\":\"1.20.1\"}", true);
|
||||
const MinecraftVersion v = VersionLoader::load(m_game, "custom", {}, nullptr);
|
||||
QCOMPARE(v.clientJarPath, m_game + "/versions/custom/custom.jar");
|
||||
QCOMPARE(v.mainClass, QStringLiteral("net.minecraft.client.main.Main"));
|
||||
}
|
||||
|
||||
void loadErrors()
|
||||
{
|
||||
QString error;
|
||||
MinecraftVersion v = VersionLoader::load(m_game, "missing", {}, &error);
|
||||
QVERIFY(!v.isValid());
|
||||
QVERIFY(error.contains(QDir::toNativeSeparators(m_game + "/versions/missing/missing.json")));
|
||||
|
||||
writeVersion(m_game, "broken", "{ nope", false);
|
||||
error.clear();
|
||||
v = VersionLoader::load(m_game, "broken", {}, &error);
|
||||
QVERIFY(!v.isValid());
|
||||
QVERIFY(!error.isEmpty());
|
||||
|
||||
writeVersion(m_game, "a", "{\"id\":\"a\",\"inheritsFrom\":\"b\",\"mainClass\":\"M\"}", false);
|
||||
writeVersion(m_game, "b", "{\"id\":\"b\",\"inheritsFrom\":\"a\"}", false);
|
||||
error.clear();
|
||||
v = VersionLoader::load(m_game, "a", {}, &error);
|
||||
QVERIFY(!v.isValid());
|
||||
QCOMPARE(error, Loc::text("version.error.inheritsCycle").arg("a"));
|
||||
|
||||
// Цепочка длиннее 10 уровней.
|
||||
for (int i = 0; i < 12; ++i)
|
||||
writeVersion(m_game, QStringLiteral("v%1").arg(i),
|
||||
QStringLiteral("{\"inheritsFrom\":\"v%1\",\"mainClass\":\"M\"}").arg(i + 1).toUtf8(), false);
|
||||
writeVersion(m_game, "v12", "{\"mainClass\":\"M\"}", false);
|
||||
error.clear();
|
||||
v = VersionLoader::load(m_game, "v0", {}, &error);
|
||||
QVERIFY(!v.isValid());
|
||||
QCOMPARE(error, Loc::text("common.error.inheritsChainTooLong").arg("v0"));
|
||||
|
||||
writeVersion(m_game, "nomain", "{\"id\":\"nomain\"}", false);
|
||||
error.clear();
|
||||
v = VersionLoader::load(m_game, "nomain", {}, &error);
|
||||
QVERIFY(!v.isValid());
|
||||
QCOMPARE(error, Loc::text("version.error.noMainClass").arg("nomain"));
|
||||
}
|
||||
|
||||
// --- инвентарь --------------------------------------------------------------
|
||||
|
||||
void installedDependentsSizeRemove()
|
||||
{
|
||||
writeVersion(m_game, "1.20.1", modernVanilla(), true);
|
||||
writeVersion(m_game, "forge-1", "{\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"F\"}", false);
|
||||
writeVersion(m_game, "fabric-1", "{\"inheritsFrom\":\"1.20.1\",\"mainClass\":\"F\"}", false);
|
||||
writeVersion(m_game, "other", "{\"inheritsFrom\":\"1.19\",\"mainClass\":\"F\"}", false);
|
||||
QDir().mkpath(m_game + "/versions/emptydir"); // без json — не версия
|
||||
|
||||
QCOMPARE(VersionLoader::installedVersions(m_game),
|
||||
QStringList({"1.20.1", "fabric-1", "forge-1", "other"}));
|
||||
QStringList dependents = VersionLoader::dependentsOf(m_game, "1.20.1");
|
||||
dependents.sort();
|
||||
QCOMPARE(dependents, QStringList({"fabric-1", "forge-1"}));
|
||||
QVERIFY(VersionLoader::dependentsOf(m_game, QString()).isEmpty());
|
||||
|
||||
const qint64 expected = QFileInfo(m_game + "/versions/1.20.1/1.20.1.json").size()
|
||||
+ QFileInfo(m_game + "/versions/1.20.1/1.20.1.jar").size();
|
||||
QCOMPARE(VersionLoader::installedSize(m_game, "1.20.1"), expected);
|
||||
QCOMPARE(VersionLoader::installedSize(m_game, "../.."), qint64(0));
|
||||
|
||||
QString error;
|
||||
QVERIFY(VersionLoader::remove(m_game, "forge-1", &error));
|
||||
QVERIFY(!QDir(m_game + "/versions/forge-1").exists());
|
||||
QVERIFY(VersionLoader::remove(m_game, "forge-1", &error)); // уже нет — не ошибка
|
||||
QCOMPARE(VersionLoader::installedVersions(m_game), QStringList({"1.20.1", "fabric-1", "other"}));
|
||||
}
|
||||
|
||||
void removeRejectsUnsafeIds_data()
|
||||
{
|
||||
QTest::addColumn<QString>("id");
|
||||
QTest::newRow("empty") << "";
|
||||
QTest::newRow("dotdot") << "..";
|
||||
QTest::newRow("hidden") << ".git";
|
||||
QTest::newRow("slash") << "a/b";
|
||||
QTest::newRow("backslash") << "a\\b";
|
||||
}
|
||||
|
||||
void removeRejectsUnsafeIds()
|
||||
{
|
||||
QFETCH(QString, id);
|
||||
Fixtures::write(m_game + "/keep.txt", "keep");
|
||||
QString error;
|
||||
QVERIFY(!VersionLoader::remove(m_game, id, &error));
|
||||
QCOMPARE(error, Loc::text("version.error.invalidId"));
|
||||
QVERIFY(QFile::exists(m_game + "/keep.txt"));
|
||||
error.clear();
|
||||
QVERIFY(!VersionLoader::remove(QString(), "1.20.1", &error));
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestMinecraftVersion)
|
||||
#include "tst_minecraftversion.moc"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "msaauthservice.h"
|
||||
|
||||
#include <QUrlQuery>
|
||||
|
||||
class TestMsaRedirect : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void authorizationUrlCarriesClientAndRedirect()
|
||||
{
|
||||
const QUrl url = MsaAuthService::authorizationUrl();
|
||||
QCOMPARE(url.scheme(), QStringLiteral("https"));
|
||||
QCOMPARE(url.host(), QStringLiteral("login.live.com"));
|
||||
const QUrlQuery query(url);
|
||||
QCOMPARE(query.queryItemValue("client_id"), MsaAuthService::clientId());
|
||||
QCOMPARE(query.queryItemValue("response_type"), QStringLiteral("code"));
|
||||
QVERIFY(!query.queryItemValue("scope").isEmpty());
|
||||
// Код возвращается на тот адрес, который потом распознаёт matchRedirect.
|
||||
const QUrl redirect(query.queryItemValue("redirect_uri", QUrl::FullyDecoded));
|
||||
QString code;
|
||||
QVERIFY(MsaAuthService::matchRedirect(QUrl(redirect.toString() + "?code=abc"), &code, nullptr));
|
||||
QCOMPARE(code, QStringLiteral("abc"));
|
||||
}
|
||||
|
||||
void matchRedirect_data()
|
||||
{
|
||||
QTest::addColumn<QString>("url");
|
||||
QTest::addColumn<bool>("matches");
|
||||
QTest::addColumn<QString>("code");
|
||||
QTest::addColumn<QString>("error");
|
||||
|
||||
QTest::newRow("code in query")
|
||||
<< "https://login.live.com/oauth20_desktop.srf?code=M.C123-abc&lc=1049" << true << "M.C123-abc" << "";
|
||||
QTest::newRow("code in fragment")
|
||||
<< "https://login.live.com/oauth20_desktop.srf#code=FRAG&state=1" << true << "FRAG" << "";
|
||||
QTest::newRow("encoded code")
|
||||
<< "https://login.live.com/oauth20_desktop.srf?code=a%2Bb%3D" << true << "a+b=" << "";
|
||||
QTest::newRow("error with description")
|
||||
<< "https://login.live.com/oauth20_desktop.srf?error=access_denied&error_description=The%20user%20denied"
|
||||
<< true << "" << "The user denied";
|
||||
QTest::newRow("error only")
|
||||
<< "https://login.live.com/oauth20_desktop.srf?error=server_error" << true << "" << "server_error";
|
||||
QTest::newRow("host case-insensitive")
|
||||
<< "https://LOGIN.LIVE.COM/oauth20_desktop.srf?code=x" << true << "x" << "";
|
||||
QTest::newRow("redirect without params")
|
||||
<< "https://login.live.com/oauth20_desktop.srf" << true << "" << "";
|
||||
QTest::newRow("authorize page") << "https://login.live.com/oauth20_authorize.srf?code=x" << false << "" << "";
|
||||
QTest::newRow("foreign host") << "https://evil.example/oauth20_desktop.srf?code=x" << false << "" << "";
|
||||
QTest::newRow("empty") << "" << false << "" << "";
|
||||
}
|
||||
|
||||
void matchRedirect()
|
||||
{
|
||||
QFETCH(QString, url);
|
||||
QFETCH(bool, matches);
|
||||
QFETCH(QString, code);
|
||||
QFETCH(QString, error);
|
||||
|
||||
QString actualCode = QStringLiteral("untouched");
|
||||
QString actualError = QStringLiteral("untouched");
|
||||
QCOMPARE(MsaAuthService::matchRedirect(QUrl(url), &actualCode, &actualError), matches);
|
||||
if (matches) {
|
||||
QCOMPARE(actualCode, code);
|
||||
QCOMPARE(actualError, error);
|
||||
} else {
|
||||
QCOMPARE(actualCode, QStringLiteral("untouched"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
BILAUNCHER_TEST_MAIN(TestMsaRedirect)
|
||||
#include "tst_msaredirect.moc"
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "localization.h"
|
||||
#include "zlibreference.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QProcessEnvironment>
|
||||
|
||||
class TestZlibReference : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void nonLinuxIsNoop()
|
||||
{
|
||||
#if defined(Q_OS_LINUX)
|
||||
QSKIP("только для Windows и macOS");
|
||||
#else
|
||||
QVERIFY(!ZlibReference::systemIsZlibNg());
|
||||
QVERIFY(ZlibReference::bundledPath().isEmpty());
|
||||
QProcessEnvironment env;
|
||||
env.insert(QStringLiteral("LD_PRELOAD"), QStringLiteral("/x.so"));
|
||||
QString note = QStringLiteral("stale");
|
||||
QVERIFY(ZlibReference::applyTo(env, ¬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"
|
||||
Reference in new issue
Block a user