diff options
Diffstat (limited to 'src/tools/androidtestrunner/main.cpp')
-rw-r--r-- | src/tools/androidtestrunner/main.cpp | 649 |
1 files changed, 437 insertions, 212 deletions
diff --git a/src/tools/androidtestrunner/main.cpp b/src/tools/androidtestrunner/main.cpp index 0a2c8e63b8..baad678ec0 100644 --- a/src/tools/androidtestrunner/main.cpp +++ b/src/tools/androidtestrunner/main.cpp @@ -1,27 +1,25 @@ // Copyright (C) 2019 BogDan Vatra <bogdan@kde.org> -// Copyright (C) 2022 The Qt Company Ltd. +// Copyright (C) 2023 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 -#include <QCoreApplication> -#include <QDir> -#include <QHash> -#include <QRegularExpression> -#include <QSystemSemaphore> -#include <QXmlStreamReader> - -#include <algorithm> -#include <chrono> +#include <QtCore/QCoreApplication> +#include <QtCore/QDeadlineTimer> +#include <QtCore/QDir> +#include <QtCore/QHash> +#include <QtCore/QProcess> +#include <QtCore/QProcessEnvironment> +#include <QtCore/QRegularExpression> +#include <QtCore/QSystemSemaphore> +#include <QtCore/QThread> +#include <QtCore/QXmlStreamReader> + +#include <atomic> +#include <csignal> #include <functional> -#include <thread> - -#include <shellquote_shared.h> - -#ifdef Q_CC_MSVC -#define popen _popen -#define QT_POPEN_READ "rb" -#define pclose _pclose +#if defined(Q_OS_WIN32) +#include <process.h> #else -#define QT_POPEN_READ "r" +#include <unistd.h> #endif using namespace Qt::StringLiterals; @@ -34,12 +32,12 @@ static bool checkJunit(const QByteArray &data) { if (!reader.isStartElement()) continue; - if (reader.name() == QStringLiteral("error")) + if (reader.name() == "error"_L1) return false; - const QString type = reader.attributes().value(QStringLiteral("type")).toString(); - if (reader.name() == QStringLiteral("failure")) { - if (type == QStringLiteral("fail") || type == QStringLiteral("xpass")) + const QString type = reader.attributes().value("type"_L1).toString(); + if (reader.name() == "failure"_L1) { + if (type == "fail"_L1 || type == "xpass"_L1) return false; } } @@ -70,10 +68,10 @@ static bool checkXml(const QByteArray &data) { QXmlStreamReader reader{data}; while (!reader.atEnd()) { reader.readNext(); - const QString type = reader.attributes().value(QStringLiteral("type")).toString(); - const bool isIncident = (reader.name() == QStringLiteral("Incident")); + const QString type = reader.attributes().value("type"_L1).toString(); + const bool isIncident = (reader.name() == "Incident"_L1); if (reader.isStartElement() && isIncident) { - if (type == QStringLiteral("fail") || type == QStringLiteral("xpass")) + if (type == "fail"_L1 || type == "xpass"_L1) return false; } } @@ -115,55 +113,88 @@ struct Options bool helpRequested = false; bool verbose = false; bool skipAddInstallRoot = false; - std::chrono::seconds timeout{480}; // 8 minutes + int timeoutSecs = 600; // 10 minutes QString buildPath; - QString adbCommand{QStringLiteral("adb")}; + QString adbCommand{"adb"_L1}; QString makeCommand; QString package; QString activity; QStringList testArgsList; QHash<QString, QString> outFiles; - QString testArgs; + QStringList amStarttestArgs; QString apkPath; - int sdkVersion = -1; - int pid = -1; + QString ndkStackPath; bool showLogcatOutput = false; const QHash<QString, std::function<bool(const QByteArray &)>> checkFiles = { - {QStringLiteral("txt"), checkTxt}, - {QStringLiteral("csv"), checkCsv}, - {QStringLiteral("xml"), checkXml}, - {QStringLiteral("lightxml"), checkLightxml}, - {QStringLiteral("xunitxml"), checkJunit}, - {QStringLiteral("junitxml"), checkJunit}, - {QStringLiteral("teamcity"), checkTeamcity}, - {QStringLiteral("tap"), checkTap}, + {"txt"_L1, checkTxt}, + {"csv"_L1, checkCsv}, + {"xml"_L1, checkXml}, + {"lightxml"_L1, checkLightxml}, + {"xunitxml"_L1, checkJunit}, + {"junitxml"_L1, checkJunit}, + {"teamcity"_L1, checkTeamcity}, + {"tap"_L1, checkTap}, }; }; static Options g_options; -static bool execCommand(const QString &command, QByteArray *output = nullptr, bool verbose = false) +struct TestInfo +{ + int sdkVersion = -1; + int pid = -1; + + std::atomic<bool> isPackageInstalled { false }; + std::atomic<bool> isTestRunnerInterrupted { false }; +}; + +static TestInfo g_testInfo; + +static bool execCommand(const QString &program, const QStringList &args, + QByteArray *output = nullptr, bool verbose = false) { - if (verbose) - fprintf(stdout, "Execute %s.\n", command.toUtf8().constData()); - FILE *process = popen(command.toUtf8().constData(), QT_POPEN_READ); + const auto command = program + " "_L1 + args.join(u' '); - if (!process) { - fprintf(stderr, "Cannot execute command %s.\n", qPrintable(command)); + if (verbose && g_options.verbose) + qDebug("Execute %s.", command.toUtf8().constData()); + + QProcess process; + process.start(program, args); + if (!process.waitForStarted()) { + qCritical("Cannot execute command %s.", qPrintable(command)); return false; } - char buffer[512]; - while (fgets(buffer, sizeof(buffer), process)) { - if (output) - output->append(buffer); - if (verbose) - fprintf(stdout, "%s", buffer); + + // If the command is not adb, for example, make or ninja, it can take more that + // QProcess::waitForFinished() 30 secs, so for that use a higher timeout. + const int FinishTimeout = program.endsWith("adb"_L1) ? 30000 : g_options.timeoutSecs * 1000; + if (!process.waitForFinished(FinishTimeout)) { + qCritical("Execution of command %s timed out.", qPrintable(command)); + return false; } - fflush(stdout); - fflush(stderr); + const auto stdOut = process.readAllStandardOutput(); + if (output) + output->append(stdOut); + + if (verbose && g_options.verbose) + qDebug() << stdOut.constData(); + + return process.exitCode() == 0; +} + +static bool execAdbCommand(const QStringList &args, QByteArray *output = nullptr, + bool verbose = true) +{ + return execCommand(g_options.adbCommand, args, output, verbose); +} - return pclose(process) == 0; +static bool execCommand(const QString &command, QByteArray *output = nullptr, bool verbose = true) +{ + auto args = QProcess::splitCommand(command); + const auto program = args.first(); + args.removeOne(program); + return execCommand(program, args, output, verbose); } static bool parseOptions() @@ -172,45 +203,50 @@ static bool parseOptions() int i = 1; for (; i < arguments.size(); ++i) { const QString &argument = arguments.at(i); - if (argument.compare(QStringLiteral("--adb"), Qt::CaseInsensitive) == 0) { + if (argument.compare("--adb"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else g_options.adbCommand = arguments.at(++i); - } else if (argument.compare(QStringLiteral("--path"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--path"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else g_options.buildPath = arguments.at(++i); - } else if (argument.compare(QStringLiteral("--make"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--make"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else g_options.makeCommand = arguments.at(++i); - } else if (argument.compare(QStringLiteral("--apk"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--apk"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else g_options.apkPath = arguments.at(++i); - } else if (argument.compare(QStringLiteral("--activity"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--activity"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else g_options.activity = arguments.at(++i); - } else if (argument.compare(QStringLiteral("--skip-install-root"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--skip-install-root"_L1, Qt::CaseInsensitive) == 0) { g_options.skipAddInstallRoot = true; - } else if (argument.compare(QStringLiteral("--show-logcat"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--show-logcat"_L1, Qt::CaseInsensitive) == 0) { g_options.showLogcatOutput = true; - } else if (argument.compare(QStringLiteral("--timeout"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--ndk-stack"_L1, Qt::CaseInsensitive) == 0) { if (i + 1 == arguments.size()) g_options.helpRequested = true; else - g_options.timeout = std::chrono::seconds{arguments.at(++i).toInt()}; - } else if (argument.compare(QStringLiteral("--help"), Qt::CaseInsensitive) == 0) { + g_options.ndkStackPath = arguments.at(++i); + } else if (argument.compare("--timeout"_L1, Qt::CaseInsensitive) == 0) { + if (i + 1 == arguments.size()) + g_options.helpRequested = true; + else + g_options.timeoutSecs = arguments.at(++i).toInt(); + } else if (argument.compare("--help"_L1, Qt::CaseInsensitive) == 0) { g_options.helpRequested = true; - } else if (argument.compare(QStringLiteral("--verbose"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--verbose"_L1, Qt::CaseInsensitive) == 0) { g_options.verbose = true; - } else if (argument.compare(QStringLiteral("--"), Qt::CaseInsensitive) == 0) { + } else if (argument.compare("--"_L1, Qt::CaseInsensitive) == 0) { ++i; break; } else { @@ -225,17 +261,24 @@ static bool parseOptions() QString serial = qEnvironmentVariable("ANDROID_DEVICE_SERIAL"); if (!serial.isEmpty()) - g_options.adbCommand += QStringLiteral(" -s %1").arg(serial); + g_options.adbCommand += " -s %1"_L1.arg(serial); + + if (g_options.ndkStackPath.isEmpty()) { + const QString ndkPath = qEnvironmentVariable("ANDROID_NDK_ROOT"); + const QString ndkStackPath = ndkPath + QDir::separator() + "ndk-stack"_L1; + if (QFile::exists(ndkStackPath)) + g_options.ndkStackPath = ndkStackPath; + } + return true; } static void printHelp() { - fprintf(stderr, "Syntax: %s <options> -- [TESTARGS] \n" + qWarning( "Syntax: %s <options> -- [TESTARGS] \n" "\n" - " Creates an Android package in a temp directory <destination> and\n" - " runs it on the default emulator/device or on the one specified by\n" - " \"ANDROID_DEVICE_SERIAL\" environment variable.\n" + " Runs an Android test on the default emulator/device or on the one\n" + " specified by \"ANDROID_DEVICE_SERIAL\" environment variable.\n" "\n" " Mandatory arguments:\n" " --path <path>: The path where androiddeployqt builds the android package.\n" @@ -245,7 +288,7 @@ static void printHelp() "\n" " Optional arguments:\n" " --make <make cmd>: make command, needed to install the qt library.\n" - " For Qt 5.14+ this can be \"make apk\".\n" + " For Qt 6, this can be \"cmake --build . --target <target>_make_apk\".\n" "\n" " --adb <adb cmd>: The Android ADB command. If missing the one from\n" " $PATH will be used.\n" @@ -253,17 +296,20 @@ static void printHelp() " --activity <acitvity>: The Activity to run. If missing the first\n" " activity from AndroidManifest.qml file will be used.\n" "\n" - " --timeout <seconds>: Timeout to run the test. Default is 5 minutes.\n" + " --timeout <seconds>: Timeout to run the test. Default is 10 minutes.\n" "\n" " --skip-install-root: Do not append INSTALL_ROOT=... to the make command.\n" "\n" " --show-logcat: Print Logcat output to stdout.\n" "\n" + " --ndk-stack: Path to ndk-stack tool that symbolizes crash stacktraces.\n" + " By default, ANDROID_NDK_ROOT env var is used to deduce the tool path.\n" + "\n" " -- Arguments that will be passed to the test application.\n" "\n" " --verbose: Prints out information during processing.\n" "\n" - " --help: Displays this information.\n\n", + " --help: Displays this information.\n", qPrintable(QCoreApplication::arguments().at(0)) ); } @@ -275,8 +321,8 @@ static QString packageNameFromAndroidManifest(const QString &androidManifestPath QXmlStreamReader reader(&androidManifestXml); while (!reader.atEnd()) { reader.readNext(); - if (reader.isStartElement() && reader.name() == QStringLiteral("manifest")) - return reader.attributes().value(QStringLiteral("package")).toString(); + if (reader.isStartElement() && reader.name() == "manifest"_L1) + return reader.attributes().value("package"_L1).toString(); } } return {}; @@ -289,8 +335,8 @@ static QString activityFromAndroidManifest(const QString &androidManifestPath) QXmlStreamReader reader(&androidManifestXml); while (!reader.atEnd()) { reader.readNext(); - if (reader.isStartElement() && reader.name() == QStringLiteral("activity")) - return reader.attributes().value(QStringLiteral("android:name")).toString(); + if (reader.isStartElement() && reader.name() == "activity"_L1) + return reader.attributes().value("android:name"_L1).toString(); } } return {}; @@ -299,26 +345,26 @@ static QString activityFromAndroidManifest(const QString &androidManifestPath) static void setOutputFile(QString file, QString format) { if (file.isEmpty()) - file = QStringLiteral("-"); + file = "-"_L1; if (format.isEmpty()) - format = QStringLiteral("txt"); + format = "txt"_L1; g_options.outFiles[format] = file; } static bool parseTestArgs() { - QRegularExpression oldFormats{QStringLiteral("^-(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$")}; - QRegularExpression newLoggingFormat{QStringLiteral("^(.*),(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$")}; + QRegularExpression oldFormats{"^-(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1}; + QRegularExpression newLoggingFormat{"^(.*),(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1}; QString file; QString logType; QStringList unhandledArgs; for (int i = 0; i < g_options.testArgsList.size(); ++i) { const QString &arg = g_options.testArgsList[i].trimmed(); - if (arg == QStringLiteral("--")) + if (arg == "--"_L1) continue; - if (arg == QStringLiteral("-o")) { + if (arg == "-o"_L1) { if (i >= g_options.testArgsList.size() - 1) return false; // missing file argument @@ -335,129 +381,157 @@ static bool parseTestArgs() if (match.hasMatch()) { logType = match.capturedTexts().at(1); } else { - unhandledArgs << QStringLiteral(" \\\"%1\\\"").arg(arg); + // Use triple literal quotes so that QProcess::splitCommand() in androidjnimain.cpp + // keeps quotes characters inside the string. + QString quotedArg = QString(arg).replace("\""_L1, "\\\"\\\"\\\""_L1); + // Escape single quotes so they don't interfere with the shell command, + // and so they get passed to the app as single quote inside the string. + quotedArg.replace("'"_L1, "\'"_L1); + // Add escaped double quote character so that args with spaces are treated as one. + unhandledArgs << " \\\"%1\\\""_L1.arg(quotedArg); } } } if (g_options.outFiles.isEmpty() || !file.isEmpty() || !logType.isEmpty()) setOutputFile(file, logType); + QString testAppArgs; for (const auto &format : g_options.outFiles.keys()) - g_options.testArgs += QStringLiteral(" -o output.%1,%1").arg(format); + testAppArgs += "-o output.%1,%1 "_L1.arg(format); + + testAppArgs += unhandledArgs.join(u' ').trimmed(); + testAppArgs = "\"%1\""_L1.arg(testAppArgs.trimmed()); + const QString activityName = "%1/%2"_L1.arg(g_options.package).arg(g_options.activity); + + // Pass over any testlib env vars if set + QString testEnvVars; + const QStringList envVarsList = QProcessEnvironment::systemEnvironment().toStringList(); + for (const QString &var : envVarsList) { + if (var.startsWith("QTEST_"_L1)) + testEnvVars += "%1 "_L1.arg(var); + } - g_options.testArgs += unhandledArgs.join(u' '); + if (!testEnvVars.isEmpty()) { + testEnvVars = QString::fromUtf8(testEnvVars.trimmed().toUtf8().toBase64()); + testEnvVars = "-e extraenvvars \"%4\""_L1.arg(testEnvVars); + } - g_options.testArgs = QStringLiteral("shell am start -e applicationArguments \"%1\" -n %2/%3") - .arg(shellQuote(g_options.testArgs.trimmed())) - .arg(g_options.package) - .arg(g_options.activity); + g_options.amStarttestArgs = { "shell"_L1, "am"_L1, "start"_L1, + "-n"_L1, activityName, + "-e"_L1, "applicationArguments"_L1, testAppArgs, + testEnvVars + }; return true; } -static bool isRunning() { +static bool obtainPid() { QByteArray output; - if (!execCommand(QStringLiteral("%1 shell \"ps | grep ' %2'\"").arg(g_options.adbCommand, - shellQuote(g_options.package)), &output)) { + const QStringList psArgs = { "shell"_L1, "ps | grep ' %1'"_L1.arg(g_options.package) }; + if (!execAdbCommand(psArgs, &output, false)) + return false; + + const QList<QByteArray> lines = output.split(u'\n'); + if (lines.size() < 1) + return false; + QList<QByteArray> columns = lines.first().simplified().replace(u'\t', u' ').split(u' '); + if (columns.size() < 3) return false; + + if (g_testInfo.pid == -1) { + bool ok = false; + int pid = columns.at(1).toInt(&ok); + if (ok) + g_testInfo.pid = pid; } + + return true; +} + +static bool isRunning() { + QByteArray output; + const QStringList psArgs = { "shell"_L1, "ps | grep ' %1'"_L1.arg(g_options.package) }; + if (!execAdbCommand(psArgs, &output, false)) + return false; + return output.indexOf(QLatin1StringView(" " + g_options.package.toUtf8())) > -1; } -static bool waitToFinish() +static void waitForStartedAndFinished() { - using clock = std::chrono::system_clock; - auto start = clock::now(); - // wait to start - while (!isRunning()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - if ((clock::now() - start) > std::chrono::seconds{10}) - return false; - } - - if (g_options.sdkVersion > 23) { // pidof is broken in SDK 23, non-existent before - QByteArray output; - const QString command(QStringLiteral("%1 shell pidof -s %2") - .arg(g_options.adbCommand, shellQuote(g_options.package))); - execCommand(command, &output, g_options.verbose); - bool ok = false; - int pid = output.toInt(&ok); // If we got more than one pid, fail. - if (ok) { - g_options.pid = pid; - } else { - fprintf(stderr, - "Unable to obtain the PID of the running unit test. Command \"%s\" " - "returned \"%s\"\n", - command.toUtf8().constData(), output.constData()); - fflush(stderr); - } - } + // wait to start and set PID + QDeadlineTimer startDeadline(10000); + do { + if (obtainPid()) + break; + QThread::msleep(100); + } while (!startDeadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load()); // Wait to finish - while (isRunning()) { - std::this_thread::sleep_for(std::chrono::milliseconds(250)); - if (g_options.timeout >= std::chrono::seconds::zero() - && (clock::now() - start) > g_options.timeout) - return false; - } - return true; + QDeadlineTimer finishedDeadline(g_options.timeoutSecs * 1000); + do { + if (!isRunning()) + break; + QThread::msleep(250); + } while (!finishedDeadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load()); + + if (finishedDeadline.hasExpired()) + qWarning() << "Timed out while waiting for the test to finish"; } -static void obtainSDKVersion() +static void obtainSdkVersion() { // SDK version is necessary, as in SDK 23 pidof is broken, so we cannot obtain the pid. // Also, Logcat cannot filter by pid in SDK 23, so we don't offer the --show-logcat option. QByteArray output; - const QString command( - QStringLiteral("%1 shell getprop ro.build.version.sdk").arg(g_options.adbCommand)); - execCommand(command, &output, g_options.verbose); + const QStringList versionArgs = { "shell"_L1, "getprop"_L1, "ro.build.version.sdk"_L1 }; + execAdbCommand(versionArgs, &output, false); bool ok = false; int sdkVersion = output.toInt(&ok); - if (ok) { - g_options.sdkVersion = sdkVersion; - } else { - fprintf(stderr, - "Unable to obtain the SDK version of the target. Command \"%s\" " - "returned \"%s\"\n", - command.toUtf8().constData(), output.constData()); - fflush(stderr); - } + if (ok) + g_testInfo.sdkVersion = sdkVersion; + else + qCritical() << "Unable to obtain the SDK version of the target."; } static bool pullFiles() { bool ret = true; + QByteArray userId; + // adb get-current-user command is available starting from API level 26. + if (g_testInfo.sdkVersion >= 26) { + const QStringList userIdArgs = {"shell"_L1, "cmd"_L1, "activity"_L1, "get-current-user"_L1}; + if (!execAdbCommand(userIdArgs, &userId, false)) { + qCritical() << "Error: failed to retrieve the user ID"; + return false; + } + } else { + userId = "0"; + } + for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.end(); ++it) { // Get only stdout from cat and get rid of stderr and fail later if the output is empty - const QString catCmd = QStringLiteral("cat files/output.%1 2> /dev/null").arg(it.key()); + const QString outSuffix = it.key(); + const QString catCmd = "cat files/output.%1 2> /dev/null"_L1.arg(outSuffix); + const QStringList fullCatArgs = { "shell"_L1, "run-as %1 --user %2 %3"_L1.arg( + g_options.package, QString::fromUtf8(userId.simplified()), catCmd) }; QByteArray output; - if (!execCommand(QStringLiteral("%1 shell 'run-as %2 %3'") - .arg(g_options.adbCommand, g_options.package, catCmd), &output)) { - // Cannot find output file. Check in path related to current user - QByteArray userId; - execCommand(QStringLiteral("%1 shell cmd activity get-current-user") - .arg(g_options.adbCommand), &userId); - const QString userIdSimplified(QString::fromUtf8(userId).simplified()); - if (!execCommand(QStringLiteral("%1 shell 'run-as %2 --user %3 %4'") - .arg(g_options.adbCommand, g_options.package, userIdSimplified, catCmd), - &output)) { - return false; - } + if (!execAdbCommand(fullCatArgs, &output, false)) { + qCritical() << "Error: failed to retrieve the test's output.%1 file."_L1.arg(outSuffix); + return false; } if (output.isEmpty()) { - fprintf(stderr, "Failed to get the test output from the target. Either the output " - "is empty or androidtestrunner failed to retrieve it.\n"); + qCritical() << "Error: the test's output.%1 is empty."_L1.arg(outSuffix); return false; } - auto checkerIt = g_options.checkFiles.find(it.key()); - ret = ret && checkerIt != g_options.checkFiles.end() && checkerIt.value()(output); - if (it.value() == QStringLiteral("-")){ - fprintf(stdout, "%s", output.constData()); - fflush(stdout); + auto checkerIt = g_options.checkFiles.find(outSuffix); + ret &= (checkerIt != g_options.checkFiles.end() && checkerIt.value()(output)); + if (it.value() == "-"_L1) { + qDebug() << output.constData(); } else { QFile out{it.value()}; if (!out.open(QIODevice::WriteOnly)) @@ -468,22 +542,171 @@ static bool pullFiles() return ret; } -struct RunnerLocker +void printLogcat(const QString &formattedTime) { - RunnerLocker() - { - runner.acquire(); + QStringList logcatArgs = { "logcat"_L1 }; + if (g_testInfo.sdkVersion <= 23 || g_testInfo.pid == -1) + logcatArgs << "-t"_L1 << formattedTime; + else + logcatArgs << "-d"_L1 << "--pid=%1"_L1.arg(QString::number(g_testInfo.pid)); + + QByteArray logcat; + if (!execAdbCommand(logcatArgs, &logcat, false)) { + qCritical() << "Error: failed to fetch logcat of the test"; + return; } - ~RunnerLocker() + + if (logcat.isEmpty()) { + qWarning() << "The retrieved logcat is empty"; + return; + } + + qDebug() << "****** Begin logcat output ******"; + qDebug().noquote() << logcat; + qDebug() << "****** End logcat output ******"; +} + +static QString getDeviceABI() +{ + const QStringList abiArgs = { "shell"_L1, "getprop"_L1, "ro.product.cpu.abi"_L1 }; + QByteArray abi; + if (!execAdbCommand(abiArgs, &abi, false)) { + qWarning() << "Warning: failed to get the device abi, fallback to first libs dir"; + return {}; + } + + return QString::fromUtf8(abi.simplified()); +} + +void printLogcatCrashBuffer(const QString &formattedTime) +{ + bool useNdkStack = false; + auto libsPath = "%1/libs/"_L1.arg(g_options.buildPath); + + if (!g_options.ndkStackPath.isEmpty()) { + QString abi = getDeviceABI(); + if (abi.isEmpty()) { + QStringList subDirs = QDir(libsPath).entryList(QDir::Dirs | QDir::NoDotAndDotDot); + if (!subDirs.isEmpty()) + abi = subDirs.first(); + } + + if (!abi.isEmpty()) { + libsPath += abi; + useNdkStack = true; + } else { + qWarning() << "Warning: failed to get the libs abi, ndk-stack cannot be used."; + } + } else { + qWarning() << "Warning: ndk-stack path not provided and couldn't be deduced " + "using the ANDROID_NDK_ROOT environment variable."; + } + + QProcess adbCrashProcess; + QProcess ndkStackProcess; + + if (useNdkStack) { + adbCrashProcess.setStandardOutputProcess(&ndkStackProcess); + ndkStackProcess.start(g_options.ndkStackPath, { "-sym"_L1, libsPath }); + } + + const QStringList adbCrashArgs = { "logcat"_L1, "-b"_L1, "crash"_L1, "-t"_L1, formattedTime }; + adbCrashProcess.start(g_options.adbCommand, adbCrashArgs); + + if (!adbCrashProcess.waitForStarted()) { + qCritical() << "Error: failed to run adb logcat crash command."; + return; + } + + if (useNdkStack && !ndkStackProcess.waitForStarted()) { + qCritical() << "Error: failed to run ndk-stack command."; + return; + } + + if (!adbCrashProcess.waitForFinished()) { + qCritical() << "Error: adb command timed out."; + return; + } + + if (useNdkStack && !ndkStackProcess.waitForFinished()) { + qCritical() << "Error: ndk-stack command timed out."; + return; + } + + const QByteArray crash = useNdkStack ? ndkStackProcess.readAllStandardOutput() + : adbCrashProcess.readAllStandardOutput(); + if (crash.isEmpty()) { + qWarning() << "The retrieved crash logcat is empty"; + return; + } + + qDebug() << "****** Begin logcat crash buffer output ******"; + qDebug().noquote() << crash; + qDebug() << "****** End logcat crash buffer output ******"; +} + +static QString getCurrentTimeString() +{ + const QString timeFormat = (g_testInfo.sdkVersion <= 23) ? + "%m-%d %H:%M:%S.000"_L1 : "%Y-%m-%d %H:%M:%S.%3N"_L1; + + QStringList dateArgs = { "shell"_L1, "date"_L1, "+'%1'"_L1.arg(timeFormat) }; + QByteArray output; + if (!execAdbCommand(dateArgs, &output, false)) { + qWarning() << "Date/time adb command failed"; + return {}; + } + + return QString::fromUtf8(output.simplified()); +} + +static bool uninstallTestPackage() +{ + return execAdbCommand({ "uninstall"_L1, g_options.package }, nullptr); +} + +struct TestRunnerSystemSemaphore +{ + TestRunnerSystemSemaphore() { } + ~TestRunnerSystemSemaphore() { release(); } + + void acquire() { isAcquired.store(semaphore.acquire()); } + + void release() { - runner.release(); + bool expected = true; + // NOTE: There's still could be tiny time gap between the compare_exchange_strong() call + // and release() call where the thread could be interrupted, if that's ever an issue, + // this code could be checked and improved further. + if (isAcquired.compare_exchange_strong(expected, false)) + isAcquired.store(!semaphore.release()); } - QSystemSemaphore runner{ QSystemSemaphore::platformSafeKey(u"androidtestrunner"_s), - 1, QSystemSemaphore::Open }; + + std::atomic<bool> isAcquired { false }; + QSystemSemaphore semaphore { QSystemSemaphore::platformSafeKey(u"androidtestrunner"_s), + 1, QSystemSemaphore::Open }; }; +TestRunnerSystemSemaphore testRunnerLock; + +void sigHandler(int signal) +{ + std::signal(signal, SIG_DFL); + testRunnerLock.release(); + // Ideally we shouldn't be doing such calls from a signal handler, + // and we can't use QSocketNotifier because this tool doesn't spin + // a main event loop. Since, there's no other alternative to do this, + // let's do the cleanup anyway. + if (!g_testInfo.isPackageInstalled.load()) + _exit(-1); + g_testInfo.isTestRunnerInterrupted.store(true); +} + int main(int argc, char *argv[]) { + std::signal(SIGINT, sigHandler); + std::signal(SIGTERM, sigHandler); + QCoreApplication a(argc, argv); if (!parseOptions()) { printHelp(); @@ -491,43 +714,33 @@ int main(int argc, char *argv[]) } if (g_options.makeCommand.isEmpty()) { - fprintf(stderr, - "It is required to provide a make command with the \"--make\" parameter " - "to generate the apk.\n"); + qCritical() << "It is required to provide a make command with the \"--make\" parameter " + "to generate the apk."; return 1; } if (!execCommand(g_options.makeCommand, nullptr, true)) { if (!g_options.skipAddInstallRoot) { // we need to run make INSTALL_ROOT=path install to install the application file(s) first - if (!execCommand(QStringLiteral("%1 INSTALL_ROOT=%2 install") - .arg(g_options.makeCommand, QDir::toNativeSeparators(g_options.buildPath)), nullptr, g_options.verbose)) { + if (!execCommand("%1 INSTALL_ROOT=%2 install"_L1.arg(g_options.makeCommand, + QDir::toNativeSeparators(g_options.buildPath)), nullptr)) { return 1; } } else { - if (!execCommand(QStringLiteral("%1") - .arg(g_options.makeCommand), nullptr, g_options.verbose)) { + if (!execCommand(g_options.makeCommand, nullptr)) return 1; - } } } if (!QFile::exists(g_options.apkPath)) { - fprintf(stderr, - "No apk \"%s\" found after running the make command. Check the provided path and " - "the make command.\n", - qPrintable(g_options.apkPath)); + qCritical("No apk \"%s\" found after running the make command. " + "Check the provided path and the make command.", + qPrintable(g_options.apkPath)); return 1; } - obtainSDKVersion(); + obtainSdkVersion(); - RunnerLocker lock; // do not install or run packages while another test is running - if (!execCommand(QStringLiteral("%1 install -r -g %2") - .arg(g_options.adbCommand, g_options.apkPath), nullptr, g_options.verbose)) { - return 1; - } - - QString manifest = g_options.buildPath + QStringLiteral("/AndroidManifest.xml"); + QString manifest = g_options.buildPath + "/AndroidManifest.xml"_L1; g_options.package = packageNameFromAndroidManifest(manifest); if (g_options.activity.isEmpty()) g_options.activity = activityFromAndroidManifest(manifest); @@ -536,30 +749,42 @@ int main(int argc, char *argv[]) if (!parseTestArgs()) return 1; + // do not install or run packages while another test is running + testRunnerLock.acquire(); + + const QStringList installArgs = { "install"_L1, "-r"_L1, "-g"_L1, g_options.apkPath }; + g_testInfo.isPackageInstalled.store(execAdbCommand(installArgs, nullptr)); + if (!g_testInfo.isPackageInstalled) + return 1; + + const QString formattedTime = getCurrentTimeString(); + // start the tests - bool res = execCommand(QStringLiteral("%1 %2").arg(g_options.adbCommand, g_options.testArgs), - nullptr, g_options.verbose) - && waitToFinish(); - - // get logcat output - if (res && g_options.showLogcatOutput) { - if (g_options.sdkVersion <= 23) { - fprintf(stderr, "Cannot show logcat output on Android 23 and below.\n"); - fflush(stderr); - } else if (g_options.pid > 0) { - fprintf(stdout, "Logcat output:\n"); - res &= execCommand(QStringLiteral("%1 logcat -d --pid=%2") - .arg(g_options.adbCommand) - .arg(g_options.pid), - nullptr, true); - fprintf(stdout, "End Logcat output.\n"); - } + bool success = execAdbCommand(g_options.amStarttestArgs, nullptr); + + waitForStartedAndFinished(); + + if (success) { + success &= pullFiles(); + if (g_options.showLogcatOutput) + printLogcat(formattedTime); + } + + // If we have a failure, attempt to print both logcat and the crash buffer which + // includes the crash stacktrace that is not included in the default logcat. + if (!success) { + printLogcat(formattedTime); + printLogcatCrashBuffer(formattedTime); + } + + success &= uninstallTestPackage(); + + testRunnerLock.release(); + + if (g_testInfo.isTestRunnerInterrupted.load()) { + qCritical() << "The androidtestrunner was interrupted and the was test cleaned up."; + return 1; } - if (res) - res &= pullFiles(); - res &= execCommand(QStringLiteral("%1 uninstall %2").arg(g_options.adbCommand, g_options.package), - nullptr, g_options.verbose); - fflush(stdout); - return res ? 0 : 1; + return success ? 0 : 1; } |