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