diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 1055ce5..6afa466 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -12,12 +12,13 @@ concurrency: group: tests-${{ github.ref }} cancel-in-progress: true +env: + BILAUNCHER_REQUIRE_FAKE_INTERNET: "1" + jobs: linux: runs-on: linux timeout-minutes: 90 - env: - BILAUNCHER_REQUIRE_FAKE_INTERNET: "1" steps: - uses: actions/checkout@v4 @@ -81,7 +82,9 @@ jobs: 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: Отчёты diff --git a/BuildsDialog.qml b/BuildsDialog.qml index 3731d21..a86b0f6 100644 --- a/BuildsDialog.qml +++ b/BuildsDialog.qml @@ -8,6 +8,7 @@ Dialog { id: buildsDialog required property var backend + enabled: !buildsDialog.backend.seasonalInstalling property int editIndex: -1 property bool loading: false diff --git a/Main.qml b/Main.qml index 9b0ce5e..0a9562f 100644 --- a/Main.qml +++ b/Main.qml @@ -427,7 +427,7 @@ 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 { diff --git a/README.md b/README.md index b749f26..fed95f8 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ drawing

-

Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависиомстей для конечных пользователей

+

Сезонные сборки: централизованное управление сборками со стороны команды разработчика, полная автоматизация скачивания, установки и разрешения зависимостей для конечных пользователей

drawing diff --git a/doc/testing.md b/doc/testing.md index 1127a9d..6fa45fc 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -87,7 +87,8 @@ CMake, тесты появятся в панели «Тесты». в TLS с тестовым сертификатом из `tests/fixtures/tls/` и обслуживается по таблице маршрутов. Запрос на адрес без маршрута получает 404 и попадает в `unmatched()`. При старте сервер проверяет себя сам. Если TLS-бэкенд Qt не умеет серверный режим, сетевые тесты пропускаются с понятным -сообщением, а в CI на Linux (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка. +сообщением, а в CI на всех ОС (`BILAUNCHER_REQUIRE_FAKE_INTERNET=1`) это ошибка. +QML-тесты также завершаются ошибкой, если стенд не поднялся. `FakeMojang` (`tests/common/fakemojang.h`) публикует через `FakeInternet` крошечную, но устроенную как настоящая версию игры: json, `client.jar`, библиотеки, натив под текущую ОС, индекс ресурсов, @@ -112,7 +113,6 @@ CMake, тесты появятся в панели «Тесты». | Тест | Проблема | |------|----------| | `tst_versioninstaller::retryOfLastFileIsNotLost` | если временный сбой (503, таймаут) или неверная контрольная сумма пришлись на последний файл, `retryOrFail()` откладывает повтор на 400 мс, а `pump()` сразу видит пустую очередь и завершает установку ошибкой «файлы отсутствуют» | -| `tst_modloaderversionservice::neoForgeLegacyArtifactArrivesFirst` | если ответ старого артефакта `net.neoforged:forge` приходит раньше основного, основной затирает слитые записи (`state->merged = parsed`), и версии NeoForge для 1.20.1 пропадают | | `tst_buildarchiveworker::dotFilesSurviveRoundTrip` | `QZipReader` срезает ведущую точку в именах записей, и после переключения сборок `.fabric/`, `.mixin.out/` возвращаются как `fabric/`, `mixin.out/` | | `tst_javainstaller::cancelDuringDownload` | `JavaInstaller::cancel()` шлёт `canceled` дважды: `abort()` синхронно доводит ответ до обработчика, который уже шлёт сигнал | | `tst_gamelauncher::extractNatives` | битый jar с нативами не считается ошибкой: `QZipReader::isReadable()` проверяет только, что файл открылся | @@ -157,7 +157,8 @@ WebEngine задерживала бы релизы. Перед выпуском 4. Для сети поднимите `FakeInternet` в `initTestCase()` через `BILAUNCHER_START_FAKE_INTERNET(m_net)` и опишите маршруты: `m_net.route("https://host/path", FakeResponse::json(...))`. Для префикса поставьте `*` в конце, для ошибок, задержек и обрывов есть `FakeResponse::error`, `delayMs`, - `hang`, `drop`, `truncateTo`. + `hang`, `drop`, `truncateTo`. Для заданного порядка ответов установите `deferred = true` + и вызовите `releaseResponse(url)` после обработки другого ответа клиентом. 5. Крупные ответы серверов кладите в `tests/fixtures/`. Если в json нужны имя ОС, архитектура или классификатор нативов, используйте подстановки `%OS%`, `%OTHER%`, `%ARCH%`, `%NATIVE%` и `Fixtures::readTemplate()`. diff --git a/modloaderversionservice.cpp b/modloaderversionservice.cpp index 1ea8b9a..c49bd18 100644 --- a/modloaderversionservice.cpp +++ b/modloaderversionservice.cpp @@ -244,6 +244,7 @@ void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &game { int outstanding = 2; QHash> merged; + QHash> legacy; QHash 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(); }); diff --git a/tests/common/fakeinternet.cpp b/tests/common/fakeinternet.cpp index 410aa90..78232fe 100644 --- a/tests/common/fakeinternet.cpp +++ b/tests/common/fakeinternet.cpp @@ -201,12 +201,32 @@ void FakeInternet::unroute(const QString &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 FakeInternet::requests() const { return m_requests; @@ -366,9 +386,13 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request) } const bool keepAlive = request.header("connection").toLower() != "close"; - QPointer socket(connection->socket); + QPointer 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); }); @@ -378,7 +402,7 @@ void FakeInternet::dispatch(Connection *connection, FakeRequest request) emit requestServed(url); } -void FakeInternet::respond(QPointer socket, const FakeResponse &response, bool keepAlive) +void FakeInternet::respond(QPointer socket, const FakeResponse &response, bool keepAlive) { if (!socket || socket->state() != QAbstractSocket::ConnectedState) return; @@ -397,20 +421,28 @@ void FakeInternet::respond(QPointer socket, const FakeResponse &resp head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n"; head += "\r\n"; - socket->write(head); if (response.truncateTo >= 0) { - socket->write(response.body.left(response.truncateTo)); - socket->flush(); - socket->disconnectFromHost(); - // Schannel (Windows) на disconnectFromHost шлёт только TLS close_notify и держит - // TCP открытым, ожидая ответного close_notify, — клиент тогда висит до таймаута. - // Обрыв на то и обрыв: если сокет не закрылся сам, рвём соединение. - QTimer::singleShot(200, socket, [socket] { - if (socket && socket->state() != QAbstractSocket::UnconnectedState) + 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(); diff --git a/tests/common/fakeinternet.h b/tests/common/fakeinternet.h index 6e7f401..20d57c5 100644 --- a/tests/common/fakeinternet.h +++ b/tests/common/fakeinternet.h @@ -56,6 +56,7 @@ struct FakeResponse QList> headers; int delayMs = 0; // ответить с задержкой bool hang = false; // не отвечать вовсе (для отмены и таймаутов) + bool deferred = false; // ждать явного releaseResponse(), без таймера bool drop = false; // оборвать соединение без ответа int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать @@ -95,6 +96,8 @@ public: // Сбросить маршруты и журнал. void reset(); + bool releaseResponse(const QString &url); + QString diagnostics() const; QList requests() const; // Сколько раз был запрошен адрес "https://host/path" (query не учитывается). @@ -122,7 +125,7 @@ private: void onReadyRead(Connection *connection); void processBuffer(Connection *connection); void dispatch(Connection *connection, FakeRequest request); - void respond(QPointer socket, const FakeResponse &response, bool keepAlive); + void respond(QPointer socket, const FakeResponse &response, bool keepAlive); void startTls(Connection *connection); const Route *match(const FakeRequest &request) const; @@ -130,6 +133,15 @@ private: FakeResponse m_fallback; QList m_requests; QStringList m_unmatched; + struct DeferredResponse + { + QString url; + QPointer socket; + FakeResponse response; + bool keepAlive; + }; + QList m_deferred; + QStringList m_diagnostics; QByteArray m_certificate; QByteArray m_key; bool m_started = false; diff --git a/tests/common/nettest.h b/tests/common/nettest.h index 63b2918..e432301 100644 --- a/tests/common/nettest.h +++ b/tests/common/nettest.h @@ -8,7 +8,7 @@ #include // В initTestCase(): поднять FakeInternet или пропустить весь тест-класс. -// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на Linux) недоступность — +// С BILAUNCHER_REQUIRE_FAKE_INTERNET=1 (так в CI на всех ОС) недоступность — // ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты. #define BILAUNCHER_START_FAKE_INTERNET(net) \ do { \ diff --git a/tests/net/tst_fakeinternet.cpp b/tests/net/tst_fakeinternet.cpp index 7e7e395..e1a36c0 100644 --- a/tests/net/tst_fakeinternet.cpp +++ b/tests/net/tst_fakeinternet.cpp @@ -3,6 +3,7 @@ #include "testenv.h" #include "fakeinternet.h" +#include "nettest.h" #include #include @@ -30,9 +31,7 @@ class TestFakeInternet : public QObject private slots: void initTestCase() { - QString error; - if (!m_net.start(&error)) - QSKIP(qPrintable(QStringLiteral("FakeInternet недоступен: ") + error)); + BILAUNCHER_START_FAKE_INTERNET(m_net); } void init() { m_net.reset(); } @@ -100,12 +99,6 @@ private slots: response.drop = true; return response; }()); - m_net.route(QStringLiteral("https://a.test/short"), [] { - FakeResponse response = FakeResponse::bytes(QByteArray(1000, 'x')); - response.truncateTo = 10; - return response; - }()); - QNetworkAccessManager manager; QScopedPointer r500(get(manager, QStringLiteral("https://a.test/500"))); QVERIFY(waitFinished(r500.data())); @@ -114,10 +107,31 @@ private slots: QScopedPointer drop(get(manager, QStringLiteral("https://a.test/drop"))); QVERIFY(waitFinished(drop.data())); QVERIFY(drop->error() != QNetworkReply::NoError); + } + void truncatedResponse_data() + { + QTest::addColumn("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 shortReply(get(manager, QStringLiteral("https://a.test/short"))); - QVERIFY(waitFinished(shortReply.data())); + 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() diff --git a/tests/net/tst_modloaderversionservice.cpp b/tests/net/tst_modloaderversionservice.cpp index b6e86b2..b15751e 100644 --- a/tests/net/tst_modloaderversionservice.cpp +++ b/tests/net/tst_modloaderversionservice.cpp @@ -7,6 +7,8 @@ #include "modloaderversionservice.h" #include +#include +#include namespace { const QString kFabric = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/1.20.1"); @@ -55,6 +57,25 @@ class TestModLoaderVersionService : public QObject 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(); + 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); } @@ -167,33 +188,30 @@ private slots: void neoForgeLegacyArtifactArrivesLast() { // Ранние сборки под 1.20.1 — из старого артефакта net.neoforged:forge. - FakeResponse legacySlow = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")); - legacySlow.delayMs = 400; - m_net.route(kNeoLegacyMeta, legacySlow); + FakeResponse legacy = xml(Fixtures::read("loaders/neoforge-legacy-maven-metadata.xml")); + legacy.deferred = true; + m_net.route(kNeoLegacyMeta, legacy); ModLoaderVersionService service; CallResult r; - QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r)); + QVERIFY(loadNeoForgeInOrder(service, r, kNeoMeta, kNeoLegacyMeta)); QVERIFY(r.ok); - const QList legacy = service.versions(ModLoader::NeoForge, "1.20.1"); - QCOMPARE(loaderVersions(legacy), QStringList({"47.1.106", "47.1.79"})); - QCOMPARE(legacy.at(0).installerUrl, + const QList 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 primarySlow = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")); - primarySlow.delayMs = 400; - m_net.route(kNeoMeta, primarySlow); + // Основной ответ выпускается только после обработки legacy клиентом. + FakeResponse primary = xml(Fixtures::read("loaders/neoforge-maven-metadata.xml")); + primary.deferred = true; + m_net.route(kNeoMeta, primary); ModLoaderVersionService service; CallResult r; - QVERIFY(load(service, ModLoader::NeoForge, "1.20.1", r)); + QVERIFY(loadNeoForgeInOrder(service, r, kNeoLegacyMeta, kNeoMeta)); QVERIFY(r.ok); QCOMPARE(service.versions(ModLoader::NeoForge, "1.21.1").size(), 2); - // В startRefresh() основной ответ делает state->merged = parsed и затирает - // уже слитые записи legacy — версии NeoForge для 1.20.1 пропадают. - QEXPECT_FAIL("", "гонка: основной maven-metadata NeoForge затирает уже пришедший legacy", Continue); QCOMPARE(service.versions(ModLoader::NeoForge, "1.20.1").size(), 2); } diff --git a/tests/net/tst_seasonalpackdownloader.cpp b/tests/net/tst_seasonalpackdownloader.cpp index 4e973a0..a999ad4 100644 --- a/tests/net/tst_seasonalpackdownloader.cpp +++ b/tests/net/tst_seasonalpackdownloader.cpp @@ -94,7 +94,7 @@ private slots: m_net.route(kPack, cut); Run run(downloader); downloader.download(QUrl(kPack), m_dir + "/b.zip", Fixtures::sha256(QByteArray(4096, 'x')), 4096, "B"); - QVERIFY(run.waitEnd()); + QVERIFY2(run.waitEnd(), qPrintable(m_net.diagnostics())); QCOMPARE(run.failed.count(), 1); QVERIFY(!QFile::exists(m_dir + "/b.zip")); } diff --git a/tests/qml/tst_qml.cpp b/tests/qml/tst_qml.cpp index 908adbb..2809b0f 100644 --- a/tests/qml/tst_qml.cpp +++ b/tests/qml/tst_qml.cpp @@ -34,8 +34,9 @@ public slots: seedData(); // Диалоги создают настоящий LauncherBackend — наружу он не должен ходить. QString error; - if (m_net.start(&error)) - m_net.setFallback(FakeResponse::error(503)); + if (!m_net.start(&error)) + qFatal("FakeInternet: %s", qPrintable(error)); + m_net.setFallback(FakeResponse::error(503)); } void qmlEngineAvailable(QQmlEngine *engine) diff --git a/tests/qml/tst_qml_smoke.cpp b/tests/qml/tst_qml_smoke.cpp index ff0ba67..afdfe0f 100644 --- a/tests/qml/tst_qml_smoke.cpp +++ b/tests/qml/tst_qml_smoke.cpp @@ -49,8 +49,8 @@ private slots: void initTestCase() { QString error; - if (m_net.start(&error)) - m_net.setFallback(FakeResponse::error(503)); + 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"));