1 /****************************************************************************
2 **
3 ** Copyright (C) 2019 BogDan Vatra <bogdan@kde.org>
4 ** Copyright (C) 2016 The Qt Company Ltd.
5 ** Contact: https://www.qt.io/licensing/
6 **
7 ** This file is part of the tools applications of the Qt Toolkit.
8 **
9 ** $QT_BEGIN_LICENSE:GPL-EXCEPT$
10 ** Commercial License Usage
11 ** Licensees holding valid commercial Qt licenses may use this file in
12 ** accordance with the commercial license agreement provided with the
13 ** Software or, alternatively, in accordance with the terms contained in
14 ** a written agreement between you and The Qt Company. For licensing terms
15 ** and conditions see https://www.qt.io/terms-conditions. For further
16 ** information use the contact form at https://www.qt.io/contact-us.
17 **
18 ** GNU General Public License Usage
19 ** Alternatively, this file may be used under the terms of the GNU
20 ** General Public License version 3 as published by the Free Software
21 ** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
22 ** included in the packaging of this file. Please review the following
23 ** information to ensure the GNU General Public License requirements will
24 ** be met: https://www.gnu.org/licenses/gpl-3.0.html.
25 **
26 ** $QT_END_LICENSE$
27 **
28 ****************************************************************************/
29 
30 #include <QCoreApplication>
31 #include <QDir>
32 #include <QHash>
33 #include <QRegExp>
34 #include <QSystemSemaphore>
35 #include <QXmlStreamReader>
36 
37 #include <algorithm>
38 #include <chrono>
39 #include <functional>
40 #include <thread>
41 
42 #ifdef Q_CC_MSVC
43 #define popen _popen
44 #define QT_POPEN_READ "rb"
45 #define pclose _pclose
46 #else
47 #define QT_POPEN_READ "r"
48 #endif
49 
50 struct Options
51 {
52     bool helpRequested = false;
53     bool verbose = false;
54     std::chrono::seconds timeout{300}; // 5minutes
55     QString androidDeployQtCommand;
56     QString buildPath;
57     QString adbCommand{QStringLiteral("adb")};
58     QString makeCommand;
59     QString package;
60     QString activity;
61     QStringList testArgsList;
62     QHash<QString, QString> outFiles;
63     QString testArgs;
64     QString apkPath;
65     QHash<QString, std::function<bool(const QByteArray &)>> checkFiles = {
__anonfbe30f080102Options66     {QStringLiteral("txt"), [](const QByteArray &data) -> bool {
67         return data.indexOf("\nFAIL!  : ") < 0;
68     }},
__anonfbe30f080202Options69     {QStringLiteral("csv"), [](const QByteArray &/*data*/) -> bool {
70         // It seems csv is broken
71         return true;
72     }},
__anonfbe30f080302Options73     {QStringLiteral("xml"), [](const QByteArray &data) -> bool {
74         QXmlStreamReader reader{data};
75         while (!reader.atEnd()) {
76             reader.readNext();
77             if (reader.isStartElement() && reader.name() == QStringLiteral("Incident") &&
78                     reader.attributes().value(QStringLiteral("type")).toString() == QStringLiteral("fail")) {
79                 return false;
80             }
81         }
82         return true;
83     }},
__anonfbe30f080402Options84     {QStringLiteral("lightxml"), [](const QByteArray &data) -> bool {
85         return data.indexOf("\n<Incident type=\"fail\" ") < 0;
86     }},
__anonfbe30f080502Options87     {QStringLiteral("xunitxml"), [](const QByteArray &data) -> bool {
88         QXmlStreamReader reader{data};
89         while (!reader.atEnd()) {
90             reader.readNext();
91             if (reader.isStartElement() && reader.name() == QStringLiteral("testcase") &&
92                     reader.attributes().value(QStringLiteral("result")).toString() == QStringLiteral("fail")) {
93                 return false;
94             }
95         }
96         return true;
97     }},
__anonfbe30f080602Options98     {QStringLiteral("teamcity"), [](const QByteArray &data) -> bool {
99         return data.indexOf("' message='Failure! |[Loc: ") < 0;
100     }},
__anonfbe30f080702Options101     {QStringLiteral("tap"), [](const QByteArray &data) -> bool {
102         return data.indexOf("\nnot ok ") < 0;
103     }},
104     };
105 };
106 
107 static Options g_options;
108 
execCommand(const QString & command,QByteArray * output=nullptr,bool verbose=false)109 static bool execCommand(const QString &command, QByteArray *output = nullptr, bool verbose = false)
110 {
111     if (verbose)
112         fprintf(stdout, "Execute %s\n", command.toUtf8().constData());
113     FILE *process = popen(command.toUtf8().constData(), QT_POPEN_READ);
114 
115     if (!process) {
116         fprintf(stderr, "Cannot execute command %s", qPrintable(command));
117         return false;
118     }
119     char buffer[512];
120     while (fgets(buffer, sizeof(buffer), process)) {
121         if (output)
122             output->append(buffer);
123         if (verbose)
124             fprintf(stdout, "%s", buffer);
125     }
126     return pclose(process) == 0;
127 }
128 
129 // Copy-pasted from qmake/library/ioutil.cpp
hasSpecialChars(const QString & arg,const uchar (& iqm)[16])130 inline static bool hasSpecialChars(const QString &arg, const uchar (&iqm)[16])
131 {
132     for (int x = arg.length() - 1; x >= 0; --x) {
133         ushort c = arg.unicode()[x].unicode();
134         if ((c < sizeof(iqm) * 8) && (iqm[c / 8] & (1 << (c & 7))))
135             return true;
136     }
137     return false;
138 }
139 
shellQuoteUnix(const QString & arg)140 static QString shellQuoteUnix(const QString &arg)
141 {
142     // Chars that should be quoted (TM). This includes:
143     static const uchar iqm[] = {
144         0xff, 0xff, 0xff, 0xff, 0xdf, 0x07, 0x00, 0xd8,
145         0x00, 0x00, 0x00, 0x38, 0x01, 0x00, 0x00, 0x78
146     }; // 0-32 \'"$`<>|;&(){}*?#!~[]
147 
148     if (!arg.length())
149         return QStringLiteral("\"\"");
150 
151     QString ret(arg);
152     if (hasSpecialChars(ret, iqm)) {
153         ret.replace(QLatin1Char('\''), QStringLiteral("'\\''"));
154         ret.prepend(QLatin1Char('\''));
155         ret.append(QLatin1Char('\''));
156     }
157     return ret;
158 }
159 
shellQuoteWin(const QString & arg)160 static QString shellQuoteWin(const QString &arg)
161 {
162     // Chars that should be quoted (TM). This includes:
163     // - control chars & space
164     // - the shell meta chars "&()<>^|
165     // - the potential separators ,;=
166     static const uchar iqm[] = {
167         0xff, 0xff, 0xff, 0xff, 0x45, 0x13, 0x00, 0x78,
168         0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10
169     };
170 
171     if (!arg.length())
172         return QStringLiteral("\"\"");
173 
174     QString ret(arg);
175     if (hasSpecialChars(ret, iqm)) {
176         // Quotes are escaped and their preceding backslashes are doubled.
177         // It's impossible to escape anything inside a quoted string on cmd
178         // level, so the outer quoting must be "suspended".
179         ret.replace(QRegExp(QStringLiteral("(\\\\*)\"")), QStringLiteral("\"\\1\\1\\^\"\""));
180         // The argument must not end with a \ since this would be interpreted
181         // as escaping the quote -- rather put the \ behind the quote: e.g.
182         // rather use "foo"\ than "foo\"
183         int i = ret.length();
184         while (i > 0 && ret.at(i - 1) == QLatin1Char('\\'))
185             --i;
186         ret.insert(i, QLatin1Char('"'));
187         ret.prepend(QLatin1Char('"'));
188     }
189     return ret;
190 }
191 
shellQuote(const QString & arg)192 static QString shellQuote(const QString &arg)
193 {
194     if (QDir::separator() == QLatin1Char('\\'))
195         return shellQuoteWin(arg);
196     else
197         return shellQuoteUnix(arg);
198 }
199 
parseOptions()200 static bool parseOptions()
201 {
202     QStringList arguments = QCoreApplication::arguments();
203     int i = 1;
204     for (; i < arguments.size(); ++i) {
205         const QString &argument = arguments.at(i);
206         if (argument.compare(QStringLiteral("--androiddeployqt"), Qt::CaseInsensitive) == 0) {
207             if (i + 1 == arguments.size())
208                 g_options.helpRequested = true;
209             else
210                 g_options.androidDeployQtCommand = arguments.at(++i).trimmed();
211         } else if (argument.compare(QStringLiteral("--adb"), Qt::CaseInsensitive) == 0) {
212             if (i + 1 == arguments.size())
213                 g_options.helpRequested = true;
214             else
215                 g_options.adbCommand = arguments.at(++i);
216         } else if (argument.compare(QStringLiteral("--path"), Qt::CaseInsensitive) == 0) {
217             if (i + 1 == arguments.size())
218                 g_options.helpRequested = true;
219             else
220                 g_options.buildPath = arguments.at(++i);
221         } else if (argument.compare(QStringLiteral("--make"), Qt::CaseInsensitive) == 0) {
222             if (i + 1 == arguments.size())
223                 g_options.helpRequested = true;
224             else
225                 g_options.makeCommand = arguments.at(++i);
226         } else if (argument.compare(QStringLiteral("--apk"), Qt::CaseInsensitive) == 0) {
227             if (i + 1 == arguments.size())
228                 g_options.helpRequested = true;
229             else
230                 g_options.apkPath = arguments.at(++i);
231         } else if (argument.compare(QStringLiteral("--activity"), Qt::CaseInsensitive) == 0) {
232             if (i + 1 == arguments.size())
233                 g_options.helpRequested = true;
234             else
235                 g_options.activity = arguments.at(++i);
236         } else if (argument.compare(QStringLiteral("--timeout"), Qt::CaseInsensitive) == 0) {
237             if (i + 1 == arguments.size())
238                 g_options.helpRequested = true;
239             else
240                 g_options.timeout = std::chrono::seconds{arguments.at(++i).toInt()};
241         } else if (argument.compare(QStringLiteral("--help"), Qt::CaseInsensitive) == 0) {
242             g_options.helpRequested = true;
243         } else if (argument.compare(QStringLiteral("--verbose"), Qt::CaseInsensitive) == 0) {
244             g_options.verbose = true;
245         } else if (argument.compare(QStringLiteral("--"), Qt::CaseInsensitive) == 0) {
246             ++i;
247             break;
248         } else {
249             g_options.testArgsList << arguments.at(i);
250         }
251     }
252     for (;i < arguments.size(); ++i)
253         g_options.testArgsList << arguments.at(i);
254 
255     if (g_options.helpRequested || g_options.androidDeployQtCommand.isEmpty() || g_options.buildPath.isEmpty())
256         return false;
257 
258     QString serial = qEnvironmentVariable("ANDROID_DEVICE_SERIAL");
259     if (!serial.isEmpty())
260         g_options.adbCommand += QStringLiteral(" -s %1").arg(serial);
261     return true;
262 }
263 
printHelp()264 static void printHelp()
265 {//                 "012345678901234567890123456789012345678901234567890123456789012345678901"
266     fprintf(stderr, "Syntax: %s <options> -- [TESTARGS] \n"
267                     "\n"
268                     "  Creates an Android package in a temp directory <destination> and\n"
269                     "  runs it on the default emulator/device or on the one specified by\n"
270                     "  \"ANDROID_DEVICE_SERIAL\" environment variable.\n\n"
271                     "  Mandatory arguments:\n"
272                     "    --androiddeployqt <androiddeployqt cmd>: The androiddeployqt:\n"
273                     "       path including its additional arguments.\n"
274                     "    --path <path>: The path where androiddeployqt will build the .apk.\n"
275                     "  Optional arguments:\n"
276                     "    --adb <adb cmd>: The Android ADB command. If missing the one from\n"
277                     "       $PATH will be used.\n"
278                     "    --activity <acitvity>: The Activity to run. If missing the first\n"
279                     "       activity from AndroidManifest.qml file will be used.\n"
280                     "    --timeout <seconds>: Timeout to run the test.\n"
281                     "       Default is 5 minutes.\n"
282                     "    --make <make cmd>: make command, needed to install the qt library.\n"
283                     "       If make is missing make sure the --path is set.\n"
284                     "    --apk <apk path>: If the apk is specified and if exists, we'll skip\n"
285                     "       the package building.\n"
286                     "    -- arguments that will be passed to the test application.\n"
287                     "    --verbose: Prints out information during processing.\n"
288                     "    --help: Displays this information.\n\n",
289                     qPrintable(QCoreApplication::arguments().at(0))
290             );
291 }
292 
packageNameFromAndroidManifest(const QString & androidManifestPath)293 static QString packageNameFromAndroidManifest(const QString &androidManifestPath)
294 {
295     QFile androidManifestXml(androidManifestPath);
296     if (androidManifestXml.open(QIODevice::ReadOnly)) {
297         QXmlStreamReader reader(&androidManifestXml);
298         while (!reader.atEnd()) {
299             reader.readNext();
300             if (reader.isStartElement() && reader.name() == QStringLiteral("manifest"))
301                 return reader.attributes().value(QStringLiteral("package")).toString();
302         }
303     }
304     return {};
305 }
306 
activityFromAndroidManifest(const QString & androidManifestPath)307 static QString activityFromAndroidManifest(const QString &androidManifestPath)
308 {
309     QFile androidManifestXml(androidManifestPath);
310     if (androidManifestXml.open(QIODevice::ReadOnly)) {
311         QXmlStreamReader reader(&androidManifestXml);
312         while (!reader.atEnd()) {
313             reader.readNext();
314             if (reader.isStartElement() && reader.name() == QStringLiteral("activity"))
315                 return reader.attributes().value(QStringLiteral("android:name")).toString();
316         }
317     }
318     return {};
319 }
320 
setOutputFile(QString file,QString format)321 static void setOutputFile(QString file, QString format)
322 {
323     if (file.isEmpty())
324         file = QStringLiteral("-");
325     if (format.isEmpty())
326         format = QStringLiteral("txt");
327 
328     g_options.outFiles[format] = file;
329 }
330 
parseTestArgs()331 static bool parseTestArgs()
332 {
333     QRegExp newLoggingFormat{QStringLiteral("(.*),(txt|csv|xunitxml|xml|lightxml|teamcity|tap)")};
334     QRegExp oldFormats{QStringLiteral("-(txt|csv|xunitxml|xml|lightxml|teamcity|tap)")};
335 
336     QString file;
337     QString logType;
338     QString unhandledArgs;
339     for (int i = 0; i < g_options.testArgsList.size(); ++i) {
340         const QString &arg = g_options.testArgsList[i].trimmed();
341         if (arg == QStringLiteral("-o")) {
342             if (i >= g_options.testArgsList.size() - 1)
343                 return false; // missing file argument
344 
345             const auto &filePath = g_options.testArgsList[++i];
346             if (!newLoggingFormat.exactMatch(filePath)) {
347                 file = filePath;
348             } else {
349                 const auto capturedTexts = newLoggingFormat.capturedTexts();
350                 setOutputFile(capturedTexts.at(1), capturedTexts.at(2));
351             }
352         } else if (oldFormats.exactMatch(arg)) {
353             logType = oldFormats.capturedTexts().at(1);
354         } else {
355             unhandledArgs += QStringLiteral(" %1").arg(arg);
356         }
357     }
358     if (g_options.outFiles.isEmpty() || !file.isEmpty() || !logType.isEmpty())
359         setOutputFile(file, logType);
360 
361     for (const auto &format : g_options.outFiles.keys())
362         g_options.testArgs += QStringLiteral(" -o output.%1,%1").arg(format);
363 
364     g_options.testArgs += unhandledArgs;
365     g_options.testArgs = QStringLiteral("shell am start -e applicationArguments \\\"%1\\\" -n %2/%3").arg(shellQuote(g_options.testArgs.trimmed()),
366                                                                                                       g_options.package,
367                                                                                                       g_options.activity);
368     return true;
369 }
370 
isRunning()371 static bool isRunning() {
372     QByteArray output;
373     if (!execCommand(QStringLiteral("%1 shell \"ps | grep ' %2'\"").arg(g_options.adbCommand,
374                                                                         shellQuote(g_options.package)), &output)) {
375 
376         return false;
377     }
378     return output.indexOf(" " + g_options.package.toUtf8()) > -1;
379 }
380 
waitToFinish()381 static bool waitToFinish()
382 {
383     using clock = std::chrono::system_clock;
384     auto start = clock::now();
385     // wait to start
386     while (!isRunning()) {
387         std::this_thread::sleep_for(std::chrono::milliseconds(100));
388         if ((clock::now() - start) > std::chrono::seconds{10})
389             return false;
390     }
391 
392     // Wait to finish
393     while (isRunning()) {
394         std::this_thread::sleep_for(std::chrono::milliseconds(250));
395         if ((clock::now() - start) > g_options.timeout)
396             return false;
397     }
398     return true;
399 }
400 
401 
pullFiles()402 static bool pullFiles()
403 {
404     bool ret = true;
405     for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.end(); ++it) {
406         QByteArray output;
407         if (!execCommand(QStringLiteral("%1 shell run-as %2 cat files/output.%3")
408                          .arg(g_options.adbCommand, g_options.package, it.key()), &output)) {
409             return false;
410         }
411         auto checkerIt = g_options.checkFiles.find(it.key());
412         ret = ret && checkerIt != g_options.checkFiles.end() && checkerIt.value()(output);
413         if (it.value() == QStringLiteral("-")){
414             fprintf(stdout, "%s", output.constData());
415             fflush(stdout);
416         } else {
417             QFile out{it.value()};
418             if (!out.open(QIODevice::WriteOnly))
419                 return false;
420             out.write(output);
421         }
422     }
423     return ret;
424 }
425 
426 struct RunnerLocker
427 {
RunnerLockerRunnerLocker428     RunnerLocker()
429     {
430         runner.acquire();
431     }
~RunnerLockerRunnerLocker432     ~RunnerLocker()
433     {
434         runner.release();
435     }
436     QSystemSemaphore runner{QStringLiteral("androidtestrunner"), 1, QSystemSemaphore::Open};
437 };
438 
main(int argc,char * argv[])439 int main(int argc, char *argv[])
440 {
441     QCoreApplication a(argc, argv);
442     if (!parseOptions()) {
443         printHelp();
444         return 1;
445     }
446 
447     RunnerLocker lock; // do not install or run packages while another test is running
448     if (!g_options.apkPath.isEmpty() && QFile::exists(g_options.apkPath)) {
449         if (!execCommand(QStringLiteral("%1 install -r %2")
450                          .arg(g_options.adbCommand, g_options.apkPath), nullptr, g_options.verbose)) {
451             return 1;
452         }
453     } else {
454         if (!g_options.makeCommand.isEmpty()) {
455             // we need to run make INSTALL_ROOT=path install to install the application file(s) first
456             if (!execCommand(QStringLiteral("%1 INSTALL_ROOT=%2 install")
457                              .arg(g_options.makeCommand, QDir::toNativeSeparators(g_options.buildPath)), nullptr, g_options.verbose)) {
458                 return 1;
459             }
460         }
461 
462         // Run androiddeployqt
463         static auto verbose = g_options.verbose ? QStringLiteral("--verbose") : QString();
464         if (!execCommand(QStringLiteral("%1 %3 --reinstall --output %2 --apk %4").arg(g_options.androidDeployQtCommand,
465                                                                                       g_options.buildPath,
466                                                                                       verbose,
467                                                                                       g_options.apkPath), nullptr, true)) {
468             return 1;
469         }
470     }
471 
472     QString manifest = g_options.buildPath + QStringLiteral("/AndroidManifest.xml");
473     g_options.package = packageNameFromAndroidManifest(manifest);
474     if (g_options.activity.isEmpty())
475         g_options.activity = activityFromAndroidManifest(manifest);
476 
477     // parseTestArgs depends on g_options.package
478     if (!parseTestArgs())
479         return 1;
480 
481     // start the tests
482     bool res = execCommand(QStringLiteral("%1 %2").arg(g_options.adbCommand, g_options.testArgs),
483                            nullptr, g_options.verbose) && waitToFinish();
484     if (res)
485         res &= pullFiles();
486     res &= execCommand(QStringLiteral("%1 uninstall %2").arg(g_options.adbCommand, g_options.package),
487                        nullptr, g_options.verbose);
488     fflush(stdout);
489     return res ? 0 : 1;
490 }
491