1 parent
ebf8ff7869
commit
41599fa82f
67 files changed
+8775
-4
No files matched your search
@@ -0,0 +1,410 @@
|
||||
#include "fakeinternet.h"
|
||||
|
||||
#include "fixtures.h"
|
||||
|
||||
#include <QEventLoop>
|
||||
#include <QHostAddress>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkProxy>
|
||||
#include <QSslConfiguration>
|
||||
#include <QSslKey>
|
||||
#include <QSslSocket>
|
||||
#include <QTimer>
|
||||
|
||||
struct FakeInternet::Connection
|
||||
{
|
||||
QSslSocket *socket = nullptr;
|
||||
QByteArray buffer;
|
||||
QString tunnelHost; // непусто — внутри CONNECT-туннеля (https)
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
QByteArray reasonPhrase(int status)
|
||||
{
|
||||
switch (status) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 204: return "No Content";
|
||||
case 301: return "Moved Permanently";
|
||||
case 302: return "Found";
|
||||
case 304: return "Not Modified";
|
||||
case 400: return "Bad Request";
|
||||
case 401: return "Unauthorized";
|
||||
case 403: return "Forbidden";
|
||||
case 404: return "Not Found";
|
||||
case 410: return "Gone";
|
||||
case 429: return "Too Many Requests";
|
||||
case 500: return "Internal Server Error";
|
||||
case 502: return "Bad Gateway";
|
||||
case 503: return "Service Unavailable";
|
||||
default: return "Status";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QUrl FakeRequest::url() const
|
||||
{
|
||||
QUrl result;
|
||||
result.setScheme(scheme);
|
||||
result.setHost(host);
|
||||
result.setPath(path);
|
||||
if (!query.isEmpty())
|
||||
result.setQuery(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::json(const QByteArray &body, int status)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.status = status;
|
||||
response.body = body;
|
||||
response.contentType = "application/json";
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::json(const QJsonObject &object, int status)
|
||||
{
|
||||
return json(QJsonDocument(object).toJson(QJsonDocument::Compact), status);
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::text(const QByteArray &body, int status)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.status = status;
|
||||
response.body = body;
|
||||
response.contentType = "text/plain; charset=utf-8";
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::bytes(const QByteArray &body)
|
||||
{
|
||||
FakeResponse response;
|
||||
response.body = body;
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeResponse FakeResponse::error(int status, const QByteArray &body)
|
||||
{
|
||||
FakeResponse response = text(body.isEmpty() ? reasonPhrase(status) : body, status);
|
||||
return response;
|
||||
}
|
||||
|
||||
FakeInternet::FakeInternet(QObject *parent)
|
||||
: QTcpServer(parent)
|
||||
{
|
||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||
}
|
||||
|
||||
FakeInternet::~FakeInternet()
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
bool FakeInternet::tlsAvailable(QString *why)
|
||||
{
|
||||
if (!QSslSocket::supportsSsl()) {
|
||||
if (why)
|
||||
*why = QStringLiteral("в этой сборке Qt нет рабочего TLS-бэкенда");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FakeInternet::start(QString *error)
|
||||
{
|
||||
if (m_started)
|
||||
return true;
|
||||
if (!tlsAvailable(error))
|
||||
return false;
|
||||
|
||||
m_certificate = Fixtures::read(QStringLiteral("tls/cert.pem"));
|
||||
m_key = Fixtures::read(QStringLiteral("tls/key.pem"));
|
||||
|
||||
if (!listen(QHostAddress::LocalHost, 0)) {
|
||||
if (error)
|
||||
*error = errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QNetworkProxy::setApplicationProxy(
|
||||
QNetworkProxy(QNetworkProxy::HttpProxy, QStringLiteral("127.0.0.1"), serverPort()));
|
||||
|
||||
QSslConfiguration ssl = QSslConfiguration::defaultConfiguration();
|
||||
ssl.setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||
QSslConfiguration::setDefaultConfiguration(ssl);
|
||||
|
||||
m_started = true;
|
||||
|
||||
// Самопроверка: TLS-бэкенд может не уметь серверный режим (бывает на
|
||||
// отдельных сборках Qt под Windows/macOS). Лучше узнать сразу и внятно,
|
||||
// чем получить десятки непонятных падений в тестах сервисов.
|
||||
const QString probe = QStringLiteral("https://selftest.fake-internet.test/ping");
|
||||
route(probe, FakeResponse::text("pong"));
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl(probe)));
|
||||
QEventLoop loop;
|
||||
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
|
||||
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
if (!reply->isFinished())
|
||||
loop.exec();
|
||||
const bool ok = reply->isFinished() && reply->error() == QNetworkReply::NoError
|
||||
&& reply->readAll() == "pong";
|
||||
const QString why = reply->isFinished() ? reply->errorString() : QStringLiteral("таймаут 10 с");
|
||||
delete reply;
|
||||
reset();
|
||||
if (!ok) {
|
||||
stop();
|
||||
if (error)
|
||||
*error = QStringLiteral("TLS через локальный прокси не заработал (%1), TLS-бэкенд: %2")
|
||||
.arg(why, QSslSocket::activeBackend());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FakeInternet::stop()
|
||||
{
|
||||
if (!m_started)
|
||||
return;
|
||||
// Никуда наружу и после остановки: прокси на закрытый порт.
|
||||
close();
|
||||
m_started = false;
|
||||
}
|
||||
|
||||
void FakeInternet::route(const QString &pattern, const Handler &handler)
|
||||
{
|
||||
unroute(pattern);
|
||||
const QUrl url(pattern.endsWith(u'*') ? pattern.chopped(1) : pattern);
|
||||
Route route;
|
||||
route.pattern = pattern;
|
||||
route.host = url.host().toLower();
|
||||
route.path = url.path().isEmpty() ? QStringLiteral("/") : url.path();
|
||||
route.prefix = pattern.endsWith(u'*');
|
||||
route.handler = handler;
|
||||
m_routes << route;
|
||||
}
|
||||
|
||||
void FakeInternet::route(const QString &pattern, const FakeResponse &response)
|
||||
{
|
||||
route(pattern, [response](const FakeRequest &) { return response; });
|
||||
}
|
||||
|
||||
void FakeInternet::unroute(const QString &pattern)
|
||||
{
|
||||
m_routes.removeIf([&](const Route &route) { return route.pattern == pattern; });
|
||||
}
|
||||
|
||||
void FakeInternet::reset()
|
||||
{
|
||||
m_routes.clear();
|
||||
m_requests.clear();
|
||||
m_unmatched.clear();
|
||||
m_fallback = FakeResponse::error(404, "fake internet: no route");
|
||||
}
|
||||
|
||||
QList<FakeRequest> FakeInternet::requests() const
|
||||
{
|
||||
return m_requests;
|
||||
}
|
||||
|
||||
int FakeInternet::hits(const QString &url) const
|
||||
{
|
||||
const QUrl wanted(url);
|
||||
int count = 0;
|
||||
for (const FakeRequest &request : m_requests) {
|
||||
if (request.host.compare(wanted.host(), Qt::CaseInsensitive) == 0
|
||||
&& request.path == wanted.path())
|
||||
++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
QStringList FakeInternet::unmatched() const
|
||||
{
|
||||
return m_unmatched;
|
||||
}
|
||||
|
||||
QStringList FakeInternet::requestedUrls() const
|
||||
{
|
||||
QStringList result;
|
||||
for (const FakeRequest &request : m_requests)
|
||||
result << request.url().toString();
|
||||
return result;
|
||||
}
|
||||
|
||||
void FakeInternet::incomingConnection(qintptr descriptor)
|
||||
{
|
||||
auto *socket = new QSslSocket(this);
|
||||
if (!socket->setSocketDescriptor(descriptor)) {
|
||||
delete socket;
|
||||
return;
|
||||
}
|
||||
|
||||
auto *connection = new Connection;
|
||||
connection->socket = socket;
|
||||
|
||||
connect(socket, &QSslSocket::readyRead, this, [this, connection] { onReadyRead(connection); });
|
||||
connect(socket, &QSslSocket::sslErrors, socket, [socket](const QList<QSslError> &) {
|
||||
socket->ignoreSslErrors();
|
||||
});
|
||||
// Удаляем отложенно: disconnected может прийти синхронно посреди processBuffer().
|
||||
connect(socket, &QSslSocket::disconnected, this, [socket, connection] {
|
||||
socket->deleteLater();
|
||||
QObject::connect(socket, &QObject::destroyed, [connection] { delete connection; });
|
||||
});
|
||||
}
|
||||
|
||||
void FakeInternet::onReadyRead(Connection *connection)
|
||||
{
|
||||
connection->buffer += connection->socket->readAll();
|
||||
processBuffer(connection);
|
||||
}
|
||||
|
||||
void FakeInternet::processBuffer(Connection *connection)
|
||||
{
|
||||
for (;;) {
|
||||
const qsizetype headerEnd = connection->buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0)
|
||||
return;
|
||||
|
||||
const QList<QByteArray> lines = connection->buffer.left(headerEnd).split('\n');
|
||||
const QList<QByteArray> requestLine = lines.value(0).trimmed().split(' ');
|
||||
if (requestLine.size() < 2) {
|
||||
connection->socket->abort();
|
||||
return;
|
||||
}
|
||||
|
||||
FakeRequest request;
|
||||
request.method = requestLine.at(0);
|
||||
for (qsizetype i = 1; i < lines.size(); ++i) {
|
||||
const QByteArray line = lines.at(i).trimmed();
|
||||
const qsizetype colon = line.indexOf(':');
|
||||
if (colon > 0)
|
||||
request.headers.insert(line.left(colon).trimmed().toLower(),
|
||||
line.mid(colon + 1).trimmed());
|
||||
}
|
||||
|
||||
const qsizetype bodySize = request.headers.value("content-length", "0").toLongLong();
|
||||
if (connection->buffer.size() < headerEnd + 4 + bodySize)
|
||||
return; // тело ещё не дошло
|
||||
request.body = connection->buffer.mid(headerEnd + 4, bodySize);
|
||||
connection->buffer.remove(0, headerEnd + 4 + bodySize);
|
||||
|
||||
const QByteArray target = requestLine.at(1);
|
||||
|
||||
if (request.method == "CONNECT" && connection->tunnelHost.isEmpty()) {
|
||||
QString host = QString::fromLatin1(target);
|
||||
host = host.left(host.lastIndexOf(u':')).toLower();
|
||||
connection->tunnelHost = host;
|
||||
connection->socket->write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
connection->socket->flush();
|
||||
startTls(connection);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!connection->tunnelHost.isEmpty()) {
|
||||
request.scheme = QStringLiteral("https");
|
||||
request.host = connection->tunnelHost;
|
||||
const QUrl relative(QString::fromLatin1(target));
|
||||
request.path = relative.path(QUrl::FullyDecoded);
|
||||
request.query = relative.query(QUrl::FullyEncoded);
|
||||
} else {
|
||||
// Простой http через прокси — адрес в absolute-form.
|
||||
const QUrl absolute(QString::fromLatin1(target));
|
||||
request.scheme = absolute.scheme();
|
||||
request.host = absolute.host().toLower();
|
||||
request.path = absolute.path(QUrl::FullyDecoded);
|
||||
request.query = absolute.query(QUrl::FullyEncoded);
|
||||
}
|
||||
if (request.path.isEmpty())
|
||||
request.path = QStringLiteral("/");
|
||||
|
||||
dispatch(connection, request);
|
||||
}
|
||||
}
|
||||
|
||||
void FakeInternet::startTls(Connection *connection)
|
||||
{
|
||||
QSslSocket *socket = connection->socket;
|
||||
socket->setLocalCertificate(QSslCertificate(m_certificate, QSsl::Pem));
|
||||
socket->setPrivateKey(QSslKey(m_key, QSsl::Rsa, QSsl::Pem));
|
||||
socket->setPeerVerifyMode(QSslSocket::VerifyNone);
|
||||
socket->startServerEncryption();
|
||||
}
|
||||
|
||||
const FakeInternet::Route *FakeInternet::match(const FakeRequest &request) const
|
||||
{
|
||||
// Последний добавленный маршрут главнее: тест может перекрыть общий частным.
|
||||
for (qsizetype i = m_routes.size() - 1; i >= 0; --i) {
|
||||
const Route &route = m_routes.at(i);
|
||||
if (route.host != request.host)
|
||||
continue;
|
||||
if (route.prefix ? request.path.startsWith(route.path) : request.path == route.path)
|
||||
return &route;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void FakeInternet::dispatch(Connection *connection, FakeRequest request)
|
||||
{
|
||||
m_requests << request;
|
||||
const QString url = request.url().toString();
|
||||
|
||||
FakeResponse response;
|
||||
if (const Route *route = match(request)) {
|
||||
response = route->handler(request);
|
||||
} else {
|
||||
m_unmatched << url;
|
||||
qWarning("FakeInternet: нет маршрута для %s %s", request.method.constData(),
|
||||
qPrintable(url));
|
||||
response = m_fallback;
|
||||
}
|
||||
|
||||
const bool keepAlive = request.header("connection").toLower() != "close";
|
||||
QPointer<QTcpSocket> socket(connection->socket);
|
||||
if (response.hang)
|
||||
return;
|
||||
if (response.delayMs > 0) {
|
||||
QTimer::singleShot(response.delayMs, this,
|
||||
[this, socket, response, keepAlive] { respond(socket, response, keepAlive); });
|
||||
} else {
|
||||
respond(socket, response, keepAlive);
|
||||
}
|
||||
emit requestServed(url);
|
||||
}
|
||||
|
||||
void FakeInternet::respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive)
|
||||
{
|
||||
if (!socket || socket->state() != QAbstractSocket::ConnectedState)
|
||||
return;
|
||||
if (response.drop) {
|
||||
socket->abort();
|
||||
return;
|
||||
}
|
||||
|
||||
QByteArray head = "HTTP/1.1 " + QByteArray::number(response.status) + ' '
|
||||
+ reasonPhrase(response.status) + "\r\n";
|
||||
head += "Content-Type: " + response.contentType + "\r\n";
|
||||
head += "Content-Length: " + QByteArray::number(response.body.size()) + "\r\n";
|
||||
for (const auto &[name, value] : response.headers)
|
||||
head += name + ": " + value + "\r\n";
|
||||
const bool close = !keepAlive || response.truncateTo >= 0;
|
||||
head += close ? "Connection: close\r\n" : "Connection: keep-alive\r\n";
|
||||
head += "\r\n";
|
||||
|
||||
socket->write(head);
|
||||
if (response.truncateTo >= 0) {
|
||||
socket->write(response.body.left(response.truncateTo));
|
||||
socket->flush();
|
||||
socket->disconnectFromHost();
|
||||
return;
|
||||
}
|
||||
socket->write(response.body);
|
||||
if (close)
|
||||
socket->disconnectFromHost();
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#pragma once
|
||||
|
||||
// «Фейковый интернет» для тестов сетевых сервисов без правки их кода.
|
||||
//
|
||||
// Сервисы лаунчера ходят на захардкоженные https-адреса через собственные
|
||||
// QNetworkAccessManager. Прокси и SSL-конфигурация по умолчанию у Qt общие
|
||||
// на весь процесс, поэтому start():
|
||||
// - ставит QNetworkProxy::setApplicationProxy() на локальный порт;
|
||||
// - отключает проверку сертификата в QSslConfiguration::defaultConfiguration().
|
||||
// Дальше каждое https-соединение приходит сюда как CONNECT host:443; сокет
|
||||
// переводится в TLS с тестовым самоподписанным сертификатом и обслуживается
|
||||
// как обычный HTTP/1.1 — по таблице маршрутов (host + path).
|
||||
//
|
||||
// Запрос на неизвестный адрес получает 404 и попадает в unmatched(): тест
|
||||
// может проверить, что код не ушёл куда-то, куда не ожидали.
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTcpServer>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
|
||||
#include <functional>
|
||||
|
||||
class QSslSocket;
|
||||
class QTcpSocket;
|
||||
|
||||
struct FakeRequest
|
||||
{
|
||||
QByteArray method;
|
||||
QString scheme; // https | http
|
||||
QString host;
|
||||
QString path;
|
||||
QString query; // без «?»
|
||||
QHash<QByteArray, QByteArray> headers; // имена в нижнем регистре
|
||||
QByteArray body;
|
||||
|
||||
QUrl url() const;
|
||||
QUrlQuery queryItems() const { return QUrlQuery(query); }
|
||||
QUrlQuery formItems() const { return QUrlQuery(QString::fromUtf8(body)); }
|
||||
QByteArray header(const QByteArray &name) const { return headers.value(name.toLower()); }
|
||||
};
|
||||
|
||||
struct FakeResponse
|
||||
{
|
||||
int status = 200;
|
||||
QByteArray body;
|
||||
QByteArray contentType = "application/octet-stream";
|
||||
QList<QPair<QByteArray, QByteArray>> headers;
|
||||
int delayMs = 0; // ответить с задержкой
|
||||
bool hang = false; // не отвечать вовсе (для отмены и таймаутов)
|
||||
bool drop = false; // оборвать соединение без ответа
|
||||
int truncateTo = -1; // объявить полный размер, отдать столько байт и оборвать
|
||||
|
||||
static FakeResponse json(const QByteArray &body, int status = 200);
|
||||
static FakeResponse json(const class QJsonObject &object, int status = 200);
|
||||
static FakeResponse text(const QByteArray &body, int status = 200);
|
||||
static FakeResponse bytes(const QByteArray &body);
|
||||
static FakeResponse error(int status, const QByteArray &body = {});
|
||||
};
|
||||
|
||||
class FakeInternet : public QTcpServer
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using Handler = std::function<FakeResponse(const FakeRequest &)>;
|
||||
|
||||
explicit FakeInternet(QObject *parent = nullptr);
|
||||
~FakeInternet() override;
|
||||
|
||||
// Слушает 127.0.0.1 и перенаправляет на себя весь трафик процесса.
|
||||
// При ошибке пишет причину в error (например, нет TLS-бэкенда с поддержкой сервера).
|
||||
bool start(QString *error = nullptr);
|
||||
void stop();
|
||||
|
||||
// Есть ли TLS вообще (без него сетевые тесты пропускаются).
|
||||
static bool tlsAvailable(QString *why = nullptr);
|
||||
|
||||
// Маршрут: "https://host/path" — точное совпадение пути (query не учитывается),
|
||||
// "https://host/prefix/*" — всё, что начинается с prefix/.
|
||||
void route(const QString &pattern, const Handler &handler);
|
||||
void route(const QString &pattern, const FakeResponse &response);
|
||||
void unroute(const QString &pattern);
|
||||
|
||||
// Ответ на всё, что не подошло ни к одному маршруту (по умолчанию 404).
|
||||
void setFallback(const FakeResponse &response) { m_fallback = response; }
|
||||
|
||||
// Сбросить маршруты и журнал.
|
||||
void reset();
|
||||
|
||||
QList<FakeRequest> requests() const;
|
||||
// Сколько раз был запрошен адрес "https://host/path" (query не учитывается).
|
||||
int hits(const QString &url) const;
|
||||
QStringList unmatched() const;
|
||||
QStringList requestedUrls() const;
|
||||
|
||||
signals:
|
||||
void requestServed(const QString &url);
|
||||
|
||||
protected:
|
||||
void incomingConnection(qintptr descriptor) override;
|
||||
|
||||
private:
|
||||
struct Connection;
|
||||
struct Route
|
||||
{
|
||||
QString pattern;
|
||||
QString host;
|
||||
QString path;
|
||||
bool prefix = false;
|
||||
Handler handler;
|
||||
};
|
||||
|
||||
void onReadyRead(Connection *connection);
|
||||
void processBuffer(Connection *connection);
|
||||
void dispatch(Connection *connection, FakeRequest request);
|
||||
void respond(QPointer<QTcpSocket> socket, const FakeResponse &response, bool keepAlive);
|
||||
void startTls(Connection *connection);
|
||||
const Route *match(const FakeRequest &request) const;
|
||||
|
||||
QList<Route> m_routes;
|
||||
FakeResponse m_fallback;
|
||||
QList<FakeRequest> m_requests;
|
||||
QStringList m_unmatched;
|
||||
QByteArray m_certificate;
|
||||
QByteArray m_key;
|
||||
bool m_started = false;
|
||||
};
|
||||
@@ -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 на Linux) недоступность —
|
||||
// ошибка, а не пропуск: иначе сломанный перехват тихо выключил бы все сетевые тесты.
|
||||
#define BILAUNCHER_START_FAKE_INTERNET(net) \
|
||||
do { \
|
||||
QString startError_; \
|
||||
if (!(net).start(&startError_)) { \
|
||||
const QString message_ = QStringLiteral("FakeInternet недоступен: ") + startError_; \
|
||||
if (qEnvironmentVariableIntValue("BILAUNCHER_REQUIRE_FAKE_INTERNET")) \
|
||||
QFAIL(qPrintable(message_)); \
|
||||
QSKIP(qPrintable(message_)); \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
// Результат колбэка вида (bool ok, QString warning).
|
||||
struct CallResult
|
||||
{
|
||||
int calls = 0;
|
||||
bool ok = false;
|
||||
QString warning;
|
||||
|
||||
auto callback()
|
||||
{
|
||||
return [this](bool okValue, const QString &warningValue) {
|
||||
++calls;
|
||||
ok = okValue;
|
||||
warning = warningValue;
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "testenv.h"
|
||||
|
||||
#include "launcherpaths.h"
|
||||
#include "localization.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
|
||||
const char kMarker[] = ".bilauncher-test-sandbox";
|
||||
|
||||
QTemporaryDir *g_sandbox = nullptr;
|
||||
bool g_realProfile = false; // Windows: работаем в настоящем профиле под меткой
|
||||
|
||||
[[noreturn]] void abortSetup(const QString &message)
|
||||
{
|
||||
std::fprintf(stderr, "TestEnv: %s\n", qPrintable(message));
|
||||
std::exit(2);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TestEnv::prepareSandbox()
|
||||
{
|
||||
g_sandbox = new QTemporaryDir(QDir::tempPath() + QStringLiteral("/bilauncher-test-XXXXXX"));
|
||||
if (!g_sandbox->isValid())
|
||||
abortSetup(QStringLiteral("не удалось создать временную папку"));
|
||||
|
||||
const QByteArray home = QDir::toNativeSeparators(g_sandbox->path()).toLocal8Bit();
|
||||
qputenv("HOME", home);
|
||||
qputenv("USERPROFILE", home);
|
||||
// Чужая Java из окружения машины не должна влиять на выбор Java в тестах.
|
||||
qunsetenv("JAVA_HOME");
|
||||
|
||||
std::atexit([] {
|
||||
if (g_realProfile)
|
||||
QDir(LauncherPaths::rootDir()).removeRecursively();
|
||||
delete g_sandbox;
|
||||
g_sandbox = nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
void TestEnv::finishSetup()
|
||||
{
|
||||
QCoreApplication::setApplicationName(QStringLiteral("BiLauncher"));
|
||||
QCoreApplication::setOrganizationName(QStringLiteral("GaleonDev"));
|
||||
|
||||
const QString root = QDir::cleanPath(LauncherPaths::rootDir());
|
||||
const QString sandbox = QDir::cleanPath(g_sandbox->path());
|
||||
if (!root.startsWith(sandbox + u'/')) {
|
||||
#if defined(Q_OS_WIN)
|
||||
if (QDir(root).exists() && !QFile::exists(root + u'/' + QLatin1String(kMarker))) {
|
||||
abortSetup(QStringLiteral(
|
||||
"%1 уже существует и создана не тестами — отказываюсь её трогать. "
|
||||
"Запускайте тесты под отдельной учётной записью.")
|
||||
.arg(QDir::toNativeSeparators(root)));
|
||||
}
|
||||
g_realProfile = true;
|
||||
resetLauncherRoot();
|
||||
#else
|
||||
abortSetup(QStringLiteral("папка лаунчера %1 вне песочницы %2").arg(root, sandbox));
|
||||
#endif
|
||||
}
|
||||
|
||||
if (!Localization::instance().load())
|
||||
abortSetup(QStringLiteral("не загрузился каталог переводов"));
|
||||
Localization::instance().setLanguage(QStringLiteral("en"));
|
||||
}
|
||||
|
||||
QString TestEnv::homeDir()
|
||||
{
|
||||
return g_sandbox->path();
|
||||
}
|
||||
|
||||
QString TestEnv::freshDir(const QString &name)
|
||||
{
|
||||
const QString path = g_sandbox->path() + QStringLiteral("/work/") + name;
|
||||
QDir(path).removeRecursively();
|
||||
QDir().mkpath(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
void TestEnv::resetLauncherRoot()
|
||||
{
|
||||
const QString root = LauncherPaths::rootDir();
|
||||
QDir(root).removeRecursively();
|
||||
if (g_realProfile) {
|
||||
QDir().mkpath(root);
|
||||
QFile marker(root + u'/' + QLatin1String(kMarker));
|
||||
marker.open(QIODevice::WriteOnly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
// Общая обвязка тестов: песочница для домашней папки и main для QtTest.
|
||||
//
|
||||
// LauncherPaths строит все пути от QDir::homePath(). На Linux и macOS он берёт
|
||||
// HOME, поэтому каждый тестовый процесс до создания QCoreApplication
|
||||
// подменяет HOME на свою временную папку — реальные ~/BiLauncher и .minecraft
|
||||
// машины, на которой гоняются тесты, не трогаются.
|
||||
//
|
||||
// На Windows QDir::homePath() спрашивает профиль у системы и переменные
|
||||
// окружения игнорирует. Там тесты работают в настоящей
|
||||
// %APPDATA%\BiLauncher, но только если её ещё нет либо она создана тестами
|
||||
// (в ней лежит метка .bilauncher-test-sandbox). Иначе процесс отказывается
|
||||
// запускаться, чтобы не испортить данные живого лаунчера.
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QString>
|
||||
#include <QtTest/QTest>
|
||||
|
||||
namespace TestEnv {
|
||||
|
||||
// До QCoreApplication: временная папка и подмена HOME/USERPROFILE.
|
||||
void prepareSandbox();
|
||||
|
||||
// После QCoreApplication: проверка, что пути лаунчера ушли в песочницу,
|
||||
// загрузка переводов, английский язык (тексты ошибок не зависят от локали машины).
|
||||
void finishSetup();
|
||||
|
||||
// Корень песочницы — то, что сейчас возвращает QDir::homePath() в тестах.
|
||||
QString homeDir();
|
||||
|
||||
// Свежая пустая папка внутри песочницы (для .minecraft, архивов и т. п.).
|
||||
QString freshDir(const QString &name);
|
||||
|
||||
// Стирает папку лаунчера (LauncherPaths::rootDir()) — между тестами.
|
||||
void resetLauncherRoot();
|
||||
|
||||
} // namespace TestEnv
|
||||
|
||||
#define BILAUNCHER_TEST_MAIN_IMPL(AppType, TestObject) \
|
||||
int main(int argc, char *argv[]) \
|
||||
{ \
|
||||
TestEnv::prepareSandbox(); \
|
||||
AppType app(argc, argv); \
|
||||
TestEnv::finishSetup(); \
|
||||
TestObject tc; \
|
||||
QTEST_SET_MAIN_SOURCE_PATH \
|
||||
return QTest::qExec(&tc, argc, argv); \
|
||||
}
|
||||
|
||||
#define BILAUNCHER_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QCoreApplication, TestObject)
|
||||
|
||||
// Тесту с GUI нужно самому подключить <QGuiApplication>.
|
||||
#define BILAUNCHER_GUI_TEST_MAIN(TestObject) BILAUNCHER_TEST_MAIN_IMPL(QGuiApplication, TestObject)
|
||||
Reference in new issue
Block a user