• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.csuite.core;
18 
19 import com.android.csuite.core.DeviceUtils.DeviceTimestamp;
20 import com.android.tradefed.device.DeviceNotAvailableException;
21 import com.android.tradefed.invoker.TestInformation;
22 import com.android.tradefed.log.LogUtil.CLog;
23 import com.android.tradefed.result.LogDataType;
24 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
25 import com.android.tradefed.util.CommandResult;
26 import com.android.tradefed.util.CommandStatus;
27 import com.android.tradefed.util.IRunUtil;
28 import com.android.tradefed.util.RunUtil;
29 import com.android.tradefed.util.ZipUtil;
30 
31 import com.google.common.annotations.VisibleForTesting;
32 import com.google.common.base.Preconditions;
33 import com.google.common.io.MoreFiles;
34 
35 import org.junit.Assert;
36 
37 import java.io.File;
38 import java.io.IOException;
39 import java.nio.file.Files;
40 import java.nio.file.Path;
41 import java.util.ArrayList;
42 import java.util.Arrays;
43 import java.util.Collections;
44 import java.util.List;
45 import java.util.concurrent.atomic.AtomicReference;
46 import java.util.stream.Collectors;
47 import java.util.stream.Stream;
48 
49 /** A tester that interact with an app crawler during testing. */
50 public final class AppCrawlTester {
51     @VisibleForTesting Path mOutput;
52     private final RunUtilProvider mRunUtilProvider;
53     private final TestUtils mTestUtils;
54     private final String mPackageName;
55     private static final long COMMAND_TIMEOUT_MILLIS = 4 * 60 * 1000;
56     private boolean mRecordScreen = false;
57     private boolean mCollectGmsVersion = false;
58     private boolean mCollectAppVersion = false;
59     private boolean mUiAutomatorMode = false;
60     private Path mApkRoot;
61 
62     /**
63      * Creates an {@link AppCrawlTester} instance.
64      *
65      * @param packageName The package name of the apk files.
66      * @param testInformation The TradeFed test information.
67      * @param testLogData The TradeFed test output receiver.
68      * @return an {@link AppCrawlTester} instance.
69      */
newInstance( String packageName, TestInformation testInformation, TestLogData testLogData)70     public static AppCrawlTester newInstance(
71             String packageName,
72             TestInformation testInformation,
73             TestLogData testLogData) {
74         return new AppCrawlTester(
75                 packageName,
76                 TestUtils.getInstance(testInformation, testLogData),
77                 () -> new RunUtil());
78     }
79 
80     @VisibleForTesting
AppCrawlTester( String packageName, TestUtils testUtils, RunUtilProvider runUtilProvider)81     AppCrawlTester(
82             String packageName,
83             TestUtils testUtils,
84             RunUtilProvider runUtilProvider) {
85         mRunUtilProvider = runUtilProvider;
86         mPackageName = packageName;
87         mTestUtils = testUtils;
88     }
89 
90     /** An exception class representing crawler test failures. */
91     public static final class CrawlerException extends Exception {
92         /**
93          * Constructs a new {@link CrawlerException} with a meaningful error message.
94          *
95          * @param message A error message describing the cause of the error.
96          */
CrawlerException(String message)97         private CrawlerException(String message) {
98             super(message);
99         }
100 
101         /**
102          * Constructs a new {@link CrawlerException} with a meaningful error message, and a cause.
103          *
104          * @param message A detailed error message.
105          * @param cause A {@link Throwable} capturing the original cause of the CrawlerException.
106          */
CrawlerException(String message, Throwable cause)107         private CrawlerException(String message, Throwable cause) {
108             super(message, cause);
109         }
110 
111         /**
112          * Constructs a new {@link CrawlerException} with a cause.
113          *
114          * @param cause A {@link Throwable} capturing the original cause of the CrawlerException.
115          */
CrawlerException(Throwable cause)116         private CrawlerException(Throwable cause) {
117             super(cause);
118         }
119     }
120 
121     /**
122      * Starts crawling the app and throw AssertionError if app crash is detected.
123      *
124      * @throws DeviceNotAvailableException When device because unavailable.
125      */
startAndAssertAppNoCrash()126     public void startAndAssertAppNoCrash() throws DeviceNotAvailableException {
127         DeviceTimestamp startTime = mTestUtils.getDeviceUtils().currentTimeMillis();
128 
129         CrawlerException crawlerException = null;
130         try {
131             start();
132         } catch (CrawlerException e) {
133             crawlerException = e;
134         }
135 
136         ArrayList<String> failureMessages = new ArrayList<>();
137 
138         try {
139             String dropboxCrashLog =
140                     mTestUtils.getDropboxPackageCrashLog(mPackageName, startTime, true);
141             if (dropboxCrashLog != null) {
142                 // Put dropbox crash log on the top of the failure messages.
143                 failureMessages.add(dropboxCrashLog);
144             }
145         } catch (IOException e) {
146             failureMessages.add("Error while getting dropbox crash log: " + e.getMessage());
147         }
148 
149         if (crawlerException != null) {
150             failureMessages.add(crawlerException.getMessage());
151         }
152 
153         Assert.assertTrue(
154                 String.join(
155                         "\n============\n",
156                         failureMessages.toArray(new String[failureMessages.size()])),
157                 failureMessages.isEmpty());
158     }
159 
160     /**
161      * Starts a crawler run on the configured app.
162      *
163      * @throws CrawlerException When the crawler was not set up correctly or the crawler run command
164      *     failed.
165      * @throws DeviceNotAvailableException When device because unavailable.
166      */
start()167     public void start() throws CrawlerException, DeviceNotAvailableException {
168         if (!AppCrawlTesterHostPreparer.isReady(mTestUtils.getTestInformation())) {
169             throw new CrawlerException(
170                     "The "
171                             + AppCrawlTesterHostPreparer.class.getName()
172                             + " is not ready. Please check whether "
173                             + AppCrawlTesterHostPreparer.class.getName()
174                             + " was included in the test plan and completed successfully.");
175         }
176 
177         if (mOutput != null) {
178             throw new CrawlerException(
179                     "The crawler has already run. Multiple runs in the same "
180                             + AppCrawlTester.class.getName()
181                             + " instance are not supported.");
182         }
183 
184         try {
185             mOutput = Files.createTempDirectory("crawler");
186         } catch (IOException e) {
187             throw new CrawlerException("Failed to create temp directory for output.", e);
188         }
189 
190         String[] command = createCrawlerRunCommand(mTestUtils.getTestInformation());
191 
192         CLog.d("Launching package: %s.", mPackageName);
193 
194         IRunUtil runUtil = mRunUtilProvider.get();
195 
196         AtomicReference<CommandResult> commandResult = new AtomicReference<>();
197         runUtil.setEnvVariable(
198                 "GOOGLE_APPLICATION_CREDENTIALS",
199                 AppCrawlTesterHostPreparer.getCredentialPath(mTestUtils.getTestInformation())
200                         .toString());
201 
202         if (mCollectGmsVersion) {
203             mTestUtils.collectGmsVersion(mPackageName);
204         }
205 
206         if (mRecordScreen) {
207             mTestUtils.collectScreenRecord(
208                     () -> {
209                         commandResult.set(runUtil.runTimedCmd(COMMAND_TIMEOUT_MILLIS, command));
210                     },
211                     mPackageName);
212         } else {
213             commandResult.set(runUtil.runTimedCmd(COMMAND_TIMEOUT_MILLIS, command));
214         }
215 
216         // Must be done after the crawler run because the app is installed by the crawler.
217         if (mCollectAppVersion) {
218             mTestUtils.collectAppVersion(mPackageName);
219         }
220 
221         collectOutputZip();
222         collectCrawlStepScreenshots();
223 
224         if (!commandResult.get().getStatus().equals(CommandStatus.SUCCESS)) {
225             throw new CrawlerException("Crawler command failed: " + commandResult.get());
226         }
227 
228         CLog.i("Completed crawling the package %s. Outputs: %s", mPackageName, commandResult.get());
229     }
230 
231     /** Copys the step screenshots into test outputs for easier access. */
collectCrawlStepScreenshots()232     private void collectCrawlStepScreenshots() {
233         if (mOutput == null) {
234             CLog.e("Output directory is not created yet. Skipping collecting step screenshots.");
235             return;
236         }
237 
238         Path subDir = mOutput.resolve("app_firebase_test_lab");
239         if (!Files.exists(subDir)) {
240             CLog.e(
241                     "The crawler output directory is not complete, skipping collecting step"
242                             + " screenshots.");
243             return;
244         }
245 
246         try (Stream<Path> files = Files.list(subDir)) {
247             files.filter(path -> path.getFileName().toString().toLowerCase().endsWith(".png"))
248                     .forEach(
249                             path -> {
250                                 mTestUtils
251                                         .getTestArtifactReceiver()
252                                         .addTestArtifact(
253                                                 mPackageName
254                                                         + "-crawl_step_screenshot_"
255                                                         + path.getFileName(),
256                                                 LogDataType.PNG,
257                                                 path.toFile());
258                             });
259         } catch (IOException e) {
260             CLog.e(e);
261         }
262     }
263 
264     /** Puts the zipped crawler output files into test output. */
collectOutputZip()265     private void collectOutputZip() {
266         if (mOutput == null) {
267             CLog.e("Output directory is not created yet. Skipping collecting output.");
268             return;
269         }
270 
271         // Compress the crawler output directory and add it to test outputs.
272         try {
273             File outputZip = ZipUtil.createZip(mOutput.toFile());
274             mTestUtils
275                     .getTestArtifactReceiver()
276                     .addTestArtifact(mPackageName + "-crawler_output", LogDataType.ZIP, outputZip);
277         } catch (IOException e) {
278             CLog.e("Failed to zip the output directory: " + e);
279         }
280     }
281 
282     /**
283      * Generates a list of APK paths where the base.apk of split apk files are always on the first
284      * index if exists.
285      *
286      * <p>If the apk path is a single apk, then the apk is returned. If the apk path is a directory
287      * containing only one non-split apk file, the apk file is returned. If the apk path is a
288      * directory containing split apk files for one package, then the list of apks are returned and
289      * the base.apk sits on the first index. If the apk path does not contain any apk files, or
290      * multiple apk files without base.apk, then an IOException is thrown.
291      *
292      * @return A list of APK paths.
293      * @throws CrawlerException If failed to read the apk path or unexpected number of apk files are
294      *     found under the path.
295      */
getApks(Path root)296     private static List<Path> getApks(Path root) throws CrawlerException {
297         // The apk path points to a non-split apk file.
298         if (Files.isRegularFile(root)) {
299             if (!root.toString().endsWith(".apk")) {
300                 throw new CrawlerException(
301                         "The file on the given apk path is not an apk file: " + root);
302             }
303             return List.of(root);
304         }
305 
306         List<Path> apks;
307         CLog.d("APK path = " + root);
308         try (Stream<Path> fileTree = Files.walk(root)) {
309             apks =
310                     fileTree.filter(Files::isRegularFile)
311                             .filter(path -> path.getFileName().toString().endsWith(".apk"))
312                             .collect(Collectors.toList());
313         } catch (IOException e) {
314             throw new CrawlerException("Failed to list apk files.", e);
315         }
316 
317         if (apks.isEmpty()) {
318             throw new CrawlerException("The apk directory does not contain any apk files");
319         }
320 
321         // The apk path contains a single non-split apk or the base.apk of a split-apk.
322         if (apks.size() == 1) {
323             return apks;
324         }
325 
326         if (apks.stream().map(path -> path.getParent().toString()).distinct().count() != 1) {
327             throw new CrawlerException(
328                     "Apk files are not all in the same folder: "
329                             + Arrays.deepToString(apks.toArray(new Path[apks.size()])));
330         }
331 
332         if (apks.stream().filter(path -> path.getFileName().toString().equals("base.apk")).count()
333                 == 0) {
334             throw new CrawlerException(
335                     "Multiple non-split apk files detected: "
336                             + Arrays.deepToString(apks.toArray(new Path[apks.size()])));
337         }
338 
339         Collections.sort(
340                 apks,
341                 (first, second) -> first.getFileName().toString().equals("base.apk") ? -1 : 0);
342 
343         return apks;
344     }
345 
346     @VisibleForTesting
createCrawlerRunCommand(TestInformation testInfo)347     String[] createCrawlerRunCommand(TestInformation testInfo) throws CrawlerException {
348 
349         ArrayList<String> cmd = new ArrayList<>();
350         cmd.addAll(
351                 Arrays.asList(
352                         "java",
353                         "-jar",
354                         AppCrawlTesterHostPreparer.getCrawlerBinPath(testInfo)
355                                 .resolve("crawl_launcher_deploy.jar")
356                                 .toString(),
357                         "--android-sdk-path",
358                         AppCrawlTesterHostPreparer.getSdkPath(testInfo).toString(),
359                         "--device-serial-code",
360                         testInfo.getDevice().getSerialNumber(),
361                         "--output-dir",
362                         mOutput.toString(),
363                         "--key-store-file",
364                         // Using the publicly known default file name of the debug keystore.
365                         AppCrawlTesterHostPreparer.getCrawlerBinPath(testInfo)
366                                 .resolve("debug.keystore")
367                                 .toString(),
368                         "--key-store-password",
369                         // Using the publicly known default password of the debug keystore.
370                         "android"));
371 
372         if (mUiAutomatorMode) {
373             cmd.addAll(Arrays.asList("--ui-automator-mode", "--app-package-name", mPackageName));
374         } else {
375             Preconditions.checkNotNull(
376                     mApkRoot, "Apk file path is required when not running in UIAutomator mode");
377 
378             List<Path> apks = getApks(mApkRoot);
379 
380             cmd.add("--apk-file");
381             cmd.add(apks.get(0).toString());
382 
383             for (int i = 1; i < apks.size(); i++) {
384                 cmd.add("--split-apk-files");
385                 cmd.add(apks.get(i).toString());
386             }
387         }
388 
389         return cmd.toArray(new String[cmd.size()]);
390     }
391 
392     /** Cleans up the crawler output directory. */
cleanUp()393     public void cleanUp() {
394         if (mOutput == null) {
395             return;
396         }
397 
398         try {
399             MoreFiles.deleteRecursively(mOutput);
400         } catch (IOException e) {
401             CLog.e("Failed to clean up the crawler output directory: " + e);
402         }
403     }
404 
405     /** Sets the option of whether to record the device screen during crawling. */
setRecordScreen(boolean recordScreen)406     public void setRecordScreen(boolean recordScreen) {
407         mRecordScreen = recordScreen;
408     }
409 
410     /** Sets the option of whether to collect GMS version in test artifacts. */
setCollectGmsVersion(boolean collectGmsVersion)411     public void setCollectGmsVersion(boolean collectGmsVersion) {
412         mCollectGmsVersion = collectGmsVersion;
413     }
414 
415     /** Sets the option of whether to collect the app version in test artifacts. */
setCollectAppVersion(boolean collectAppVersion)416     public void setCollectAppVersion(boolean collectAppVersion) {
417         mCollectAppVersion = collectAppVersion;
418     }
419 
420     /** Sets the option of whether to run the crawler with UIAutomator mode. */
setUiAutomatorMode(boolean uiAutomatorMode)421     public void setUiAutomatorMode(boolean uiAutomatorMode) {
422         mUiAutomatorMode = uiAutomatorMode;
423     }
424 
425     /**
426      * Sets the apk file path. Required when not running in UIAutomator mode.
427      *
428      * @param apkRoot The root path for an apk or a directory that contains apk files for a package.
429      */
setApkPath(Path apkRoot)430     public void setApkPath(Path apkRoot) {
431         mApkRoot = apkRoot;
432     }
433 
434     @VisibleForTesting
435     interface RunUtilProvider {
get()436         IRunUtil get();
437     }
438 }
439