1 | // Copyright (C) 2019 BogDan Vatra <bogdan@kde.org> |
---|---|
2 | // Copyright (C) 2023 The Qt Company Ltd. |
3 | // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 |
4 | |
5 | #include <QtCore/QCoreApplication> |
6 | #include <QtCore/QDeadlineTimer> |
7 | #include <QtCore/QDir> |
8 | #include <QtCore/QHash> |
9 | #include <QtCore/QProcess> |
10 | #include <QtCore/QProcessEnvironment> |
11 | #include <QtCore/QRegularExpression> |
12 | #include <QtCore/QSystemSemaphore> |
13 | #include <QtCore/QThread> |
14 | #include <QtCore/QXmlStreamReader> |
15 | #include <QtCore/QFileInfo> |
16 | #include <QtCore/QSysInfo> |
17 | |
18 | #include <atomic> |
19 | #include <csignal> |
20 | #include <functional> |
21 | #include <optional> |
22 | #if defined(Q_OS_WIN32) |
23 | #include <process.h> |
24 | #else |
25 | #include <unistd.h> |
26 | #endif |
27 | |
28 | using namespace Qt::StringLiterals; |
29 | |
30 | #define EXIT_ERROR -1 |
31 | |
32 | struct Options |
33 | { |
34 | bool helpRequested = false; |
35 | bool verbose = false; |
36 | bool skipAddInstallRoot = false; |
37 | int timeoutSecs = 600; // 10 minutes |
38 | int resultsPullRetries = 3; |
39 | QString buildPath; |
40 | QString adbCommand{"adb"_L1}; |
41 | QString serial; |
42 | QString makeCommand; |
43 | QString package; |
44 | QString activity; |
45 | QStringList testArgsList; |
46 | QString stdoutFileName; |
47 | QHash<QString, QString> outFiles; |
48 | QStringList amStarttestArgs; |
49 | QString apkPath; |
50 | QString ndkStackPath; |
51 | bool showLogcatOutput = false; |
52 | std::optional<QProcess> stdoutLogger; |
53 | }; |
54 | |
55 | static Options g_options; |
56 | |
57 | struct TestInfo |
58 | { |
59 | int sdkVersion = -1; |
60 | int pid = -1; |
61 | QString userId; |
62 | |
63 | std::atomic<bool> isPackageInstalled { false }; |
64 | std::atomic<bool> isTestRunnerInterrupted { false }; |
65 | }; |
66 | |
67 | static TestInfo g_testInfo; |
68 | |
69 | static bool execCommand(const QString &program, const QStringList &args, |
70 | QByteArray *output = nullptr, bool verbose = false) |
71 | { |
72 | const auto command = program + " "_L1+ args.join(sep: u' '); |
73 | |
74 | if (verbose && g_options.verbose) |
75 | fprintf(stdout,format: "Execute %s.\n", command.toUtf8().constData()); |
76 | |
77 | QProcess process; |
78 | process.start(program, arguments: args); |
79 | if (!process.waitForStarted()) { |
80 | qCritical(msg: "Cannot execute command %s.", qPrintable(command)); |
81 | return false; |
82 | } |
83 | |
84 | // If the command is not adb, for example, make or ninja, it can take more that |
85 | // QProcess::waitForFinished() 30 secs, so for that use a higher timeout. |
86 | const int FinishTimeout = program.endsWith(s: "adb"_L1) ? 30000 : g_options.timeoutSecs * 1000; |
87 | if (!process.waitForFinished(msecs: FinishTimeout)) { |
88 | qCritical(msg: "Execution of command %s timed out.", qPrintable(command)); |
89 | return false; |
90 | } |
91 | |
92 | const auto stdOut = process.readAllStandardOutput(); |
93 | if (output) |
94 | output->append(a: stdOut); |
95 | |
96 | if (verbose && g_options.verbose) |
97 | fprintf(stdout, format: "%s\n", stdOut.constData()); |
98 | |
99 | return process.exitCode() == 0; |
100 | } |
101 | |
102 | static bool execAdbCommand(const QStringList &args, QByteArray *output = nullptr, |
103 | bool verbose = true) |
104 | { |
105 | if (g_options.serial.isEmpty()) |
106 | return execCommand(program: g_options.adbCommand, args, output, verbose); |
107 | |
108 | QStringList argsWithSerial = {"-s"_L1, g_options.serial}; |
109 | argsWithSerial.append(l: args); |
110 | |
111 | return execCommand(program: g_options.adbCommand, args: argsWithSerial, output, verbose); |
112 | } |
113 | |
114 | static bool execCommand(const QString &command, QByteArray *output = nullptr, bool verbose = true) |
115 | { |
116 | auto args = QProcess::splitCommand(command); |
117 | const auto program = args.first(); |
118 | args.removeOne(t: program); |
119 | return execCommand(program, args, output, verbose); |
120 | } |
121 | |
122 | static bool parseOptions() |
123 | { |
124 | QStringList arguments = QCoreApplication::arguments(); |
125 | int i = 1; |
126 | for (; i < arguments.size(); ++i) { |
127 | const QString &argument = arguments.at(i); |
128 | if (argument.compare(other: "--adb"_L1, cs: Qt::CaseInsensitive) == 0) { |
129 | if (i + 1 == arguments.size()) |
130 | g_options.helpRequested = true; |
131 | else |
132 | g_options.adbCommand = arguments.at(i: ++i); |
133 | } else if (argument.compare(other: "--path"_L1, cs: Qt::CaseInsensitive) == 0) { |
134 | if (i + 1 == arguments.size()) |
135 | g_options.helpRequested = true; |
136 | else |
137 | g_options.buildPath = arguments.at(i: ++i); |
138 | } else if (argument.compare(other: "--make"_L1, cs: Qt::CaseInsensitive) == 0) { |
139 | if (i + 1 == arguments.size()) |
140 | g_options.helpRequested = true; |
141 | else |
142 | g_options.makeCommand = arguments.at(i: ++i); |
143 | } else if (argument.compare(other: "--apk"_L1, cs: Qt::CaseInsensitive) == 0) { |
144 | if (i + 1 == arguments.size()) |
145 | g_options.helpRequested = true; |
146 | else |
147 | g_options.apkPath = arguments.at(i: ++i); |
148 | } else if (argument.compare(other: "--activity"_L1, cs: Qt::CaseInsensitive) == 0) { |
149 | if (i + 1 == arguments.size()) |
150 | g_options.helpRequested = true; |
151 | else |
152 | g_options.activity = arguments.at(i: ++i); |
153 | } else if (argument.compare(other: "--skip-install-root"_L1, cs: Qt::CaseInsensitive) == 0) { |
154 | g_options.skipAddInstallRoot = true; |
155 | } else if (argument.compare(other: "--show-logcat"_L1, cs: Qt::CaseInsensitive) == 0) { |
156 | g_options.showLogcatOutput = true; |
157 | } else if (argument.compare(other: "--ndk-stack"_L1, cs: Qt::CaseInsensitive) == 0) { |
158 | if (i + 1 == arguments.size()) |
159 | g_options.helpRequested = true; |
160 | else |
161 | g_options.ndkStackPath = arguments.at(i: ++i); |
162 | } else if (argument.compare(other: "--timeout"_L1, cs: Qt::CaseInsensitive) == 0) { |
163 | if (i + 1 == arguments.size()) |
164 | g_options.helpRequested = true; |
165 | else |
166 | g_options.timeoutSecs = arguments.at(i: ++i).toInt(); |
167 | } else if (argument.compare(other: "--help"_L1, cs: Qt::CaseInsensitive) == 0) { |
168 | g_options.helpRequested = true; |
169 | } else if (argument.compare(other: "--verbose"_L1, cs: Qt::CaseInsensitive) == 0) { |
170 | g_options.verbose = true; |
171 | } else if (argument.compare(other: "--"_L1, cs: Qt::CaseInsensitive) == 0) { |
172 | ++i; |
173 | break; |
174 | } else { |
175 | g_options.testArgsList << arguments.at(i); |
176 | } |
177 | } |
178 | for (;i < arguments.size(); ++i) |
179 | g_options.testArgsList << arguments.at(i); |
180 | |
181 | if (g_options.helpRequested || g_options.buildPath.isEmpty() || g_options.apkPath.isEmpty()) |
182 | return false; |
183 | |
184 | g_options.serial = qEnvironmentVariable(varName: "ANDROID_SERIAL"); |
185 | if (g_options.serial.isEmpty()) |
186 | g_options.serial = qEnvironmentVariable(varName: "ANDROID_DEVICE_SERIAL"); |
187 | |
188 | if (g_options.ndkStackPath.isEmpty()) { |
189 | const QString ndkPath = qEnvironmentVariable(varName: "ANDROID_NDK_ROOT"); |
190 | const QString ndkStackPath = ndkPath + QDir::separator() + "ndk-stack"_L1; |
191 | if (QFile::exists(fileName: ndkStackPath)) |
192 | g_options.ndkStackPath = ndkStackPath; |
193 | } |
194 | |
195 | return true; |
196 | } |
197 | |
198 | static void printHelp() |
199 | { |
200 | qWarning( msg: "Syntax: %s <options> -- [TESTARGS] \n" |
201 | "\n" |
202 | " Runs a Qt for Android test on an emulator or a device. Specify a device\n" |
203 | " using the environment variables ANDROID_SERIAL or ANDROID_DEVICE_SERIAL.\n" |
204 | " Returns the number of failed tests, -1 on test runner deployment related\n" |
205 | " failures or zero on success." |
206 | "\n" |
207 | " Mandatory arguments:\n" |
208 | " --path <path>: The path where androiddeployqt builds the android package.\n" |
209 | "\n" |
210 | " --make <make cmd>: make command to create an APK, for example:\n" |
211 | " \"cmake --build <build-dir> --target <target>_make_apk\".\n" |
212 | "\n" |
213 | " --apk <apk path>: The test apk path. The apk has to exist already, if it\n" |
214 | " does not exist the make command must be provided for building the apk.\n" |
215 | "\n" |
216 | " Optional arguments:\n" |
217 | " --adb <adb cmd>: The Android ADB command. If missing the one from\n" |
218 | " $PATH will be used.\n" |
219 | "\n" |
220 | " --activity <acitvity>: The Activity to run. If missing the first\n" |
221 | " activity from AndroidManifest.qml file will be used.\n" |
222 | "\n" |
223 | " --timeout <seconds>: Timeout to run the test. Default is 10 minutes.\n" |
224 | "\n" |
225 | " --skip-install-root: Do not append INSTALL_ROOT=... to the make command.\n" |
226 | "\n" |
227 | " --show-logcat: Print Logcat output to stdout. If an ANR occurs during\n" |
228 | " the test run, logs from the system_server process are included.\n" |
229 | " This argument is implied if a test fails.\n" |
230 | "\n" |
231 | " --ndk-stack: Path to ndk-stack tool that symbolizes crash stacktraces.\n" |
232 | " By default, ANDROID_NDK_ROOT env var is used to deduce the tool path.\n" |
233 | "\n" |
234 | " -- Arguments that will be passed to the test application.\n" |
235 | "\n" |
236 | " --verbose: Prints out information during processing.\n" |
237 | "\n" |
238 | " --help: Displays this information.\n", |
239 | qPrintable(QCoreApplication::arguments().at(0)) |
240 | ); |
241 | } |
242 | |
243 | static QString packageNameFromAndroidManifest(const QString &androidManifestPath) |
244 | { |
245 | QFile androidManifestXml(androidManifestPath); |
246 | if (androidManifestXml.open(flags: QIODevice::ReadOnly)) { |
247 | QXmlStreamReader reader(&androidManifestXml); |
248 | while (!reader.atEnd()) { |
249 | reader.readNext(); |
250 | if (reader.isStartElement() && reader.name() == "manifest"_L1) |
251 | return reader.attributes().value(qualifiedName: "package"_L1).toString(); |
252 | } |
253 | } |
254 | return {}; |
255 | } |
256 | |
257 | static QString activityFromAndroidManifest(const QString &androidManifestPath) |
258 | { |
259 | QFile androidManifestXml(androidManifestPath); |
260 | if (androidManifestXml.open(flags: QIODevice::ReadOnly)) { |
261 | QXmlStreamReader reader(&androidManifestXml); |
262 | while (!reader.atEnd()) { |
263 | reader.readNext(); |
264 | if (reader.isStartElement() && reader.name() == "activity"_L1) |
265 | return reader.attributes().value(qualifiedName: "android:name"_L1).toString(); |
266 | } |
267 | } |
268 | return {}; |
269 | } |
270 | |
271 | static void setOutputFile(QString file, QString format) |
272 | { |
273 | if (format.isEmpty()) |
274 | format = "txt"_L1; |
275 | |
276 | if ((file.isEmpty() || file == u'-')) { |
277 | if (g_options.outFiles.contains(key: format)) { |
278 | file = g_options.outFiles.value(key: format); |
279 | } else { |
280 | file = "stdout.%1"_L1.arg(args&: format); |
281 | g_options.outFiles[format] = file; |
282 | } |
283 | g_options.stdoutFileName = QFileInfo(file).fileName(); |
284 | } else { |
285 | g_options.outFiles[format] = file; |
286 | } |
287 | } |
288 | |
289 | static bool parseTestArgs() |
290 | { |
291 | QRegularExpression oldFormats{"^-(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1}; |
292 | QRegularExpression newLoggingFormat{"^(.*),(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1}; |
293 | |
294 | QString file; |
295 | QString logType; |
296 | QStringList unhandledArgs; |
297 | for (int i = 0; i < g_options.testArgsList.size(); ++i) { |
298 | const QString &arg = g_options.testArgsList[i].trimmed(); |
299 | if (arg == "--"_L1) |
300 | continue; |
301 | if (arg == "-o"_L1) { |
302 | if (i >= g_options.testArgsList.size() - 1) |
303 | return false; // missing file argument |
304 | |
305 | const auto &filePath = g_options.testArgsList[++i]; |
306 | const auto match = newLoggingFormat.match(subject: filePath); |
307 | if (!match.hasMatch()) { |
308 | file = filePath; |
309 | } else { |
310 | const auto capturedTexts = match.capturedTexts(); |
311 | setOutputFile(file: capturedTexts.at(i: 1), format: capturedTexts.at(i: 2)); |
312 | } |
313 | } else { |
314 | auto match = oldFormats.match(subject: arg); |
315 | if (match.hasMatch()) { |
316 | logType = match.capturedTexts().at(i: 1); |
317 | } else { |
318 | // Use triple literal quotes so that QProcess::splitCommand() in androidjnimain.cpp |
319 | // keeps quotes characters inside the string. |
320 | QString quotedArg = QString(arg).replace(before: "\""_L1, after: "\\\"\\\"\\\""_L1); |
321 | // Escape single quotes so they don't interfere with the shell command, |
322 | // and so they get passed to the app as single quote inside the string. |
323 | quotedArg.replace(before: "'"_L1, after: "\'"_L1); |
324 | // Add escaped double quote character so that args with spaces are treated as one. |
325 | unhandledArgs << " \\\"%1\\\""_L1.arg(args&: quotedArg); |
326 | } |
327 | } |
328 | } |
329 | if (g_options.outFiles.isEmpty() || !file.isEmpty() || !logType.isEmpty()) |
330 | setOutputFile(file, format: logType); |
331 | |
332 | QString testAppArgs; |
333 | for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.constEnd(); ++it) |
334 | testAppArgs += "-o %1,%2 "_L1.arg(args: QFileInfo(it.value()).fileName(), args: it.key()); |
335 | |
336 | testAppArgs += unhandledArgs.join(sep: u' ').trimmed(); |
337 | testAppArgs = "\"%1\""_L1.arg(args: testAppArgs.trimmed()); |
338 | const QString activityName = "%1/%2"_L1.arg(args&: g_options.package).arg(a: g_options.activity); |
339 | |
340 | // Pass over any qt or testlib env vars if set |
341 | QString testEnvVars; |
342 | const QStringList envVarsList = QProcessEnvironment::systemEnvironment().toStringList(); |
343 | for (const QString &var : envVarsList) { |
344 | if (var.startsWith(s: "QTEST_"_L1) || var.startsWith(s: "QT_"_L1)) |
345 | testEnvVars += "%1 "_L1.arg(args: var); |
346 | } |
347 | |
348 | if (!testEnvVars.isEmpty()) { |
349 | testEnvVars = QString::fromUtf8(ba: testEnvVars.trimmed().toUtf8().toBase64()); |
350 | testEnvVars = "-e extraenvvars \"%4\""_L1.arg(args&: testEnvVars); |
351 | } |
352 | |
353 | g_options.amStarttestArgs = { "shell"_L1, "am"_L1, "start"_L1, |
354 | "-n"_L1, activityName, |
355 | "-e"_L1, "applicationArguments"_L1, testAppArgs, |
356 | testEnvVars |
357 | }; |
358 | |
359 | return true; |
360 | } |
361 | |
362 | static int getPid(const QString &package) |
363 | { |
364 | QByteArray output; |
365 | const QStringList psArgs = { "shell"_L1, "ps | grep ' %1'"_L1.arg(args: package) }; |
366 | if (!execAdbCommand(args: psArgs, output: &output, verbose: false)) |
367 | return false; |
368 | |
369 | const QList<QByteArray> lines = output.split(sep: u'\n'); |
370 | if (lines.size() < 1) |
371 | return false; |
372 | |
373 | QList<QByteArray> columns = lines.first().simplified().replace(before: u'\t', after: u' ').split(sep: u' '); |
374 | if (columns.size() < 3) |
375 | return false; |
376 | |
377 | bool ok = false; |
378 | int pid = columns.at(i: 1).toInt(ok: &ok); |
379 | if (ok) |
380 | return pid; |
381 | |
382 | return -1; |
383 | } |
384 | |
385 | static QString runCommandAsUserArgs(const QString &cmd) |
386 | { |
387 | return "run-as %1 --user %2 %3"_L1.arg(args&: g_options.package, args&: g_testInfo.userId, args: cmd); |
388 | } |
389 | |
390 | static bool isRunning() { |
391 | if (g_testInfo.pid < 1) |
392 | return false; |
393 | |
394 | QByteArray output; |
395 | const QStringList psArgs = { "shell"_L1, "ps"_L1, "-p"_L1, QString::number(g_testInfo.pid), |
396 | "|"_L1, "grep"_L1, "-o"_L1, " %1$"_L1.arg(args&: g_options.package) }; |
397 | bool psSuccess = false; |
398 | for (int i = 1; i <= 3; ++i) { |
399 | psSuccess = execAdbCommand(args: psArgs, output: &output, verbose: false); |
400 | if (psSuccess) |
401 | break; |
402 | QThread::msleep(250); |
403 | } |
404 | |
405 | return psSuccess && output.trimmed() == g_options.package.toUtf8(); |
406 | } |
407 | |
408 | static void waitForStarted() |
409 | { |
410 | // wait to start and set PID |
411 | QDeadlineTimer startDeadline(10000); |
412 | do { |
413 | g_testInfo.pid = getPid(package: g_options.package); |
414 | if (g_testInfo.pid > 0) |
415 | break; |
416 | QThread::msleep(100); |
417 | } while (!startDeadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load()); |
418 | } |
419 | |
420 | static void waitForLoggingStarted() |
421 | { |
422 | const QString lsCmd = "ls files/%1"_L1.arg(args&: g_options.stdoutFileName); |
423 | const QStringList adbLsCmd = { "shell"_L1, runCommandAsUserArgs(cmd: lsCmd) }; |
424 | |
425 | QDeadlineTimer deadline(5000); |
426 | do { |
427 | if (execAdbCommand(args: adbLsCmd, output: nullptr, verbose: false)) |
428 | break; |
429 | QThread::msleep(100); |
430 | } while (!deadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load()); |
431 | } |
432 | |
433 | static bool setupStdoutLogger() |
434 | { |
435 | // Start tail to get results to stdout as soon as they're available |
436 | const QString tailPipeCmd = "tail -n +1 -f files/%1"_L1.arg(args&: g_options.stdoutFileName); |
437 | const QStringList adbTailCmd = { "shell"_L1, runCommandAsUserArgs(cmd: tailPipeCmd) }; |
438 | |
439 | g_options.stdoutLogger.emplace(); |
440 | g_options.stdoutLogger->setProcessChannelMode(QProcess::ForwardedOutputChannel); |
441 | g_options.stdoutLogger->start(program: g_options.adbCommand, arguments: adbTailCmd); |
442 | |
443 | if (!g_options.stdoutLogger->waitForStarted()) { |
444 | qCritical() << "Error: failed to run adb command to fetch stdout test results."; |
445 | g_options.stdoutLogger = std::nullopt; |
446 | return false; |
447 | } |
448 | |
449 | return true; |
450 | } |
451 | |
452 | static bool stopStdoutLogger() |
453 | { |
454 | if (!g_options.stdoutLogger.has_value()) { |
455 | // In case this ever happens, it setupStdoutLogger() wasn't called, whether |
456 | // that's on purpose or not, return true since what it does is achieved. |
457 | qCritical() << "Trying to stop the stdout logger process while it's been uninitialised"; |
458 | return true; |
459 | } |
460 | |
461 | if (g_options.stdoutLogger->state() == QProcess::NotRunning) { |
462 | // We expect the tail command to be running until we stop it, so if it's |
463 | // not running it might have been terminated outside of the test runner. |
464 | qCritical() << "The stdout logger process was terminated unexpectedly, " |
465 | "It might have been terminated by an external process"; |
466 | return false; |
467 | } |
468 | |
469 | g_options.stdoutLogger->terminate(); |
470 | |
471 | if (!g_options.stdoutLogger->waitForFinished()) { |
472 | qCritical() << "Error: adb test results tail command timed out."; |
473 | return false; |
474 | } |
475 | |
476 | return true; |
477 | } |
478 | |
479 | static void waitForFinished() |
480 | { |
481 | // Wait to finish |
482 | QDeadlineTimer finishedDeadline(g_options.timeoutSecs * 1000); |
483 | do { |
484 | if (!isRunning()) |
485 | break; |
486 | QThread::msleep(250); |
487 | } while (!finishedDeadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load()); |
488 | |
489 | if (finishedDeadline.hasExpired()) |
490 | qWarning() << "Timed out while waiting for the test to finish"; |
491 | } |
492 | |
493 | static void obtainSdkVersion() |
494 | { |
495 | // SDK version is necessary, as in SDK 23 pidof is broken, so we cannot obtain the pid. |
496 | // Also, Logcat cannot filter by pid in SDK 23, so we don't offer the --show-logcat option. |
497 | QByteArray output; |
498 | const QStringList versionArgs = { "shell"_L1, "getprop"_L1, "ro.build.version.sdk"_L1}; |
499 | execAdbCommand(args: versionArgs, output: &output, verbose: false); |
500 | bool ok = false; |
501 | int sdkVersion = output.toInt(ok: &ok); |
502 | if (ok) |
503 | g_testInfo.sdkVersion = sdkVersion; |
504 | else |
505 | qCritical() << "Unable to obtain the SDK version of the target."; |
506 | } |
507 | |
508 | static QString userId() |
509 | { |
510 | // adb get-current-user command is available starting from API level 26. |
511 | QByteArray userId; |
512 | if (g_testInfo.sdkVersion >= 26) { |
513 | const QStringList userIdArgs = {"shell"_L1, "cmd"_L1, "activity"_L1, "get-current-user"_L1}; |
514 | if (!execAdbCommand(args: userIdArgs, output: &userId, verbose: false)) { |
515 | qCritical() << "Error: failed to retrieve the user ID"; |
516 | userId.clear(); |
517 | } |
518 | } |
519 | |
520 | if (userId.isEmpty()) |
521 | userId = "0"; |
522 | |
523 | return QString::fromUtf8(ba: userId.simplified()); |
524 | } |
525 | |
526 | static QStringList runningDevices() |
527 | { |
528 | QByteArray output; |
529 | execAdbCommand(args: { "devices"_L1}, output: &output, verbose: false); |
530 | |
531 | QStringList devices; |
532 | for (const QByteArray &line : output.split(sep: u'\n')) { |
533 | if (line.contains(bv: "\tdevice"_L1)) |
534 | devices.append(t: QString::fromUtf8(ba: line.split(sep: u'\t').first())); |
535 | } |
536 | |
537 | return devices; |
538 | } |
539 | |
540 | static bool pullResults() |
541 | { |
542 | for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.constEnd(); ++it) { |
543 | const QString filePath = it.value(); |
544 | const QString fileName = QFileInfo(filePath).fileName(); |
545 | // Get only stdout from cat and get rid of stderr and fail later if the output is empty |
546 | const QString catCmd = "cat files/%1 2> /dev/null"_L1.arg(args: fileName); |
547 | const QStringList fullCatArgs = { "shell"_L1, runCommandAsUserArgs(cmd: catCmd) }; |
548 | |
549 | bool catSuccess = false; |
550 | QByteArray output; |
551 | |
552 | for (int i = 1; i <= g_options.resultsPullRetries; ++i) { |
553 | catSuccess = execAdbCommand(args: fullCatArgs, output: &output, verbose: false); |
554 | if (!catSuccess) |
555 | continue; |
556 | else if (!output.isEmpty()) |
557 | break; |
558 | } |
559 | |
560 | if (!catSuccess) { |
561 | qCritical() << "Error: failed to retrieve the test result file %1."_L1.arg(args: fileName); |
562 | return false; |
563 | } |
564 | |
565 | if (output.isEmpty()) { |
566 | qCritical() << "Error: the test result file %1 is empty."_L1.arg(args: fileName); |
567 | return false; |
568 | } |
569 | |
570 | QFile out{filePath}; |
571 | if (!out.open(flags: QIODevice::WriteOnly)) { |
572 | qCritical() << "Error: failed to open %1 to write results to host."_L1.arg(args: filePath); |
573 | return false; |
574 | } |
575 | out.write(data: output); |
576 | } |
577 | |
578 | return true; |
579 | } |
580 | |
581 | static QString getAbiLibsPath() |
582 | { |
583 | QString libsPath = "%1/libs/"_L1.arg(args&: g_options.buildPath); |
584 | const QStringList abiArgs = { "shell"_L1, "getprop"_L1, "ro.product.cpu.abi"_L1}; |
585 | QByteArray abi; |
586 | if (!execAdbCommand(args: abiArgs, output: &abi, verbose: false)) { |
587 | QStringList subDirs = QDir(libsPath).entryList(filters: QDir::Dirs | QDir::NoDotAndDotDot); |
588 | if (!subDirs.isEmpty()) |
589 | abi = subDirs.first().toUtf8(); |
590 | } |
591 | |
592 | abi = abi.trimmed(); |
593 | if (abi.isEmpty()) |
594 | qWarning() << "Failed to get the libs abi, falling to host architecture"; |
595 | |
596 | QString hostArch = QSysInfo::currentCpuArchitecture(); |
597 | if (hostArch == "x86_64"_L1) |
598 | abi = "arm64-x86_64"; |
599 | else if (hostArch == "arm64"_L1) |
600 | abi = "arm64-v8a"; |
601 | else if (hostArch == "i386"_L1) |
602 | abi = "x86"; |
603 | else |
604 | abi = "armeabi-v7a"; |
605 | |
606 | return libsPath + QString::fromUtf8(ba: abi); |
607 | } |
608 | |
609 | void printLogcatCrash(const QByteArray &logcat) |
610 | { |
611 | // No crash report, do nothing |
612 | if (logcat.isEmpty()) |
613 | return; |
614 | |
615 | QByteArray crashLogcat(logcat); |
616 | if (!g_options.ndkStackPath.isEmpty()) { |
617 | QProcess ndkStackProc; |
618 | ndkStackProc.start(program: g_options.ndkStackPath, arguments: { "-sym"_L1, getAbiLibsPath() }); |
619 | |
620 | if (ndkStackProc.waitForStarted()) { |
621 | ndkStackProc.write(data: crashLogcat); |
622 | ndkStackProc.closeWriteChannel(); |
623 | |
624 | if (ndkStackProc.waitForReadyRead()) |
625 | crashLogcat = ndkStackProc.readAllStandardOutput(); |
626 | |
627 | ndkStackProc.terminate(); |
628 | if (!ndkStackProc.waitForFinished()) |
629 | qCritical() << "Error: ndk-stack command timed out."; |
630 | } else { |
631 | qCritical() << "Error: failed to run ndk-stack command."; |
632 | return; |
633 | } |
634 | } else { |
635 | qWarning() << "Warning: ndk-stack path not provided and couldn't be deduced " |
636 | "using the ANDROID_NDK_ROOT environment variable."; |
637 | } |
638 | |
639 | if (!crashLogcat.startsWith(bv: "********** Crash dump")) |
640 | qDebug() << "********** Crash dump: **********"; |
641 | qDebug().noquote() << crashLogcat.trimmed(); |
642 | qDebug() << "********** End crash dump **********"; |
643 | } |
644 | |
645 | void analyseLogcat(const QString &timeStamp, int *exitCode) |
646 | { |
647 | QStringList logcatArgs = { "shell"_L1, "logcat"_L1, "-t"_L1, "'%1'"_L1.arg(args: timeStamp), |
648 | "-v"_L1, "brief"_L1}; |
649 | |
650 | const bool useColor = qEnvironmentVariable(varName: "QTEST_ENVIRONMENT") != "ci"_L1; |
651 | if (useColor) |
652 | logcatArgs << "-v"_L1<< "color"_L1; |
653 | |
654 | QByteArray logcat; |
655 | if (!execAdbCommand(args: logcatArgs, output: &logcat, verbose: false)) { |
656 | qCritical() << "Error: failed to fetch logcat of the test"; |
657 | return; |
658 | } |
659 | |
660 | if (logcat.isEmpty()) { |
661 | qWarning() << "The retrieved logcat is empty"; |
662 | return; |
663 | } |
664 | |
665 | const QByteArray crashMarker("*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***"); |
666 | int crashMarkerIndex = logcat.indexOf(bv: crashMarker); |
667 | QByteArray crashLogcat; |
668 | |
669 | if (crashMarkerIndex != -1) { |
670 | crashLogcat = logcat.mid(index: crashMarkerIndex); |
671 | logcat = logcat.left(n: crashMarkerIndex); |
672 | } |
673 | |
674 | // Check for ANRs |
675 | const bool anrOccurred = logcat.contains(bv: "ANR in %1"_L1.arg(args&: g_options.package).toUtf8()); |
676 | if (anrOccurred) { |
677 | // Treat a found ANR as a test failure. |
678 | *exitCode = *exitCode < 1 ? 1 : *exitCode; |
679 | qCritical(msg: "An ANR has occurred while running the test %s. The logcat will include " |
680 | "additional logs from the system_server process.", |
681 | qPrintable(g_options.package)); |
682 | } |
683 | |
684 | int systemServerPid = getPid(package: "system_server"_L1); |
685 | |
686 | static const QRegularExpression logcatRegEx{ |
687 | "(?:^\\x1B\\[[0-9]+m)?"// color |
688 | "(\\w)/"// message type 1. capture |
689 | ".*"// source |
690 | "(\\(\\s*\\d*\\)):"// pid 2. capture |
691 | "\\s*" |
692 | ".*"// message |
693 | "(?:\\x1B\\[[0-9]+m)?"// color |
694 | "[\\n\\r]*$"_L1 |
695 | }; |
696 | |
697 | QByteArrayList testLogcat; |
698 | for (const QByteArray &line : logcat.split(sep: u'\n')) { |
699 | QRegularExpressionMatch match = logcatRegEx.match(subject: QString::fromUtf8(ba: line)); |
700 | if (match.hasMatch()) { |
701 | const QString msgType = match.captured(nth: 1); |
702 | const QString pidStr = match.captured(nth: 2); |
703 | const int capturedPid = pidStr.mid(position: 1, n: pidStr.size() - 2).trimmed().toInt(); |
704 | if (capturedPid == g_testInfo.pid || msgType == u'F') |
705 | testLogcat.append(t: line); |
706 | else if (anrOccurred && capturedPid == systemServerPid) |
707 | testLogcat.append(t: line); |
708 | } else { |
709 | // If we can't match then just print everything |
710 | testLogcat.append(t: line); |
711 | } |
712 | } |
713 | |
714 | // If we have a failure, attempt to print both logcat and the crash buffer which |
715 | // includes the crash stacktrace that is not included in the default logcat. |
716 | if (g_options.showLogcatOutput || *exitCode != 0) { |
717 | qDebug() << "********** logcat dump **********"; |
718 | qDebug().noquote() << testLogcat.join(sep: u'\n').trimmed(); |
719 | qDebug() << "********** End logcat dump **********"; |
720 | } |
721 | |
722 | if (!crashLogcat.isEmpty() && *exitCode != 0) |
723 | printLogcatCrash(logcat: crashLogcat); |
724 | } |
725 | |
726 | static QString getCurrentTimeString() |
727 | { |
728 | const QString timeFormat = (g_testInfo.sdkVersion <= 23) ? |
729 | "%m-%d %H:%M:%S.000"_L1: "%Y-%m-%d %H:%M:%S.%3N"_L1; |
730 | |
731 | QStringList dateArgs = { "shell"_L1, "date"_L1, "+'%1'"_L1.arg(args: timeFormat) }; |
732 | QByteArray output; |
733 | if (!execAdbCommand(args: dateArgs, output: &output, verbose: false)) { |
734 | qWarning() << "Date/time adb command failed"; |
735 | return {}; |
736 | } |
737 | |
738 | return QString::fromUtf8(ba: output.simplified()); |
739 | } |
740 | |
741 | static int testExitCode() |
742 | { |
743 | QByteArray exitCodeOutput; |
744 | const QString exitCodeCmd = "cat files/qtest_last_exit_code 2> /dev/null"_L1; |
745 | if (!execAdbCommand(args: { "shell"_L1, runCommandAsUserArgs(cmd: exitCodeCmd) }, output: &exitCodeOutput, verbose: false)) { |
746 | qCritical() << "Failed to retrieve the test exit code."; |
747 | return EXIT_ERROR; |
748 | } |
749 | |
750 | bool ok; |
751 | int exitCode = exitCodeOutput.toInt(ok: &ok); |
752 | |
753 | return ok ? exitCode : EXIT_ERROR; |
754 | } |
755 | |
756 | static bool uninstallTestPackage() |
757 | { |
758 | return execAdbCommand(args: { "uninstall"_L1, g_options.package }, output: nullptr); |
759 | } |
760 | |
761 | struct TestRunnerSystemSemaphore |
762 | { |
763 | TestRunnerSystemSemaphore() { } |
764 | ~TestRunnerSystemSemaphore() { release(); } |
765 | |
766 | void acquire() { isAcquired.store(i: semaphore.acquire()); } |
767 | |
768 | void release() |
769 | { |
770 | bool expected = true; |
771 | // NOTE: There's still could be tiny time gap between the compare_exchange_strong() call |
772 | // and release() call where the thread could be interrupted, if that's ever an issue, |
773 | // this code could be checked and improved further. |
774 | if (isAcquired.compare_exchange_strong(i1&: expected, i2: false)) |
775 | isAcquired.store(i: !semaphore.release()); |
776 | } |
777 | |
778 | std::atomic<bool> isAcquired { false }; |
779 | QSystemSemaphore semaphore { QSystemSemaphore::platformSafeKey(key: u"androidtestrunner"_s), |
780 | 1, QSystemSemaphore::Open }; |
781 | }; |
782 | |
783 | TestRunnerSystemSemaphore testRunnerLock; |
784 | |
785 | void sigHandler(int signal) |
786 | { |
787 | std::signal(sig: signal, SIG_DFL); |
788 | testRunnerLock.release(); |
789 | // Ideally we shouldn't be doing such calls from a signal handler, |
790 | // and we can't use QSocketNotifier because this tool doesn't spin |
791 | // a main event loop. Since, there's no other alternative to do this, |
792 | // let's do the cleanup anyway. |
793 | if (!g_testInfo.isPackageInstalled.load()) |
794 | _exit(status: -1); |
795 | g_testInfo.isTestRunnerInterrupted.store(i: true); |
796 | } |
797 | |
798 | int main(int argc, char *argv[]) |
799 | { |
800 | std::signal(SIGINT, handler: sigHandler); |
801 | std::signal(SIGTERM, handler: sigHandler); |
802 | |
803 | QCoreApplication a(argc, argv); |
804 | if (!parseOptions()) { |
805 | printHelp(); |
806 | return EXIT_ERROR; |
807 | } |
808 | |
809 | if (g_options.makeCommand.isEmpty()) { |
810 | qCritical() << "It is required to provide a make command with the \"--make\" parameter " |
811 | "to generate the apk."; |
812 | return EXIT_ERROR; |
813 | } |
814 | if (!execCommand(command: g_options.makeCommand, output: nullptr, verbose: true)) { |
815 | if (!g_options.skipAddInstallRoot) { |
816 | // we need to run make INSTALL_ROOT=path install to install the application file(s) first |
817 | if (!execCommand(command: "%1 INSTALL_ROOT=%2 install"_L1.arg(args&: g_options.makeCommand, |
818 | args: QDir::toNativeSeparators(pathName: g_options.buildPath)), output: nullptr)) { |
819 | return EXIT_ERROR; |
820 | } |
821 | } else { |
822 | if (!execCommand(command: g_options.makeCommand, output: nullptr)) |
823 | return EXIT_ERROR; |
824 | } |
825 | } |
826 | |
827 | if (!QFile::exists(fileName: g_options.apkPath)) { |
828 | qCritical(msg: "No apk \"%s\" found after running the make command. " |
829 | "Check the provided path and the make command.", |
830 | qPrintable(g_options.apkPath)); |
831 | return EXIT_ERROR; |
832 | } |
833 | |
834 | const QStringList devices = runningDevices(); |
835 | if (devices.isEmpty()) { |
836 | qCritical(msg: "No connected devices or running emulators can be found."); |
837 | return EXIT_ERROR; |
838 | } else if (!g_options.serial.isEmpty() && !devices.contains(str: g_options.serial)) { |
839 | qCritical(msg: "No connected device or running emulator with serial '%s' can be found.", |
840 | qPrintable(g_options.serial)); |
841 | return EXIT_ERROR; |
842 | } |
843 | |
844 | obtainSdkVersion(); |
845 | |
846 | g_testInfo.userId = userId(); |
847 | |
848 | QString manifest = g_options.buildPath + "/AndroidManifest.xml"_L1; |
849 | g_options.package = packageNameFromAndroidManifest(androidManifestPath: manifest); |
850 | if (g_options.activity.isEmpty()) |
851 | g_options.activity = activityFromAndroidManifest(androidManifestPath: manifest); |
852 | |
853 | // parseTestArgs depends on g_options.package |
854 | if (!parseTestArgs()) |
855 | return EXIT_ERROR; |
856 | |
857 | // do not install or run packages while another test is running |
858 | testRunnerLock.acquire(); |
859 | |
860 | const QStringList installArgs = { "install"_L1, "-r"_L1, "-g"_L1, g_options.apkPath }; |
861 | g_testInfo.isPackageInstalled.store(i: execAdbCommand(args: installArgs, output: nullptr)); |
862 | if (!g_testInfo.isPackageInstalled) |
863 | return EXIT_ERROR; |
864 | |
865 | // Pre test start |
866 | const QString formattedStartTime = getCurrentTimeString(); |
867 | |
868 | // Start the test |
869 | if (!execAdbCommand(args: g_options.amStarttestArgs, output: nullptr)) |
870 | return EXIT_ERROR; |
871 | |
872 | waitForStarted(); |
873 | waitForLoggingStarted(); |
874 | |
875 | if (!setupStdoutLogger()) |
876 | return EXIT_ERROR; |
877 | |
878 | waitForFinished(); |
879 | |
880 | // Post test run |
881 | if (!stopStdoutLogger()) |
882 | return EXIT_ERROR; |
883 | |
884 | int exitCode = testExitCode(); |
885 | |
886 | analyseLogcat(timeStamp: formattedStartTime, exitCode: &exitCode); |
887 | |
888 | exitCode = pullResults() ? exitCode : EXIT_ERROR; |
889 | |
890 | if (!uninstallTestPackage()) |
891 | return EXIT_ERROR; |
892 | |
893 | testRunnerLock.release(); |
894 | |
895 | if (g_testInfo.isTestRunnerInterrupted.load()) { |
896 | qCritical() << "The androidtestrunner was interrupted and the was test cleaned up."; |
897 | return EXIT_ERROR; |
898 | } |
899 | |
900 | return exitCode; |
901 | } |
902 |
Definitions
- Options
- g_options
- TestInfo
- g_testInfo
- execCommand
- execAdbCommand
- execCommand
- parseOptions
- printHelp
- packageNameFromAndroidManifest
- activityFromAndroidManifest
- setOutputFile
- parseTestArgs
- getPid
- runCommandAsUserArgs
- isRunning
- waitForStarted
- waitForLoggingStarted
- setupStdoutLogger
- stopStdoutLogger
- waitForFinished
- obtainSdkVersion
- userId
- runningDevices
- pullResults
- getAbiLibsPath
- printLogcatCrash
- analyseLogcat
- getCurrentTimeString
- testExitCode
- uninstallTestPackage
- TestRunnerSystemSemaphore
- TestRunnerSystemSemaphore
- ~TestRunnerSystemSemaphore
- acquire
- release
- testRunnerLock
- sigHandler
Learn to use CMake with our Intro Training
Find out more