| /**************************************************************************** |
| ** |
| ** Copyright (C) 2016 The Qt Company Ltd. |
| ** Contact: https://www.qt.io/licensing/ |
| ** |
| ** This file is part of the test suite of the Qt Toolkit. |
| ** |
| ** $QT_BEGIN_LICENSE:GPL-EXCEPT$ |
| ** Commercial License Usage |
| ** Licensees holding valid commercial Qt licenses may use this file in |
| ** accordance with the commercial license agreement provided with the |
| ** Software or, alternatively, in accordance with the terms contained in |
| ** a written agreement between you and The Qt Company. For licensing terms |
| ** and conditions see https://www.qt.io/terms-conditions. For further |
| ** information use the contact form at https://www.qt.io/contact-us. |
| ** |
| ** GNU General Public License Usage |
| ** Alternatively, this file may be used under the terms of the GNU |
| ** General Public License version 3 as published by the Free Software |
| ** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT |
| ** included in the packaging of this file. Please review the following |
| ** information to ensure the GNU General Public License requirements will |
| ** be met: https://www.gnu.org/licenses/gpl-3.0.html. |
| ** |
| ** $QT_END_LICENSE$ |
| ** |
| ****************************************************************************/ |
| |
| #define QT_USE_FAST_CONCATENATION |
| #define QT_USE_FAST_OPERATOR_PLUS |
| |
| #include "baselineserver.h" |
| #include <QBuffer> |
| #include <QFile> |
| #include <QDir> |
| #include <QCoreApplication> |
| #include <QFileInfo> |
| #include <QHostInfo> |
| #include <QTextStream> |
| #include <QProcess> |
| #include <QDirIterator> |
| #include <QUrl> |
| |
| // extra fields, for use in image metadata storage |
| const QString PI_ImageChecksum(QLS("ImageChecksum")); |
| const QString PI_RunId(QLS("RunId")); |
| const QString PI_CreationDate(QLS("CreationDate")); |
| |
| QString BaselineServer::storage; |
| QString BaselineServer::url; |
| QStringList BaselineServer::pathKeys; |
| |
| BaselineServer::BaselineServer(QObject *parent) |
| : QTcpServer(parent), lastRunIdIdx(0) |
| { |
| QFileInfo me(QCoreApplication::applicationFilePath()); |
| meLastMod = me.lastModified(); |
| heartbeatTimer = new QTimer(this); |
| connect(heartbeatTimer, SIGNAL(timeout()), this, SLOT(heartbeat())); |
| heartbeatTimer->start(HEARTBEAT*1000); |
| } |
| |
| QString BaselineServer::storagePath() |
| { |
| if (storage.isEmpty()) { |
| storage = QLS(qgetenv("QT_LANCELOT_DIR")); |
| if (storage.isEmpty()) |
| storage = QLS("/var/www"); |
| } |
| return storage; |
| } |
| |
| QString BaselineServer::baseUrl() |
| { |
| if (url.isEmpty()) { |
| url = QLS("http://") |
| + QHostInfo::localHostName().toLatin1() + '.' |
| + QHostInfo::localDomainName().toLatin1() + '/'; |
| } |
| return url; |
| } |
| |
| QStringList BaselineServer::defaultPathKeys() |
| { |
| if (pathKeys.isEmpty()) |
| pathKeys << PI_QtVersion << PI_QMakeSpec << PI_HostName; |
| return pathKeys; |
| } |
| |
| void BaselineServer::incomingConnection(qintptr socketDescriptor) |
| { |
| QString runId = QDateTime::currentDateTime().toString(QLS("MMMdd-hhmmss")); |
| if (runId == lastRunId) { |
| runId += QLC('-') + QString::number(++lastRunIdIdx); |
| } else { |
| lastRunId = runId; |
| lastRunIdIdx = 0; |
| } |
| qDebug() << "Server: New connection! RunId:" << runId; |
| BaselineThread *thread = new BaselineThread(runId, socketDescriptor, this); |
| connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); |
| thread->start(); |
| } |
| |
| void BaselineServer::heartbeat() |
| { |
| // The idea is to exit to be restarted when modified, as soon as not actually serving |
| QFileInfo me(QCoreApplication::applicationFilePath()); |
| if (me.lastModified() == meLastMod) |
| return; |
| if (!me.exists() || !me.isExecutable()) |
| return; |
| |
| //# (could close() here to avoid accepting new connections, to avoid livelock) |
| //# also, could check for a timeout to force exit, to avoid hung threads blocking |
| bool isServing = false; |
| foreach(BaselineThread *thread, findChildren<BaselineThread *>()) { |
| if (thread->isRunning()) { |
| isServing = true; |
| break; |
| } |
| } |
| |
| if (!isServing) |
| QCoreApplication::exit(); |
| } |
| |
| BaselineThread::BaselineThread(const QString &runId, int socketDescriptor, QObject *parent) |
| : QThread(parent), runId(runId), socketDescriptor(socketDescriptor) |
| { |
| } |
| |
| void BaselineThread::run() |
| { |
| BaselineHandler handler(runId, socketDescriptor); |
| exec(); |
| } |
| |
| |
| BaselineHandler::BaselineHandler(const QString &runId, int socketDescriptor) |
| : QObject(), runId(runId), connectionEstablished(false), settings(0), fuzzLevel(0) |
| { |
| idleTimer = new QTimer(this); |
| idleTimer->setSingleShot(true); |
| idleTimer->setInterval(IDLE_CLIENT_TIMEOUT * 1000); |
| connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleClientTimeout())); |
| idleTimer->start(); |
| |
| if (socketDescriptor == -1) |
| return; |
| |
| connect(&proto.socket, SIGNAL(readyRead()), this, SLOT(receiveRequest())); |
| connect(&proto.socket, SIGNAL(disconnected()), this, SLOT(receiveDisconnect())); |
| proto.socket.setSocketDescriptor(socketDescriptor); |
| proto.socket.setSocketOption(QAbstractSocket::KeepAliveOption, 1); |
| } |
| |
| const char *BaselineHandler::logtime() |
| { |
| return 0; |
| //return QTime::currentTime().toString(QLS("mm:ss.zzz")); |
| } |
| |
| QString BaselineHandler::projectPath(bool absolute) const |
| { |
| QString p = clientInfo.value(PI_Project); |
| return absolute ? BaselineServer::storagePath() + QLC('/') + p : p; |
| } |
| |
| bool BaselineHandler::checkClient(QByteArray *errMsg, bool *dryRunMode) |
| { |
| if (!errMsg) |
| return false; |
| if (clientInfo.value(PI_Project).isEmpty() || clientInfo.value(PI_TestCase).isEmpty()) { |
| *errMsg = "No Project and/or TestCase specified in client info."; |
| return false; |
| } |
| |
| // Determine ad-hoc state ### hardcoded for now |
| if (clientInfo.value(PI_TestCase) == QLS("tst_Lancelot")) { |
| //### Todo: push this stuff out in a script |
| if (!clientInfo.isAdHocRun()) { |
| // ### comp. with earlier versions still running (4.8) (?) |
| clientInfo.setAdHocRun(clientInfo.value(PI_PulseGitBranch).isEmpty() && clientInfo.value(PI_PulseTestrBranch).isEmpty()); |
| } |
| } |
| else { |
| // TBD |
| } |
| |
| if (clientInfo.isAdHocRun()) { |
| if (dryRunMode) |
| *dryRunMode = false; |
| return true; |
| } |
| |
| // Not ad hoc: filter the client |
| settings->beginGroup("ClientFilters"); |
| bool matched = false; |
| bool dryRunReq = false; |
| foreach (const QString &rule, settings->childKeys()) { |
| //qDebug() << " > RULE" << rule; |
| dryRunReq = false; |
| QString ruleMode = settings->value(rule).toString().toLower(); |
| if (ruleMode == QLS("dryrun")) |
| dryRunReq = true; |
| else if (ruleMode != QLS("enabled")) |
| continue; |
| settings->beginGroup(rule); |
| bool ruleMatched = true; |
| foreach (const QString &filterKey, settings->childKeys()) { |
| //qDebug() << " > FILTER" << filterKey; |
| QString filter = settings->value(filterKey).toString(); |
| if (filter.isEmpty()) |
| continue; |
| QString platVal = clientInfo.value(filterKey); |
| if (!platVal.contains(filter)) { |
| ruleMatched = false; |
| break; |
| } |
| } |
| if (ruleMatched) { |
| ruleName = rule; |
| matched = true; |
| break; |
| } |
| settings->endGroup(); |
| } |
| |
| if (!matched && errMsg) |
| *errMsg = "Non-adhoc client did not match any filter rule in " + settings->fileName().toLatin1(); |
| |
| if (matched && dryRunMode) |
| *dryRunMode = dryRunReq; |
| |
| // NB! Must reset the settings object before returning |
| while (!settings->group().isEmpty()) |
| settings->endGroup(); |
| |
| return matched; |
| } |
| |
| bool BaselineHandler::establishConnection() |
| { |
| if (!proto.acceptConnection(&clientInfo)) { |
| qWarning() << runId << logtime() << "Accepting new connection from" << proto.socket.peerAddress().toString() << "failed." << proto.errorMessage(); |
| proto.sendBlock(BaselineProtocol::Abort, proto.errorMessage().toLatin1()); // In case the client can hear us, tell it what's wrong. |
| proto.socket.disconnectFromHost(); |
| return false; |
| } |
| QString logMsg; |
| foreach (QString key, clientInfo.keys()) { |
| if (key != PI_HostName && key != PI_HostAddress) |
| logMsg += key + QLS(": '") + clientInfo.value(key) + QLS("', "); |
| } |
| qDebug() << runId << logtime() << "Connection established with" << clientInfo.value(PI_HostName) |
| << '[' << qPrintable(clientInfo.value(PI_HostAddress)) << ']' << logMsg |
| << "Overrides:" << clientInfo.overrides() << "AdHoc-Run:" << clientInfo.isAdHocRun(); |
| |
| // ### Hardcoded backwards compatibility: add project field for certain existing clients that lack it |
| if (clientInfo.value(PI_Project).isEmpty()) { |
| QString tc = clientInfo.value(PI_TestCase); |
| if (tc == QLS("tst_Lancelot")) |
| clientInfo.insert(PI_Project, QLS("Raster")); |
| else if (tc == QLS("tst_Scenegraph")) |
| clientInfo.insert(PI_Project, QLS("SceneGraph")); |
| else |
| clientInfo.insert(PI_Project, QLS("Other")); |
| } |
| |
| QString settingsFile = projectPath() + QLS("/config.ini"); |
| settings = new QSettings(settingsFile, QSettings::IniFormat, this); |
| |
| QByteArray errMsg; |
| bool dryRunMode = false; |
| if (!checkClient(&errMsg, &dryRunMode)) { |
| qDebug() << runId << logtime() << "Rejecting connection:" << errMsg; |
| proto.sendBlock(BaselineProtocol::Abort, errMsg); |
| proto.socket.disconnectFromHost(); |
| return false; |
| } |
| |
| fuzzLevel = qBound(0, settings->value("FuzzLevel").toInt(), 100); |
| if (!clientInfo.isAdHocRun()) { |
| qDebug() << runId << logtime() << "Client matches filter rule" << ruleName |
| << "Dryrun:" << dryRunMode |
| << "FuzzLevel:" << fuzzLevel |
| << "ReportMissingResults:" << settings->value("ReportMissingResults").toBool(); |
| } |
| |
| proto.sendBlock(dryRunMode ? BaselineProtocol::DoDryRun : BaselineProtocol::Ack, QByteArray()); |
| report.init(this, runId, clientInfo, settings); |
| return true; |
| } |
| |
| void BaselineHandler::receiveRequest() |
| { |
| idleTimer->start(); // Restart idle client timeout |
| |
| if (!connectionEstablished) { |
| connectionEstablished = establishConnection(); |
| return; |
| } |
| |
| QByteArray block; |
| BaselineProtocol::Command cmd; |
| if (!proto.receiveBlock(&cmd, &block)) { |
| qWarning() << runId << logtime() << "Command reception failed. "<< proto.errorMessage(); |
| QThread::currentThread()->exit(1); |
| return; |
| } |
| |
| switch(cmd) { |
| case BaselineProtocol::RequestBaselineChecksums: |
| provideBaselineChecksums(block); |
| break; |
| case BaselineProtocol::AcceptMatch: |
| recordMatch(block); |
| break; |
| case BaselineProtocol::AcceptNewBaseline: |
| storeImage(block, true); |
| break; |
| case BaselineProtocol::AcceptMismatch: |
| storeImage(block, false); |
| break; |
| default: |
| qWarning() << runId << logtime() << "Unknown command received. " << proto.errorMessage(); |
| proto.sendBlock(BaselineProtocol::UnknownError, QByteArray()); |
| } |
| } |
| |
| |
| void BaselineHandler::provideBaselineChecksums(const QByteArray &itemListBlock) |
| { |
| ImageItemList itemList; |
| QDataStream ds(itemListBlock); |
| ds >> itemList; |
| qDebug() << runId << logtime() << "Received request for checksums for" << itemList.count() |
| << "items in test function" << itemList.at(0).testFunction; |
| |
| for (ImageItemList::iterator i = itemList.begin(); i != itemList.end(); ++i) { |
| i->imageChecksums.clear(); |
| i->status = ImageItem::BaselineNotFound; |
| QString prefix = pathForItem(*i, true); |
| PlatformInfo itemData = fetchItemMetadata(prefix); |
| if (itemData.contains(PI_ImageChecksum)) { |
| bool ok = false; |
| quint64 checksum = itemData.value(PI_ImageChecksum).toULongLong(&ok, 16); |
| if (ok) { |
| i->imageChecksums.prepend(checksum); |
| i->status = ImageItem::Ok; |
| } |
| } |
| } |
| |
| // Find and mark blacklisted items |
| QString context = pathForItem(itemList.at(0), true, false).section(QLC('/'), 0, -2); |
| if (itemList.count() > 0) { |
| QFile file(BaselineServer::storagePath() + QLC('/') + context + QLS("/BLACKLIST")); |
| if (file.open(QIODevice::ReadOnly)) { |
| QTextStream in(&file); |
| do { |
| QString itemName = in.readLine(); |
| if (!itemName.isNull()) { |
| for (ImageItemList::iterator i = itemList.begin(); i != itemList.end(); ++i) { |
| if (i->itemName == itemName) |
| i->status = ImageItem::IgnoreItem; |
| } |
| } |
| } while (!in.atEnd()); |
| } |
| } |
| |
| QByteArray block; |
| QDataStream ods(&block, QIODevice::WriteOnly); |
| ods << itemList; |
| proto.sendBlock(BaselineProtocol::Ack, block); |
| report.addItems(itemList); |
| } |
| |
| |
| void BaselineHandler::recordMatch(const QByteArray &itemBlock) |
| { |
| QDataStream ds(itemBlock); |
| ImageItem item; |
| ds >> item; |
| report.addResult(item); |
| proto.sendBlock(BaselineProtocol::Ack, QByteArray()); |
| } |
| |
| |
| void BaselineHandler::storeImage(const QByteArray &itemBlock, bool isBaseline) |
| { |
| QDataStream ds(itemBlock); |
| ImageItem item; |
| ds >> item; |
| |
| if (isBaseline && !clientInfo.overrides().isEmpty()) { |
| qDebug() << runId << logtime() << "Received baseline from client with override info, ignoring. Item:" << item.itemName; |
| proto.sendBlock(BaselineProtocol::UnknownError, "New baselines not accepted from client with override info."); |
| return; |
| } |
| |
| QString blPrefix = pathForItem(item, true); |
| QString mmPrefix = pathForItem(item, false); |
| QString prefix = isBaseline ? blPrefix : mmPrefix; |
| |
| qDebug() << runId << logtime() << "Received" << (isBaseline ? "baseline" : "mismatched") << "image for:" << item.itemName << "Storing in" << prefix; |
| |
| // Reply to the client |
| QString msg; |
| if (isBaseline) |
| msg = QLS("New baseline image stored: ") + blPrefix + QLS(FileFormat); |
| else |
| msg = BaselineServer::baseUrl() + report.filePath(); |
| |
| if (isBaseline || !fuzzLevel) |
| proto.sendBlock(BaselineProtocol::Ack, msg.toLatin1()); // Do early reply if possible: don't make the client wait longer than necessary |
| |
| // Store the image |
| QString dir = prefix.section(QLC('/'), 0, -2); |
| QDir cwd; |
| if (!cwd.exists(dir)) |
| cwd.mkpath(dir); |
| item.image.save(prefix + QLS(FileFormat), FileFormat); |
| |
| PlatformInfo itemData = clientInfo; |
| itemData.insert(PI_ImageChecksum, QString::number(item.imageChecksums.at(0), 16)); //# Only the first is stored. TBD: get rid of list |
| itemData.insert(PI_RunId, runId); |
| itemData.insert(PI_CreationDate, QDateTime::currentDateTime().toString()); |
| storeItemMetadata(itemData, prefix); |
| |
| if (!isBaseline) { |
| // Do fuzzy matching |
| bool fuzzyMatch = false; |
| if (fuzzLevel) { |
| BaselineProtocol::Command cmd = BaselineProtocol::Ack; |
| fuzzyMatch = fuzzyCompare(blPrefix, mmPrefix); |
| if (fuzzyMatch) { |
| msg.prepend(QString("Fuzzy match at fuzzlevel %1%. Report: ").arg(fuzzLevel)); |
| cmd = BaselineProtocol::FuzzyMatch; |
| } |
| proto.sendBlock(cmd, msg.toLatin1()); // We didn't reply earlier |
| } |
| |
| // Add to report |
| item.status = fuzzyMatch ? ImageItem::FuzzyMatch : ImageItem::Mismatch; |
| report.addResult(item); |
| } |
| } |
| |
| |
| void BaselineHandler::storeItemMetadata(const PlatformInfo &metadata, const QString &path) |
| { |
| QFile file(path + QLS(MetadataFileExt)); |
| if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { |
| qWarning() << runId << logtime() << "ERROR: could not write to file" << file.fileName(); |
| return; |
| } |
| QTextStream out(&file); |
| PlatformInfo::const_iterator it = metadata.constBegin(); |
| while (it != metadata.constEnd()) { |
| out << it.key() << ": " << it.value() << endl; |
| ++it; |
| } |
| file.close(); |
| } |
| |
| |
| PlatformInfo BaselineHandler::fetchItemMetadata(const QString &path) |
| { |
| PlatformInfo res; |
| QFile file(path + QLS(MetadataFileExt)); |
| if (!file.open(QIODevice::ReadOnly) || !QFile::exists(path + QLS(FileFormat))) |
| return res; |
| QTextStream in(&file); |
| do { |
| QString line = in.readLine(); |
| int idx = line.indexOf(QLS(": ")); |
| if (idx > 0) |
| res.insert(line.left(idx), line.mid(idx+2)); |
| } while (!in.atEnd()); |
| return res; |
| } |
| |
| |
| void BaselineHandler::idleClientTimeout() |
| { |
| qWarning() << runId << logtime() << "Idle client timeout: no request received for" << IDLE_CLIENT_TIMEOUT << "seconds, terminating connection."; |
| proto.socket.disconnectFromHost(); |
| } |
| |
| |
| void BaselineHandler::receiveDisconnect() |
| { |
| qDebug() << runId << logtime() << "Client disconnected."; |
| report.end(); |
| if (report.reportProduced() && !clientInfo.isAdHocRun()) |
| issueMismatchNotification(); |
| if (settings && settings->value("ProcessXmlResults").toBool() && !clientInfo.isAdHocRun()) { |
| // ### TBD: actually execute the processing command. For now, just generate the xml files. |
| QString xmlDir = report.writeResultsXmlFiles(); |
| } |
| QThread::currentThread()->exit(0); |
| } |
| |
| |
| PlatformInfo BaselineHandler::mapPlatformInfo(const PlatformInfo& orig) const |
| { |
| PlatformInfo mapped; |
| foreach (const QString &key, orig.uniqueKeys()) { |
| QString val = orig.value(key).simplified(); |
| val.replace(QLC('/'), QLC('_')); |
| val.replace(QLC(' '), QLC('_')); |
| mapped.insert(key, QUrl::toPercentEncoding(val, "+")); |
| //qDebug() << "MAPPED" << key << "FROM" << orig.value(key) << "TO" << mapped.value(key); |
| } |
| |
| // Special fixup for OS version |
| if (mapped.value(PI_OSName) == QLS("MacOS")) { |
| int ver = mapped.value(PI_OSVersion).toInt(); |
| if (ver > 1) |
| mapped.insert(PI_OSVersion, QString("MV_10_%1").arg(ver-2)); |
| } |
| else if (mapped.value(PI_OSName) == QLS("Windows")) { |
| // TBD: map windows version numbers to names |
| } |
| |
| // Special fixup for hostname |
| QString host = mapped.value(PI_HostName).section(QLC('.'), 0, 0); // Filter away domain, if any |
| if (host.isEmpty() || host == QLS("localhost")) { |
| host = orig.value(PI_HostAddress); |
| } else { |
| if (!orig.isAdHocRun()) { // i.e. CI system run, so remove index postfix typical of vm hostnames |
| host.remove(QRegExp(QLS("\\d+$"))); |
| if (host.endsWith(QLC('-'))) |
| host.chop(1); |
| } |
| } |
| if (host.isEmpty()) |
| host = QLS("UNKNOWN-HOST"); |
| if (mapped.value(PI_OSName) == QLS("MacOS")) // handle multiple os versions on same host |
| host += QLC('-') + mapped.value(PI_OSVersion); |
| mapped.insert(PI_HostName, host); |
| |
| // Special fixup for Qt version |
| QString ver = mapped.value(PI_QtVersion); |
| if (!ver.isEmpty()) |
| mapped.insert(PI_QtVersion, ver.prepend(QLS("Qt-"))); |
| |
| return mapped; |
| } |
| |
| |
| QString BaselineHandler::pathForItem(const ImageItem &item, bool isBaseline, bool absolute) const |
| { |
| if (mappedClientInfo.isEmpty()) { |
| mappedClientInfo = mapPlatformInfo(clientInfo); |
| PlatformInfo oraw = clientInfo; |
| // ### simplify: don't map if no overrides! |
| for (int i = 0; i < clientInfo.overrides().size()-1; i+=2) |
| oraw.insert(clientInfo.overrides().at(i), clientInfo.overrides().at(i+1)); |
| overriddenMappedClientInfo = mapPlatformInfo(oraw); |
| } |
| |
| const PlatformInfo& mapped = isBaseline ? overriddenMappedClientInfo : mappedClientInfo; |
| |
| QString itemName = safeName(item.itemName); |
| itemName.append(QLC('_') + QString::number(item.itemChecksum, 16).rightJustified(4, QLC('0'))); |
| |
| QStringList path; |
| path += projectPath(absolute); |
| path += mapped.value(PI_TestCase); |
| path += QLS(isBaseline ? "baselines" : "mismatches"); |
| path += item.testFunction; |
| QStringList itemPathKeys; |
| if (settings) |
| itemPathKeys = settings->value("ItemPathKeys").toStringList(); |
| if (itemPathKeys.isEmpty()) |
| itemPathKeys = BaselineServer::defaultPathKeys(); |
| foreach (const QString &key, itemPathKeys) |
| path += mapped.value(key, QLS("UNSET-")+key); |
| if (!isBaseline) |
| path += runId; |
| path += itemName + QLC('.'); |
| |
| return path.join(QLS("/")); |
| } |
| |
| |
| QString BaselineHandler::view(const QString &baseline, const QString &rendered, const QString &compared) |
| { |
| QFile f(":/templates/view.html"); |
| f.open(QIODevice::ReadOnly); |
| return QString::fromLatin1(f.readAll()).arg('/'+baseline, '/'+rendered, '/'+compared, diffstats(baseline, rendered)); |
| } |
| |
| QString BaselineHandler::diffstats(const QString &baseline, const QString &rendered) |
| { |
| QImage blImg(BaselineServer::storagePath() + QLC('/') + baseline); |
| QImage mmImg(BaselineServer::storagePath() + QLC('/') + rendered); |
| if (blImg.isNull() || mmImg.isNull()) |
| return QLS("Could not compute diffstats: image loading failed."); |
| |
| // ### TBD: cache the results |
| return computeMismatchScore(blImg, mmImg); |
| } |
| |
| QString BaselineHandler::clearAllBaselines(const QString &context) |
| { |
| int tot = 0; |
| int failed = 0; |
| QDirIterator it(BaselineServer::storagePath() + QLC('/') + context, |
| QStringList() << QLS("*.") + QLS(FileFormat) |
| << QLS("*.") + QLS(MetadataFileExt) |
| << QLS("*.") + QLS(ThumbnailExt)); |
| while (it.hasNext()) { |
| bool counting = !it.next().endsWith(QLS(ThumbnailExt)); |
| if (counting) |
| tot++; |
| if (!QFile::remove(it.filePath()) && counting) |
| failed++; |
| } |
| return QString(QLS("%1 of %2 baselines cleared from context ")).arg((tot-failed)/2).arg(tot/2) + context; |
| } |
| |
| QString BaselineHandler::updateBaselines(const QString &context, const QString &mismatchContext, const QString &itemFile) |
| { |
| int tot = 0; |
| int failed = 0; |
| QString storagePrefix = BaselineServer::storagePath() + QLC('/'); |
| // If itemId is set, update just that one, otherwise, update all: |
| QString filter = itemFile.isEmpty() ? QLS("*_????.") : itemFile; |
| QDirIterator it(storagePrefix + mismatchContext, |
| QStringList() << filter + QLS(FileFormat) |
| << filter + QLS(MetadataFileExt) |
| << filter + QLS(ThumbnailExt)); |
| while (it.hasNext()) { |
| bool counting = !it.next().endsWith(QLS(ThumbnailExt)); |
| if (counting) |
| tot++; |
| QString oldFile = storagePrefix + context + QLC('/') + it.fileName(); |
| QFile::remove(oldFile); // Remove existing baseline file |
| if (!QFile::copy(it.filePath(), oldFile) && counting) // and replace it with the mismatch |
| failed++; |
| } |
| return QString(QLS("%1 of %2 baselines updated in context %3 from context %4")).arg((tot-failed)/2).arg(tot/2).arg(context, mismatchContext); |
| } |
| |
| QString BaselineHandler::blacklistTest(const QString &context, const QString &itemId, bool removeFromBlacklist) |
| { |
| QFile file(BaselineServer::storagePath() + QLC('/') + context + QLS("/BLACKLIST")); |
| QStringList blackList; |
| if (file.open(QIODevice::ReadWrite)) { |
| while (!file.atEnd()) |
| blackList.append(file.readLine().trimmed()); |
| |
| if (removeFromBlacklist) |
| blackList.removeAll(itemId); |
| else if (!blackList.contains(itemId)) |
| blackList.append(itemId); |
| |
| file.resize(0); |
| foreach (QString id, blackList) |
| file.write(id.toLatin1() + '\n'); |
| file.close(); |
| return QLS(removeFromBlacklist ? "Whitelisted " : "Blacklisted ") + itemId + QLS(" in context ") + context; |
| } else { |
| return QLS("Unable to update blacklisted tests, failed to open ") + file.fileName(); |
| } |
| } |
| |
| |
| void BaselineHandler::testPathMapping() |
| { |
| qDebug() << "Storage prefix:" << BaselineServer::storagePath(); |
| |
| QStringList hosts; |
| hosts << QLS("bq-ubuntu910-x86-01") |
| << QLS("bq-ubuntu910-x86-15") |
| << QLS("osl-mac-master-5.test.qt-project.org") |
| << QLS("osl-mac-master-6.test.qt-project.org") |
| << QLS("sv-xp-vs-010") |
| << QLS("sv-xp-vs-011") |
| << QLS("sv-solaris-sparc-008") |
| << QLS("macbuilder-02.test.troll.no") |
| << QLS("bqvm1164") |
| << QLS("chimera") |
| << QLS("localhost") |
| << QLS(""); |
| |
| ImageItem item; |
| item.testFunction = QLS("testPathMapping"); |
| item.itemName = QLS("arcs.qps"); |
| item.imageChecksums << 0x0123456789abcdefULL; |
| item.itemChecksum = 0x0123; |
| |
| clientInfo.insert(PI_QtVersion, QLS("5.0.0")); |
| clientInfo.insert(PI_QMakeSpec, QLS("linux-g++")); |
| clientInfo.insert(PI_PulseGitBranch, QLS("somebranch")); |
| clientInfo.setAdHocRun(false); |
| foreach(const QString& host, hosts) { |
| mappedClientInfo.clear(); |
| clientInfo.insert(PI_HostName, host); |
| qDebug() << "Baseline from" << host << "->" << pathForItem(item, true); |
| qDebug() << "Mismatch from" << host << "->" << pathForItem(item, false); |
| } |
| } |
| |
| |
| QString BaselineHandler::computeMismatchScore(const QImage &baseline, const QImage &rendered) |
| { |
| if (baseline.size() != rendered.size() || baseline.format() != rendered.format()) |
| return QLS("[No diffstats, incomparable images.]"); |
| if (baseline.depth() != 32) |
| return QLS("[Diffstats computation not implemented for format.]"); |
| |
| int w = baseline.width(); |
| int h = baseline.height(); |
| |
| uint ncd = 0; // number of differing color pixels |
| uint nad = 0; // number of differing alpha pixels |
| uint scd = 0; // sum of color pixel difference |
| uint sad = 0; // sum of alpha pixel difference |
| uint mind = 0; // minimum difference |
| uint maxd = 0; // maximum difference |
| |
| for (int y=0; y<h; ++y) { |
| const QRgb *bl = (const QRgb *) baseline.constScanLine(y); |
| const QRgb *rl = (const QRgb *) rendered.constScanLine(y); |
| for (int x=0; x<w; ++x) { |
| QRgb b = bl[x]; |
| QRgb r = rl[x]; |
| if (r != b) { |
| uint dr = qAbs(qRed(b) - qRed(r)); |
| uint dg = qAbs(qGreen(b) - qGreen(r)); |
| uint db = qAbs(qBlue(b) - qBlue(r)); |
| uint ds = (dr + dg + db) / 3; |
| uint da = qAbs(qAlpha(b) - qAlpha(r)); |
| if (ds) { |
| ncd++; |
| scd += ds; |
| if (!mind || ds < mind) |
| mind = ds; |
| if (ds > maxd) |
| maxd = ds; |
| } |
| if (da) { |
| nad++; |
| sad += da; |
| } |
| } |
| } |
| } |
| |
| |
| double pcd = 100.0 * ncd / (w*h); // percent of pixels that differ |
| double acd = ncd ? double(scd) / (ncd) : 0; // avg. difference |
| /* |
| if (baseline.hasAlphaChannel()) { |
| double pad = 100.0 * nad / (w*h); // percent of pixels that differ |
| double aad = nad ? double(sad) / (3*nad) : 0; // avg. difference |
| } |
| */ |
| QString res = "<table>\n"; |
| QString item = "<tr><td>%1</td><td align=right>%2</td></tr>\n"; |
| res += item.arg("Number of mismatching pixels").arg(ncd); |
| res += item.arg("Percentage mismatching pixels").arg(pcd, 0, 'g', 2); |
| res += item.arg("Minimum pixel distance").arg(mind); |
| res += item.arg("Maximum pixel distance").arg(maxd); |
| if (acd >= 10.0) |
| res += item.arg("Average pixel distance").arg(qRound(acd)); |
| else |
| res += item.arg("Average pixel distance").arg(acd, 0, 'g', 2); |
| |
| if (baseline.hasAlphaChannel()) |
| res += item.arg("Number of mismatching alpha values").arg(nad); |
| |
| res += "</table>\n"; |
| res += "<p>(Distances are normalized to the range 0-255)</p>\n"; |
| return res; |
| } |
| |
| |
| bool BaselineHandler::fuzzyCompare(const QString &baselinePath, const QString &mismatchPath) |
| { |
| QProcess compareProc; |
| QStringList args; |
| args << "-fuzz" << QString("%1%").arg(fuzzLevel) << "-metric" << "AE"; |
| args << baselinePath + QLS(FileFormat) << mismatchPath + QLS(FileFormat) << "/dev/null"; // TBD: Should save output image, so report won't have to regenerate it |
| |
| compareProc.setProcessChannelMode(QProcess::MergedChannels); |
| compareProc.start("compare", args, QIODevice::ReadOnly); |
| if (compareProc.waitForFinished(3000) && compareProc.error() == QProcess::UnknownError) { |
| bool ok = false; |
| int metric = compareProc.readAll().trimmed().toInt(&ok); |
| if (ok && metric == 0) |
| return true; |
| } |
| return false; |
| } |
| |
| |
| void BaselineHandler::issueMismatchNotification() |
| { |
| // KISS: hardcoded use of the "sendemail" utility. Make this configurable if and when demand arises. |
| if (!settings) |
| return; |
| |
| settings->beginGroup("Notification"); |
| QStringList receivers = settings->value("Receivers").toStringList(); |
| QString sender = settings->value("Sender").toString(); |
| QString server = settings->value("SMTPserver").toString(); |
| settings->endGroup(); |
| if (receivers.isEmpty() || sender.isEmpty() || server.isEmpty()) |
| return; |
| |
| QString msg = QString("\nResult summary for test run %1:\n").arg(runId); |
| msg += report.summary(); |
| msg += "\nReport: " + BaselineServer::baseUrl() + report.filePath() + "\n"; |
| |
| msg += "\nTest run platform properties:\n------------------\n"; |
| foreach (const QString &key, clientInfo.keys()) |
| msg += key + ": " + clientInfo.value(key) + '\n'; |
| msg += "\nCheers,\n- Your friendly Lancelot Baseline Server\n"; |
| |
| QProcess proc; |
| QString cmd = "sendemail"; |
| QStringList args; |
| args << "-s" << server << "-f" << sender << "-t" << receivers; |
| args << "-u" << "[Lancelot] Mismatch report for project " + clientInfo.value(PI_Project) + ", test case " + clientInfo.value(PI_TestCase); |
| args << "-m" << msg; |
| |
| //proc.setProcessChannelMode(QProcess::MergedChannels); |
| proc.start(cmd, args); |
| if (!proc.waitForFinished(10 * 1000) || (proc.exitStatus() != QProcess::NormalExit) || proc.exitCode()) { |
| qWarning() << "FAILED to issue notification. Command:" << cmd << args.mid(0, args.size()-2); |
| qWarning() << " Command standard output:" << proc.readAllStandardOutput(); |
| qWarning() << " Command error output:" << proc.readAllStandardError(); |
| } |
| } |
| |
| |
| // Make an identifer safer for use as filename and URL |
| QString safeName(const QString& name) |
| { |
| QString res = name.simplified(); |
| res.replace(QLC(' '), QLC('_')); |
| res.replace(QLC('.'), QLC('_')); |
| res.replace(QLC('/'), QLC('^')); |
| return res; |
| } |