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