From aa9728450cc515c66545323646c66d826a1af50a Mon Sep 17 00:00:00 2001 From: aavit Date: Mon, 13 Aug 2012 14:13:40 +0200 Subject: Misc. updates to the lancelot autotest framework Moving more logic into the protocol and framework, easening the burden on the autotest implementation. Implementing several new features in the server and report, like fuzzy matching and static baselines. Change-Id: Iaf070918195ae05767808a548f019d09d9d5f8c0 Reviewed-by: Paul Olav Tvete --- tests/baselineserver/shared/baselineprotocol.cpp | 29 +- tests/baselineserver/shared/baselineprotocol.h | 13 +- tests/baselineserver/shared/qbaselinetest.cpp | 248 +++++++++++-- tests/baselineserver/shared/qbaselinetest.h | 8 +- tests/baselineserver/src/baselineserver.cpp | 422 +++++++++++++++++++---- tests/baselineserver/src/baselineserver.h | 32 +- tests/baselineserver/src/report.cpp | 270 ++++++++++++--- tests/baselineserver/src/report.h | 27 +- tests/baselineserver/src/templates/view.html | 9 +- 9 files changed, 900 insertions(+), 158 deletions(-) (limited to 'tests/baselineserver') diff --git a/tests/baselineserver/shared/baselineprotocol.cpp b/tests/baselineserver/shared/baselineprotocol.cpp index b8e141374f..e800b76fec 100644 --- a/tests/baselineserver/shared/baselineprotocol.cpp +++ b/tests/baselineserver/shared/baselineprotocol.cpp @@ -50,6 +50,7 @@ #include #include +const QString PI_Project(QLS("Project")); const QString PI_TestCase(QLS("TestCase")); const QString PI_HostName(QLS("HostName")); const QString PI_HostAddress(QLS("HostAddress")); @@ -356,10 +357,14 @@ BaselineProtocol::BaselineProtocol() } BaselineProtocol::~BaselineProtocol() +{ + disconnect(); +} + +bool BaselineProtocol::disconnect() { socket.close(); - if (socket.state() != QTcpSocket::UnconnectedState) - socket.waitForDisconnected(Timeout); + return (socket.state() == QTcpSocket::UnconnectedState) ? true : socket.waitForDisconnected(Timeout); } @@ -372,7 +377,7 @@ bool BaselineProtocol::connect(const QString &testCase, bool *dryrun, const Plat socket.connectToHost(serverName, ServerPort); if (!socket.waitForConnected(Timeout)) { - sysSleep(Timeout); // Wait a bit and try again, the server might just be restarting + sysSleep(3000); // Wait a bit and try again, the server might just be restarting if (!socket.waitForConnected(Timeout)) { errMsg += QLS("TCP connectToHost failed. Host:") + serverName + QLS(" port:") + QString::number(ServerPort); return false; @@ -456,6 +461,15 @@ bool BaselineProtocol::requestBaselineChecksums(const QString &testFunction, Ima } +bool BaselineProtocol::submitMatch(const ImageItem &item, QByteArray *serverMsg) +{ + Command cmd; + ImageItem smallItem = item; + smallItem.image = QImage(); // No need to waste bandwith sending image (identical to baseline) to server + return (sendItem(AcceptMatch, smallItem) && receiveBlock(&cmd, serverMsg) && cmd == Ack); +} + + bool BaselineProtocol::submitNewBaseline(const ImageItem &item, QByteArray *serverMsg) { Command cmd; @@ -463,10 +477,15 @@ bool BaselineProtocol::submitNewBaseline(const ImageItem &item, QByteArray *serv } -bool BaselineProtocol::submitMismatch(const ImageItem &item, QByteArray *serverMsg) +bool BaselineProtocol::submitMismatch(const ImageItem &item, QByteArray *serverMsg, bool *fuzzyMatch) { Command cmd; - return (sendItem(AcceptMismatch, item) && receiveBlock(&cmd, serverMsg) && cmd == Ack); + if (sendItem(AcceptMismatch, item) && receiveBlock(&cmd, serverMsg) && (cmd == Ack || cmd == FuzzyMatch)) { + if (fuzzyMatch) + *fuzzyMatch = (cmd == FuzzyMatch); + return true; + } + return false; } diff --git a/tests/baselineserver/shared/baselineprotocol.h b/tests/baselineserver/shared/baselineprotocol.h index 61feaa34a9..a5f873e3d5 100644 --- a/tests/baselineserver/shared/baselineprotocol.h +++ b/tests/baselineserver/shared/baselineprotocol.h @@ -55,6 +55,7 @@ #define FileFormat "png" +extern const QString PI_Project; extern const QString PI_TestCase; extern const QString PI_HostName; extern const QString PI_HostAddress; @@ -111,7 +112,9 @@ public: Ok = 0, BaselineNotFound = 1, IgnoreItem = 2, - Mismatch = 3 + Mismatch = 3, + FuzzyMatch = 4, + Error = 5 }; QString testFunction; @@ -155,21 +158,25 @@ public: // Queries AcceptPlatformInfo = 1, RequestBaselineChecksums = 2, + AcceptMatch = 3, AcceptNewBaseline = 4, AcceptMismatch = 5, // Responses Ack = 128, Abort = 129, - DoDryRun = 130 + DoDryRun = 130, + FuzzyMatch = 131 }; // For client: // For advanced client: bool connect(const QString &testCase, bool *dryrun = 0, const PlatformInfo& clientInfo = PlatformInfo()); + bool disconnect(); bool requestBaselineChecksums(const QString &testFunction, ImageItemList *itemList); + bool submitMatch(const ImageItem &item, QByteArray *serverMsg); bool submitNewBaseline(const ImageItem &item, QByteArray *serverMsg); - bool submitMismatch(const ImageItem &item, QByteArray *serverMsg); + bool submitMismatch(const ImageItem &item, QByteArray *serverMsg, bool *fuzzyMatch = 0); // For server: bool acceptConnection(PlatformInfo *pi); diff --git a/tests/baselineserver/shared/qbaselinetest.cpp b/tests/baselineserver/shared/qbaselinetest.cpp index 11de0421e9..0c28f6eb46 100644 --- a/tests/baselineserver/shared/qbaselinetest.cpp +++ b/tests/baselineserver/shared/qbaselinetest.cpp @@ -41,44 +41,233 @@ #include "qbaselinetest.h" #include "baselineprotocol.h" +#include +#include + +#define MAXCMDLINEARGS 128 namespace QBaselineTest { -BaselineProtocol proto; -bool connected = false; -bool triedConnecting = false; +static char *fargv[MAXCMDLINEARGS]; +static bool simfail = false; +static PlatformInfo customInfo; -QByteArray curFunction; -ImageItemList itemList; -bool gotBaselines; +static BaselineProtocol proto; +static bool connected = false; +static bool triedConnecting = false; +static QByteArray curFunction; +static ImageItemList itemList; +static bool gotBaselines; -bool connect(QByteArray *msg, bool *error) +static QString definedTestProject; +static QString definedTestCase; + + +void handleCmdLineArgs(int *argcp, char ***argvp) { - if (!triedConnecting) { - triedConnecting = true; - if (!proto.connect(QTest::testObject()->metaObject()->className())) { - *msg += "Failed to connect to baseline server: " + proto.errorMessage().toLatin1(); - *error = true; - return false; + if (!argcp || !argvp) + return; + + bool showHelp = false; + + int fargc = 0; + int numArgs = *argcp; + + for (int i = 0; i < numArgs; i++) { + QByteArray arg = (*argvp)[i]; + QByteArray nextArg = (i+1 < numArgs) ? (*argvp)[i+1] : 0; + + if (arg == "-simfail") { + simfail = true; + } else if (arg == "-auto") { + customInfo.setAdHocRun(false); + } else if (arg == "-adhoc") { + customInfo.setAdHocRun(true); + } else if (arg == "-compareto") { + i++; + int split = qMax(0, nextArg.indexOf('=')); + QByteArray key = nextArg.left(split).trimmed(); + QByteArray value = nextArg.mid(split+1).trimmed(); + if (key.isEmpty() || value.isEmpty()) { + qWarning() << "-compareto requires parameter of the form ="; + showHelp = true; + break; + } + customInfo.addOverride(key, value); + } else { + if ( (arg == "-help") || (arg == "--help") ) + showHelp = true; + if (fargc >= MAXCMDLINEARGS) { + qWarning() << "Too many command line arguments!"; + break; + } + fargv[fargc++] = (*argvp)[i]; + } + } + *argcp = fargc; + *argvp = fargv; + + if (showHelp) { + // TBD: arrange for this to be printed *after* QTest's help + QTextStream out(stdout); + out << "\n Baseline testing (lancelot) options:\n"; + out << " -simfail : Force an image comparison mismatch. For testing purposes.\n"; + out << " -auto : Inform server that this run is done by a daemon, CI system or similar.\n"; + out << " -adhoc (default) : The inverse of -auto; this run is done by human, e.g. for testing.\n"; + out << " -compareto KEY=VAL : Force comparison to baselines from a different client,\n"; + out << " for example: -compareto QtVersion=4.8.0\n"; + out << " Multiple -compareto client specifications may be given.\n"; + out << "\n"; + } +} + + +void addClientProperty(const QString& key, const QString& value) +{ + customInfo.insert(key, value); +} + + +/* + If a client property script is present, run it and accept its output + in the form of one 'key: value' property per line +*/ +void fetchCustomClientProperties() +{ + QString script = "hostinfo.sh"; //### TBD: better name + + QProcess runScript; + runScript.setWorkingDirectory(QCoreApplication::applicationDirPath()); + runScript.start("sh", QStringList() << script, QIODevice::ReadOnly); + if (!runScript.waitForFinished(5000) || runScript.error() != QProcess::UnknownError) { + qWarning() << "QBaselineTest: Error running script" << runScript.workingDirectory() + QDir::separator() + script << ":" << runScript.errorString(); + qDebug() << " stderr:" << runScript.readAllStandardError().trimmed(); + } + while (!runScript.atEnd()) { + QByteArray line = runScript.readLine().trimmed(); // ###local8bit? utf8? + QString key, val; + int colonPos = line.indexOf(':'); + if (colonPos > 0) { + key = line.left(colonPos).simplified().replace(' ', '_'); + val = line.mid(colonPos+1).trimmed(); } - connected = true; + if (!key.isEmpty() && key.length() < 64 && val.length() < 256) // ###TBD: maximum 256 chars in value? + addClientProperty(key, val); + else + qDebug() << "Unparseable script output ignored:" << line; + } +} + + +bool connect(QByteArray *msg, bool *error) +{ + if (connected) { + return true; } - if (!connected) { + else if (triedConnecting) { + // Avoid repeated connection attempts, to avoid the program using Timeout * #testItems seconds before giving up *msg = "Not connected to baseline server."; *error = true; return false; } + + triedConnecting = true; + fetchCustomClientProperties(); + // Merge the platform info set by the program with the protocols default info + PlatformInfo clientInfo = customInfo; + PlatformInfo defaultInfo = PlatformInfo::localHostInfo(); + foreach (QString key, defaultInfo.keys()) { + if (!clientInfo.contains(key)) + clientInfo.insert(key, defaultInfo.value(key)); + } + + if (!definedTestProject.isEmpty()) + clientInfo.insert(PI_Project, definedTestProject); + + QString testCase = definedTestCase; + if (testCase.isEmpty() && QTest::testObject() && QTest::testObject()->metaObject()) { + //qDebug() << "Trying to Read TestCaseName from Testlib!"; + testCase = QTest::testObject()->metaObject()->className(); + } + if (testCase.isEmpty()) { + qWarning("QBaselineTest::connect: No test case name specified, cannot connect."); + return false; + } + + bool dummy; // ### TBD: dryrun handling + if (!proto.connect(testCase, &dummy, clientInfo)) { + *msg += "Failed to connect to baseline server: " + proto.errorMessage().toLatin1(); + *error = true; + return false; + } + connected = true; return true; } +bool disconnectFromBaselineServer() +{ + if (proto.disconnect()) { + connected = false; + triedConnecting = false; + return true; + } + + return false; +} + +bool connectToBaselineServer(QByteArray *msg, const QString &testProject, const QString &testCase) +{ + bool dummy; + QByteArray dummyMsg; + + definedTestProject = testProject; + definedTestCase = testCase; + + return connect(msg ? msg : &dummyMsg, &dummy); +} + +void setAutoMode(bool mode) +{ + customInfo.setAdHocRun(!mode); +} + +void setSimFail(bool fail) +{ + simfail = fail; +} + + +void modifyImage(QImage *img) +{ + uint c0 = 0x0000ff00; + uint c1 = 0x0080ff00; + img->setPixel(1,1,c0); + img->setPixel(2,1,c1); + img->setPixel(3,1,c0); + img->setPixel(1,2,c1); + img->setPixel(1,3,c0); + img->setPixel(2,3,c1); + img->setPixel(3,3,c0); + img->setPixel(1,4,c1); + img->setPixel(1,5,c0); +} + bool compareItem(const ImageItem &baseline, const QImage &img, QByteArray *msg, bool *error) { ImageItem item = baseline; - item.image = img; + if (simfail) { + // Simulate test failure by forcing image mismatch; for testing purposes + QImage misImg = img; + modifyImage(&misImg); + item.image = misImg; + simfail = false; // One failure is typically enough + } else { + item.image = img; + } item.imageChecksums.clear(); - item.imageChecksums.prepend(ImageItem::computeChecksum(img)); + item.imageChecksums.prepend(ImageItem::computeChecksum(item.image)); QByteArray srvMsg; switch (baseline.status) { case ImageItem::Ok: @@ -88,6 +277,7 @@ bool compareItem(const ImageItem &baseline, const QImage &img, QByteArray *msg, return true; break; case ImageItem::BaselineNotFound: + // ### TBD: don't submit if have overrides; will be rejected anyway if (proto.submitNewBaseline(item, &srvMsg)) qDebug() << msg->constData() << "Baseline not found on server. New baseline uploaded."; else @@ -101,27 +291,43 @@ bool compareItem(const ImageItem &baseline, const QImage &img, QByteArray *msg, } *error = false; // The actual comparison of the given image with the baseline: - if (baseline.imageChecksums.contains(item.imageChecksums.at(0))) + if (baseline.imageChecksums.contains(item.imageChecksums.at(0))) { + if (!proto.submitMatch(item, &srvMsg)) + qWarning() << "Failed to report image match to server:" << srvMsg; return true; - proto.submitMismatch(item, &srvMsg); + } + bool fuzzyMatch = false; + bool res = proto.submitMismatch(item, &srvMsg, &fuzzyMatch); + if (res && fuzzyMatch) { + *error = true; // To force a QSKIP/debug output; somewhat kludgy + *msg += srvMsg; + return true; // The server decides: a fuzzy match means no mismatch + } *msg += "Mismatch. See report:\n " + srvMsg; return false; } -bool checkImage(const QImage &img, const char *name, quint16 checksum, QByteArray *msg, bool *error) +bool checkImage(const QImage &img, const char *name, quint16 checksum, QByteArray *msg, bool *error, int manualdatatag) { if (!connected && !connect(msg, error)) return true; QByteArray itemName; bool hasName = qstrlen(name); + const char *tag = QTest::currentDataTag(); if (qstrlen(tag)) { itemName = tag; if (hasName) itemName.append('_').append(name); } else { - itemName = hasName ? name : "default_name"; + itemName = hasName ? name : "default_name"; + } + + if (manualdatatag > 0) + { + itemName.prepend("_"); + itemName.prepend(QByteArray::number(manualdatatag)); } *msg = "Baseline check of image '" + itemName + "': "; diff --git a/tests/baselineserver/shared/qbaselinetest.h b/tests/baselineserver/shared/qbaselinetest.h index 40f4160e60..0bcfefa056 100644 --- a/tests/baselineserver/shared/qbaselinetest.h +++ b/tests/baselineserver/shared/qbaselinetest.h @@ -45,9 +45,15 @@ #include namespace QBaselineTest { -bool checkImage(const QImage& img, const char *name, quint16 checksum, QByteArray *msg, bool *error); +void setAutoMode(bool mode); +void setSimFail(bool fail); +void handleCmdLineArgs(int *argcp, char ***argvp); +void addClientProperty(const QString& key, const QString& value); +bool connectToBaselineServer(QByteArray *msg = 0, const QString &testProject = QString(), const QString &testCase = QString()); +bool checkImage(const QImage& img, const char *name, quint16 checksum, QByteArray *msg, bool *error, int manualdatatag = 0); bool testImage(const QImage& img, QByteArray *msg, bool *error); QTestData &newRow(const char *dataTag, quint16 checksum = 0); +bool disconnectFromBaselineServer(); } #define QBASELINE_CHECK_SUM(image, name, checksum)\ diff --git a/tests/baselineserver/src/baselineserver.cpp b/tests/baselineserver/src/baselineserver.cpp index 1653754333..a85dca375a 100644 --- a/tests/baselineserver/src/baselineserver.cpp +++ b/tests/baselineserver/src/baselineserver.cpp @@ -52,6 +52,7 @@ #include #include #include +#include // extra fields, for use in image metadata storage const QString PI_ImageChecksum(QLS("ImageChecksum")); @@ -60,7 +61,7 @@ const QString PI_CreationDate(QLS("CreationDate")); QString BaselineServer::storage; QString BaselineServer::url; -QString BaselineServer::settingsFile; +QStringList BaselineServer::pathKeys; BaselineServer::BaselineServer(QObject *parent) : QTcpServer(parent), lastRunIdIdx(0) @@ -92,13 +93,11 @@ QString BaselineServer::baseUrl() return url; } -QString BaselineServer::settingsFilePath() +QStringList BaselineServer::defaultPathKeys() { - if (settingsFile.isEmpty()) { - QString exeName = QCoreApplication::applicationFilePath().section(QLC('/'), -1); - settingsFile = storagePath() + QLC('/') + exeName + QLS(".ini"); - } - return settingsFile; + if (pathKeys.isEmpty()) + pathKeys << PI_QtVersion << PI_QMakeSpec << PI_HostName; + return pathKeys; } void BaselineServer::incomingConnection(qintptr socketDescriptor) @@ -152,9 +151,13 @@ void BaselineThread::run() BaselineHandler::BaselineHandler(const QString &runId, int socketDescriptor) - : QObject(), runId(runId), connectionEstablished(false) + : QObject(), runId(runId), connectionEstablished(false), settings(0), fuzzLevel(0) { - settings = new QSettings(BaselineServer::settingsFilePath(), QSettings::IniFormat, this); + 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; @@ -162,6 +165,7 @@ BaselineHandler::BaselineHandler(const QString &runId, int socketDescriptor) 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() @@ -170,6 +174,85 @@ const char *BaselineHandler::logtime() //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)) { @@ -187,35 +270,46 @@ bool BaselineHandler::establishConnection() << "[" << qPrintable(clientInfo.value(PI_HostAddress)) << "]" << logMsg << "Overrides:" << clientInfo.overrides() << "AdHoc-Run:" << clientInfo.isAdHocRun(); - //### Temporarily override the client setting, for client compatibility: - if (!clientInfo.isAdHocRun()) - clientInfo.setAdHocRun(clientInfo.value(PI_PulseGitBranch).isEmpty() && clientInfo.value(PI_PulseTestrBranch).isEmpty()); + // ### 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")); + } - settings->beginGroup("ClientFilters"); - if (!clientInfo.isAdHocRun()) { // for CI runs, allow filtering of clients. TBD: different filters (settings file) per testCase - foreach (QString filterKey, settings->childKeys()) { - QString filter = settings->value(filterKey).toString(); - QString platVal = clientInfo.value(filterKey); - if (filter.isEmpty()) - continue; // tbd: add a syntax for specifying a "value-must-be-present" filter - if (!platVal.contains(filter)) { - qDebug() << runId << logtime() << "Did not pass client filter on" << filterKey << "; disconnecting."; - proto.sendBlock(BaselineProtocol::Abort, QByteArray("Configured to not do testing for this client or repo, ref. ") + BaselineServer::settingsFilePath().toLatin1()); - proto.socket.disconnectFromHost(); - return false; - } - } + 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; } - settings->endGroup(); - proto.sendBlock(BaselineProtocol::Ack, QByteArray()); + 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(); + } - report.init(this, runId, clientInfo); + 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; @@ -233,6 +327,9 @@ void BaselineHandler::receiveRequest() case BaselineProtocol::RequestBaselineChecksums: provideBaselineChecksums(block); break; + case BaselineProtocol::AcceptMatch: + recordMatch(block); + break; case BaselineProtocol::AcceptNewBaseline: storeImage(block, true); break; @@ -295,6 +392,16 @@ void BaselineHandler::provideBaselineChecksums(const QByteArray &itemListBlock) } +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); @@ -307,16 +414,23 @@ void BaselineHandler::storeImage(const QByteArray &itemBlock, bool isBaseline) return; } - QString prefix = pathForItem(item, isBaseline); + 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: ") + pathForItem(item, true, true) + QLS(FileFormat); + msg = QLS("New baseline image stored: ") + blPrefix + QLS(FileFormat); else msg = BaselineServer::baseUrl() + report.filePath(); - proto.sendBlock(BaselineProtocol::Ack, msg.toLatin1()); + 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)) @@ -329,8 +443,23 @@ void BaselineHandler::storeImage(const QByteArray &itemBlock, bool isBaseline) itemData.insert(PI_CreationDate, QDateTime::currentDateTime().toString()); storeItemMetadata(itemData, prefix); - if (!isBaseline) - report.addMismatch(item); + 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); + } } @@ -355,7 +484,7 @@ PlatformInfo BaselineHandler::fetchItemMetadata(const QString &path) { PlatformInfo res; QFile file(path + QLS(MetadataFileExt)); - if (!file.open(QIODevice::ReadOnly)) + if (!file.open(QIODevice::ReadOnly) || !QFile::exists(path + QLS(FileFormat))) return res; QTextStream in(&file); do { @@ -368,20 +497,50 @@ PlatformInfo BaselineHandler::fetchItemMetadata(const QString &path) } +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 = orig; + 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 + } - // Map hostname - QString host = orig.value(PI_HostName).section(QLC('.'), 0, 0); // Filter away domain, if any + // 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 { @@ -392,16 +551,15 @@ PlatformInfo BaselineHandler::mapPlatformInfo(const PlatformInfo& orig) const } } if (host.isEmpty()) - host = QLS("unknownhost"); + 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); - // Map qmakespec - QString mkspec = orig.value(PI_QMakeSpec); - mapped.insert(PI_QMakeSpec, mkspec.replace(QLC('/'), QLC('_'))); - - // Map Qt version - QString ver = orig.value(PI_QtVersion); - mapped.insert(PI_QtVersion, ver.prepend(QLS("Qt-"))); + // Special fixup for Qt version + QString ver = mapped.value(PI_QtVersion); + if (!ver.isEmpty()) + mapped.insert(PI_QtVersion, ver.prepend(QLS("Qt-"))); return mapped; } @@ -412,6 +570,7 @@ QString BaselineHandler::pathForItem(const ImageItem &item, bool isBaseline, boo 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); @@ -419,21 +578,21 @@ QString BaselineHandler::pathForItem(const ImageItem &item, bool isBaseline, boo const PlatformInfo& mapped = isBaseline ? overriddenMappedClientInfo : mappedClientInfo; - QString itemName = item.itemName.simplified(); - itemName.replace(QLC(' '), QLC('_')); - itemName.replace(QLC('.'), QLC('_')); - itemName.append(QLC('_')); - itemName.append(QString::number(item.itemChecksum, 16).rightJustified(4, QLC('0'))); + QString itemName = safeName(item.itemName); + itemName.append(QLC('_') + QString::number(item.itemChecksum, 16).rightJustified(4, QLC('0'))); QStringList path; - if (absolute) - path += BaselineServer::storagePath(); + path += projectPath(absolute); path += mapped.value(PI_TestCase); path += QLS(isBaseline ? "baselines" : "mismatches"); path += item.testFunction; - path += mapped.value(PI_QtVersion); - path += mapped.value(PI_QMakeSpec); - path += mapped.value(PI_HostName); + 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('.'); @@ -446,19 +605,33 @@ QString BaselineHandler::view(const QString &baseline, const QString &rendered, { QFile f(":/templates/view.html"); f.open(QIODevice::ReadOnly); - return QString::fromLatin1(f.readAll()).arg('/'+baseline, '/'+rendered, '/'+compared); + 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)); + QStringList() << QLS("*.") + QLS(FileFormat) + << QLS("*.") + QLS(MetadataFileExt) + << QLS("*.") + QLS(ThumbnailExt)); while (it.hasNext()) { - tot++; - if (!QFile::remove(it.next())) + 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; @@ -471,13 +644,17 @@ QString BaselineHandler::updateBaselines(const QString &context, const QString & 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)); + QDirIterator it(storagePrefix + mismatchContext, + QStringList() << filter + QLS(FileFormat) + << filter + QLS(MetadataFileExt) + << filter + QLS(ThumbnailExt)); while (it.hasNext()) { - tot++; - it.next(); + 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)) // and replace it with the mismatch + 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); @@ -534,7 +711,7 @@ void BaselineHandler::testPathMapping() 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); @@ -547,9 +724,9 @@ void BaselineHandler::testPathMapping() QString BaselineHandler::computeMismatchScore(const QImage &baseline, const QImage &rendered) { if (baseline.size() != rendered.size() || baseline.format() != rendered.format()) - return QLS("[No score, incomparable images.]"); + return QLS("[No diffstats, incomparable images.]"); if (baseline.depth() != 32) - return QLS("[Score computation not implemented for format.]"); + return QLS("[Diffstats computation not implemented for format.]"); int w = baseline.width(); int h = baseline.height(); @@ -558,6 +735,8 @@ QString BaselineHandler::computeMismatchScore(const QImage &baseline, const QIma 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 maxd) + maxd = ds; } if (da) { nad++; @@ -583,13 +766,100 @@ QString BaselineHandler::computeMismatchScore(const QImage &baseline, const QIma } } + double pcd = 100.0 * ncd / (w*h); // percent of pixels that differ - double acd = ncd ? double(scd) / (3*ncd) : 0; // avg. difference - QString res = QString(QLS("Diffscore: %1% (Num:%2 Avg:%3)")).arg(pcd, 0, 'g', 2).arg(ncd).arg(acd, 0, 'g', 2); + 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 - res += QString(QLS(" Alpha-diffscore: %1% (Num:%2 Avg:%3)")).arg(pad, 0, 'g', 2).arg(nad).arg(aad, 0, 'g', 2); } +*/ + QString res = "\n"; + QString item = "\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 += "
%1%2
\n"; + res += "

(Distances are normalized to the range 0-255)

\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; } diff --git a/tests/baselineserver/src/baselineserver.h b/tests/baselineserver/src/baselineserver.h index c8ce3860fb..d30a74195d 100644 --- a/tests/baselineserver/src/baselineserver.h +++ b/tests/baselineserver/src/baselineserver.h @@ -53,9 +53,14 @@ #include "baselineprotocol.h" #include "report.h" -// #seconds between update checks +// #seconds between checks for update of the executable #define HEARTBEAT 10 +// Timeout if no activity received from client, #seconds +#define IDLE_CLIENT_TIMEOUT 3*60 + #define MetadataFileExt "metadata" +#define ThumbnailExt "thumbnail.jpg" + class BaselineServer : public QTcpServer { @@ -66,7 +71,7 @@ public: static QString storagePath(); static QString baseUrl(); - static QString settingsFilePath(); + static QStringList defaultPathKeys(); protected: void incomingConnection(qintptr socketDescriptor); @@ -81,7 +86,7 @@ private: int lastRunIdIdx; static QString storage; static QString url; - static QString settingsFile; + static QStringList pathKeys; }; @@ -106,28 +111,38 @@ class BaselineHandler : public QObject public: BaselineHandler(const QString &runId, int socketDescriptor = -1); - void testPathMapping(); + QString projectPath(bool absolute = true) const; QString pathForItem(const ImageItem &item, bool isBaseline = true, bool absolute = true) const; // CGI callbacks: static QString view(const QString &baseline, const QString &rendered, const QString &compared); + static QString diffstats(const QString &baseline, const QString &rendered); static QString clearAllBaselines(const QString &context); static QString updateBaselines(const QString &context, const QString &mismatchContext, const QString &itemFile); static QString blacklistTest(const QString &context, const QString &itemId, bool removeFromBlacklist = false); + // for debugging + void testPathMapping(); + private slots: void receiveRequest(); void receiveDisconnect(); + void idleClientTimeout(); private: + bool checkClient(QByteArray *errMsg, bool *dryRunMode = 0); bool establishConnection(); void provideBaselineChecksums(const QByteArray &itemListBlock); + void recordMatch(const QByteArray &itemBlock); void storeImage(const QByteArray &itemBlock, bool isBaseline); void storeItemMetadata(const PlatformInfo &metadata, const QString &path); PlatformInfo fetchItemMetadata(const QString &path); PlatformInfo mapPlatformInfo(const PlatformInfo& orig) const; const char *logtime(); - QString computeMismatchScore(const QImage& baseline, const QImage& rendered); + void issueMismatchNotification(); + bool fuzzyCompare(const QString& baselinePath, const QString& mismatchPath); + + static QString computeMismatchScore(const QImage& baseline, const QImage& rendered); BaselineProtocol proto; PlatformInfo clientInfo; @@ -137,6 +152,13 @@ private: bool connectionEstablished; Report report; QSettings *settings; + QString ruleName; + int fuzzLevel; + QTimer *idleTimer; }; + +// Make an identifer safer for use as filename and URL +QString safeName(const QString& name); + #endif // BASELINESERVER_H diff --git a/tests/baselineserver/src/report.cpp b/tests/baselineserver/src/report.cpp index d29f22e2a3..cd544a6c78 100644 --- a/tests/baselineserver/src/report.cpp +++ b/tests/baselineserver/src/report.cpp @@ -44,9 +44,11 @@ #include #include #include +#include Report::Report() - : written(false), numItems(0), numMismatches(0) + : initialized(false), handler(0), written(false), numItems(0), numMismatches(0), settings(0), + hasStats(false) { } @@ -60,19 +62,31 @@ QString Report::filePath() return path; } -void Report::init(const BaselineHandler *h, const QString &r, const PlatformInfo &p) +int Report::numberOfMismatches() +{ + return numMismatches; +} + +bool Report::reportProduced() +{ + return written; +} + +void Report::init(const BaselineHandler *h, const QString &r, const PlatformInfo &p, const QSettings *s) { handler = h; runId = r; plat = p; + settings = s; rootDir = BaselineServer::storagePath() + QLC('/'); - reportDir = plat.value(PI_TestCase) + QLC('/') + (plat.isAdHocRun() ? QLS("reports/adhoc/") : QLS("reports/pulse/")); - QString dir = rootDir + reportDir; + baseDir = handler->pathForItem(ImageItem(), true, false).remove(QRegExp("/baselines/.*$")); + QString dir = baseDir + (plat.isAdHocRun() ? QLS("/adhoc-reports") : QLS("/auto-reports")); QDir cwd; - if (!cwd.exists(dir)) - cwd.mkpath(dir); - path = reportDir + QLS("Report_") + runId + QLS(".html"); + if (!cwd.exists(rootDir + dir)) + cwd.mkpath(rootDir + dir); + path = dir + QLS("/Report_") + runId + QLS(".html"); hasOverride = !plat.overrides().isEmpty(); + initialized = true; } void Report::addItems(const ImageItemList &items) @@ -83,38 +97,110 @@ void Report::addItems(const ImageItemList &items) QString func = items.at(0).testFunction; if (!testFunctions.contains(func)) testFunctions.append(func); - itemLists[func] += items; + ImageItemList list = items; + if (settings->value("ReportMissingResults").toBool()) { + for (ImageItemList::iterator it = list.begin(); it != list.end(); ++it) { + if (it->status == ImageItem::Ok) + it->status = ImageItem::Error; // Status should be set by report from client, else report as error + } + } + itemLists[func] += list; } -void Report::addMismatch(const ImageItem &item) +void Report::addResult(const ImageItem &item) { if (!testFunctions.contains(item.testFunction)) { - qWarning() << "Report::addMismatch: unknown testfunction" << item.testFunction; + qWarning() << "Report::addResult: unknown testfunction" << item.testFunction; return; } bool found = false; ImageItemList &list = itemLists[item.testFunction]; for (ImageItemList::iterator it = list.begin(); it != list.end(); ++it) { if (it->itemName == item.itemName && it->itemChecksum == item.itemChecksum) { - it->status = ImageItem::Mismatch; + it->status = item.status; found = true; break; } } - if (found) - numMismatches++; - else - qWarning() << "Report::addMismatch: unknown item" << item.itemName << "in testfunction" << item.testFunction; + if (found) { + if (item.status == ImageItem::Mismatch) + numMismatches++; + } else { + qWarning() << "Report::addResult: unknown item" << item.itemName << "in testfunction" << item.testFunction; + } } void Report::end() { - if (written || !numMismatches) + if (!initialized || written) + return; + // Make report iff (#mismatches>0) || (#fuzzymatches>0) || (#errors>0 && settings say report errors) + bool doReport = (numMismatches > 0); + if (!doReport) { + bool reportErrors = settings->value("ReportMissingResults").toBool(); + computeStats(); + foreach (const QString &func, itemLists.keys()) { + FuncStats stat = stats.value(func); + if (stat.value(ImageItem::FuzzyMatch) > 0) { + doReport = true; + break; + } + foreach (const ImageItem &item, itemLists.value(func)) { + if (reportErrors && item.status == ImageItem::Error) { + doReport = true; + break; + } + } + if (doReport) + break; + } + } + if (!doReport) return; write(); written = true; } +void Report::computeStats() +{ + if (hasStats) + return; + foreach (const QString &func, itemLists.keys()) { + FuncStats funcStat; + funcStat[ImageItem::Ok] = 0; + funcStat[ImageItem::BaselineNotFound] = 0; + funcStat[ImageItem::IgnoreItem] = 0; + funcStat[ImageItem::Mismatch] = 0; + funcStat[ImageItem::FuzzyMatch] = 0; + funcStat[ImageItem::Error] = 0; + foreach (const ImageItem &item, itemLists.value(func)) { + funcStat[item.status]++; + } + stats[func] = funcStat; + } + hasStats = true; +} + +QString Report::summary() +{ + computeStats(); + QString res; + foreach (const QString &func, itemLists.keys()) { + FuncStats stat = stats.value(func); + QString s = QString("%1 %3 mismatch(es), %4 error(s), %5 fuzzy match(es)\n"); + s = s.arg(QString("%1() [%2 items]:").arg(func).arg(itemLists.value(func).size()).leftJustified(40)); + s = s.arg(stat.value(ImageItem::Mismatch)); + s = s.arg(stat.value(ImageItem::Error)); + s = s.arg(stat.value(ImageItem::FuzzyMatch)); + res += s; + } +#if 0 + qDebug() << "***************************** Summary *************************"; + qDebug() << res; + qDebug() << "***************************************************************"; +#endif + return res; +} void Report::write() { @@ -131,24 +217,27 @@ void Report::write() } writeFooter(); file.close(); + updateLatestPointer(); } void Report::writeHeader() { - QString title = plat.value(PI_TestCase) + QLS(" Qt Baseline Test Report"); - out << "" << title << "\n" - << "

" << title << "

\n" + QString title = plat.value(PI_Project) + QLC(':') + plat.value(PI_TestCase) + QLS(" Lancelot Test Report"); + out << "\n" + << "" << title << "\n" + << "

" << title << "

\n" << "

Note: This is a static page, generated at " << QDateTime::currentDateTime().toString() << " for the test run with id " << runId << "

\n" - << "

Summary: " << numMismatches << " of " << numItems << " items reported mismatching

\n\n"; + << "

Summary: " << numMismatches << " of " << numItems << " items reported mismatching

\n"; + out << "
\n" << summary() << "
\n\n"; out << "

Testing Client Platform Info:

\n" << "\n"; foreach (QString key, plat.keys()) out << "\n"; out << "
" << key << ":" << plat.value(key) << "
\n\n"; if (hasOverride) { - out << "

Note! Platform Override Info:

\n" + out << "

Note! Override Platform Info:

\n" << "

The client's output has been compared to baselines created on a different platform. Differences:

\n" << "\n"; for (int i = 0; i < plat.overrides().size()-1; i+=2) @@ -184,14 +273,25 @@ void Report::writeFunctionResults(const ImageItemList &list) "\n\n"; foreach (const ImageItem &item, list) { + QString mmPrefix = handler->pathForItem(item, false, false); + QString blPrefix = handler->pathForItem(item, true, false); + + // Make hard links to the current baseline, so that the report is static even if the baseline changes + generateThumbnail(blPrefix + QLS(FileFormat), rootDir); // Make sure baseline thumbnail is up to date + QString lnPrefix = mmPrefix + QLS("baseline."); + QByteArray blPrefixBa = (rootDir + blPrefix).toLatin1(); + QByteArray lnPrefixBa = (rootDir + lnPrefix).toLatin1(); + ::link((blPrefixBa + FileFormat).constData(), (lnPrefixBa + FileFormat).constData()); + ::link((blPrefixBa + MetadataFileExt).constData(), (lnPrefixBa + MetadataFileExt).constData()); + ::link((blPrefixBa + ThumbnailExt).constData(), (lnPrefixBa + ThumbnailExt).constData()); + + QString baseline = lnPrefix + QLS(FileFormat); + QString metadata = lnPrefix + QLS(MetadataFileExt); out << "\n"; out << "\n"; - QString prefix = handler->pathForItem(item, true, false); - QString baseline = prefix + QLS(FileFormat); - QString metadata = prefix + QLS(MetadataFileExt); - if (item.status == ImageItem::Mismatch) { - QString rendered = handler->pathForItem(item, false, false) + QLS(FileFormat); - QString itemFile = prefix.section(QLC('/'), -1); + if (item.status == ImageItem::Mismatch || item.status == ImageItem::FuzzyMatch) { + QString rendered = mmPrefix + QLS(FileFormat); + QString itemFile = mmPrefix.section(QLC('/'), -1); writeItem(baseline, rendered, item, itemFile, ctx, misCtx, metadata); } else { @@ -210,6 +310,9 @@ void Report::writeFunctionResults(const ImageItemList &list) << "\">Whitelist this item"; } break; + case ImageItem::Error: + out << "Error: No result reported!"; + break; case ImageItem::Ok: out << "No mismatch reported"; break; @@ -233,11 +336,14 @@ void Report::writeItem(const QString &baseline, const QString &rendered, const I QStringList images = QStringList() << baseline << rendered << compared; foreach (const QString& img, images) - out << "\n"; + out << "\n"; - out << "\n"; + << "&compared=" << compared << "&url=" << pageUrl << "\">Inspect

\n"; + +#if 0 + out << "

Diffstats

\n"; +#endif + + out << "\n"; } void Report::writeFooter() @@ -259,8 +371,8 @@ QString Report::generateCompared(const QString &baseline, const QString &rendere { QString res = rendered; QFileInfo fi(res); - res.chop(fi.suffix().length() + 1); - res += QLS(fuzzy ? "_fuzzycompared.png" : "_compared.png"); + res.chop(fi.suffix().length()); + res += QLS(fuzzy ? "fuzzycompared.png" : "compared.png"); QStringList args; if (fuzzy) args << QLS("-fuzz") << QLS("5%"); @@ -270,12 +382,14 @@ QString Report::generateCompared(const QString &baseline, const QString &rendere } -QString Report::generateThumbnail(const QString &image) +QString Report::generateThumbnail(const QString &image, const QString &rootDir) { QString res = image; QFileInfo imgFI(rootDir+image); - res.chop(imgFI.suffix().length() + 1); - res += QLS("_thumbnail.jpg"); + if (!imgFI.exists()) + return res; + res.chop(imgFI.suffix().length()); + res += ThumbnailExt; QFileInfo resFI(rootDir+res); if (resFI.exists() && resFI.lastModified() > imgFI.lastModified()) return res; @@ -286,12 +400,80 @@ QString Report::generateThumbnail(const QString &image) } +QString Report::writeResultsXmlFiles() +{ + if (!itemLists.size()) + return QString(); + QString dir = rootDir + baseDir + QLS("/xml-reports/") + runId; + QDir cwd; + if (!cwd.exists(dir)) + cwd.mkpath(dir); + foreach (const QString &func, itemLists.keys()) { + QFile f(dir + "/" + func + "-results.xml"); + if (!f.open(QIODevice::WriteOnly)) + continue; + QXmlStreamWriter s(&f); + s.setAutoFormatting(true); + s.writeStartDocument(); + foreach (QString key, plat.keys()) { + QString cmt = " " + key + "=\"" + plat.value(key) +"\" "; + s.writeComment(cmt.replace("--", "[-]")); + } + s.writeStartElement("testsuite"); + s.writeAttribute("name", func); + foreach (const ImageItem &item, itemLists.value(func)) { + QString res; + switch (item.status) { + case ImageItem::Ok: + case ImageItem::FuzzyMatch: + res = "pass"; + break; + case ImageItem::Mismatch: + case ImageItem::Error: + res = "fail"; + break; + case ImageItem::BaselineNotFound: + case ImageItem::IgnoreItem: + default: + res = "skip"; + } + s.writeStartElement("testcase"); + s.writeAttribute("name", item.itemName); + s.writeAttribute("result", res); + s.writeEndElement(); + } + s.writeEndElement(); + s.writeEndDocument(); + } + return dir; +} + + + +void Report::updateLatestPointer() +{ + QString linkPath = rootDir + baseDir + QLS("/latest_report.html"); + QString reportPath = path.mid(baseDir.size()+1); + QFile::remove(linkPath); // possible race with another thread, yada yada yada + QFile::link(reportPath, linkPath); + +#if 0 + QByteArray fwd = "\n"; + fwd.replace("%1", filePath().prepend(QLC('/')).toLatin1()); + + QFile file(rootDir + baseDir + "/latest_report.html"); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + file.write(fwd); +#endif +} + + void Report::handleCGIQuery(const QString &query) { QUrl cgiUrl(QLS("http://dummy/cgi-bin/dummy.cgi?") + query); QTextStream s(stdout); s << "Content-Type: text/html\r\n\r\n" - << ""; + << "\n\n\n"; // Lancelot blue QString command(cgiUrl.queryItemValue("cmd")); @@ -300,6 +482,12 @@ void Report::handleCGIQuery(const QString &query) cgiUrl.queryItemValue(QLS("rendered")), cgiUrl.queryItemValue(QLS("compared"))); } +#if 0 + else if (command == QLS("diffstats")) { + s << BaselineHandler::diffstats(cgiUrl.queryItemValue(QLS("baseline")), + cgiUrl.queryItemValue(QLS("rendered"))); + } +#endif else if (command == QLS("updateSingleBaseline")) { s << BaselineHandler::updateBaselines(cgiUrl.queryItemValue(QLS("context")), cgiUrl.queryItemValue(QLS("mismatchContext")), @@ -321,6 +509,6 @@ void Report::handleCGIQuery(const QString &query) } else { s << "Unknown query:
" << query << "
"; } - s << "

Back to report"; - s << ""; + s << "

Back to report\n"; + s << "\n"; } diff --git a/tests/baselineserver/src/report.h b/tests/baselineserver/src/report.h index 660593784e..918d09d420 100644 --- a/tests/baselineserver/src/report.h +++ b/tests/baselineserver/src/report.h @@ -46,6 +46,7 @@ #include #include #include +#include class BaselineHandler; @@ -55,15 +56,24 @@ public: Report(); ~Report(); - void init(const BaselineHandler *h, const QString &r, const PlatformInfo &p); + void init(const BaselineHandler *h, const QString &r, const PlatformInfo &p, const QSettings *s); void addItems(const ImageItemList& items); - void addMismatch(const ImageItem& item); + void addResult(const ImageItem& item); void end(); + bool reportProduced(); + + int numberOfMismatches(); + QString summary(); + QString filePath(); + QString writeResultsXmlFiles(); + static void handleCGIQuery(const QString &query); + static QString generateThumbnail(const QString &image, const QString &rootDir = QString()); + private: void write(); void writeFunctionResults(const ImageItemList &list); @@ -72,13 +82,17 @@ private: void writeHeader(); void writeFooter(); QString generateCompared(const QString &baseline, const QString &rendered, bool fuzzy = false); - QString generateThumbnail(const QString &image); + void updateLatestPointer(); + + void computeStats(); + + bool initialized; const BaselineHandler *handler; QString runId; PlatformInfo plat; QString rootDir; - QString reportDir; + QString baseDir; QString path; QStringList testFunctions; QMap itemLists; @@ -87,6 +101,11 @@ private: int numMismatches; QTextStream out; bool hasOverride; + const QSettings *settings; + + typedef QMap FuncStats; + QMap stats; + bool hasStats; }; #endif // REPORT_H diff --git a/tests/baselineserver/src/templates/view.html b/tests/baselineserver/src/templates/view.html index c048f4781c..f0971010f2 100644 --- a/tests/baselineserver/src/templates/view.html +++ b/tests/baselineserver/src/templates/view.html @@ -23,9 +23,14 @@ Zoom:

" << item.itemName << "\n" - << "

Mismatch reported

\n" - << "

Baseline Info\n"; + out << "

\n"; + if (item.status == ImageItem::FuzzyMatch) + out << "

Fuzzy match

\n"; + else + out << "

Mismatch reported

\n"; + out << "

Baseline Info\n"; if (!hasOverride) { out << "

Let this be the new baseline

\n" @@ -245,8 +351,14 @@ void Report::writeItem(const QString &baseline, const QString &rendered, const I << "&itemId=" << item.itemName << "&url=" << pageUrl << "\">Blacklist this item

\n"; } out << "

Inspect

\n" - << "

-

+

+ + +
-

+
+%4 +