1 /* 2 * Copyright (C) 2024 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 package com.android.tradefed.result.ddmlib; 17 18 import com.android.annotations.NonNull; 19 import com.android.ddmlib.AdbCommandRejectedException; 20 import com.android.ddmlib.IDevice; 21 import com.android.ddmlib.IShellEnabledDevice; 22 import com.android.ddmlib.ShellCommandUnresponsiveException; 23 import com.android.ddmlib.TimeoutException; 24 import com.android.ddmlib.testrunner.IInstrumentationResultParser; 25 import com.android.ddmlib.testrunner.IRemoteAndroidTestRunner; 26 import com.android.ddmlib.testrunner.ITestRunListener; 27 import com.android.tradefed.log.Log; 28 import com.android.tradefed.log.LogUtil.CLog; 29 30 import java.io.IOException; 31 import java.util.Arrays; 32 import java.util.Collection; 33 import java.util.Hashtable; 34 import java.util.Map; 35 import java.util.Map.Entry; 36 import java.util.concurrent.TimeUnit; 37 import java.util.function.BiFunction; 38 39 /** Runs a Android test command remotely and reports results. */ 40 public class RemoteAndroidTestRunner implements IRemoteAndroidTestRunner { 41 42 /** Represents a status reporter mode in am instrument command options. */ 43 public enum StatusReporterMode { 44 /** 45 * Use raw text message to receive status from am instrument command. 46 * 47 * @deprecated Use {@link #PROTO_STD} for API level 26 and above. 48 */ 49 @Deprecated 50 RAW_TEXT("-r", 0, InstrumentationResultParser::new), 51 /** 52 * Use instrumentationData protobuf status reporter to receive status from am instrument 53 * command. 54 */ 55 PROTO_STD("-m", 26, InstrumentationProtoResultParser::new); 56 StatusReporterMode( String amInstrumentCommandArg, int minApiLevel, BiFunction<String, Collection<ITestRunListener>, IInstrumentationResultParser> parserFactory)57 StatusReporterMode( 58 String amInstrumentCommandArg, 59 int minApiLevel, 60 BiFunction<String, Collection<ITestRunListener>, IInstrumentationResultParser> 61 parserFactory) { 62 this.amInstrumentCommandArg = amInstrumentCommandArg; 63 this.minApiLevel = minApiLevel; 64 this.parserFactory = parserFactory; 65 } 66 67 private final String amInstrumentCommandArg; 68 private final int minApiLevel; 69 private final BiFunction<String, Collection<ITestRunListener>, IInstrumentationResultParser> 70 parserFactory; 71 72 /** 73 * Returns a command line arg for am instrument command to specify this status reporter 74 * mode. 75 */ getAmInstrumentCommandArg()76 public String getAmInstrumentCommandArg() { 77 return amInstrumentCommandArg; 78 } 79 80 /** 81 * Returns the minimum Android API level which supports this instrumentation status report 82 * type. 83 */ getMinimumApiLevel()84 public int getMinimumApiLevel() { 85 return minApiLevel; 86 } 87 88 /** 89 * Create the {@link InstrumentationResultParser} that can be used to parse the 90 * instrumentation output. 91 * 92 * @param runName The name of the run to use. 93 * @param listeners The listeners where to report the results. 94 * @return An instance of {@link InstrumentationResultParser}. 95 */ createInstrumentationResultParser( @onNull String runName, @NonNull Collection<ITestRunListener> listeners)96 public IInstrumentationResultParser createInstrumentationResultParser( 97 @NonNull String runName, @NonNull Collection<ITestRunListener> listeners) { 98 return parserFactory.apply(runName, listeners); 99 } 100 } 101 102 private final StatusReporterMode mStatusReporterMode; 103 private final String mPackageName; 104 private final String mRunnerName; 105 private IShellEnabledDevice mRemoteDevice; 106 // default to no timeout 107 private long mMaxTimeoutMs = 0L; 108 private long mMaxTimeToOutputResponseMs = 0L; 109 private String mRunName = null; 110 111 /** map of name-value instrumentation argument pairs */ 112 private Map<String, String> mArgMap; 113 114 private IInstrumentationResultParser mParser; 115 private static final String LOG_TAG = "RemoteAndroidTest"; 116 private static final String DEFAULT_RUNNER_NAME = "android.test.InstrumentationTestRunner"; 117 private static final char CLASS_SEPARATOR = ','; 118 private static final char METHOD_SEPARATOR = '#'; 119 private static final char RUNNER_SEPARATOR = '/'; 120 // defined instrumentation argument names 121 private static final String CLASS_ARG_NAME = "class"; 122 private static final String LOG_ARG_NAME = "log"; 123 private static final String DEBUG_ARG_NAME = "debug"; 124 private static final String COVERAGE_ARG_NAME = "coverage"; 125 private static final String PACKAGE_ARG_NAME = "package"; 126 private static final String SIZE_ARG_NAME = "size"; 127 private static final String DELAY_MSEC_ARG_NAME = "delay_msec"; 128 private String mRunOptions = ""; 129 private static final int TEST_COLLECTION_TIMEOUT = 2 * 60 * 1000; // 2 min 130 131 /** 132 * Creates a remote Android test runner. 133 * 134 * @param packageName the Android application package that contains the tests to run 135 * @param runnerName the instrumentation test runner to execute. If null, will use default 136 * runner 137 * @param remoteDevice the Android device to execute tests on 138 * @param statusReporterMode the status reporter mode to be used for am instrument command 139 */ RemoteAndroidTestRunner( String packageName, String runnerName, IShellEnabledDevice remoteDevice, StatusReporterMode statusReporterMode)140 public RemoteAndroidTestRunner( 141 String packageName, 142 String runnerName, 143 IShellEnabledDevice remoteDevice, 144 StatusReporterMode statusReporterMode) { 145 mPackageName = packageName; 146 mRunnerName = runnerName; 147 mRemoteDevice = remoteDevice; 148 mStatusReporterMode = statusReporterMode; 149 mArgMap = new Hashtable<String, String>(); 150 } 151 152 /** 153 * Alternate constructor. Uses default {@code statusReporterMode}. 154 * 155 * @param packageName the Android application package that contains the tests to run 156 * @param runnerName the instrumentation test runner to execute. If null, will use default 157 * runner 158 * @param remoteDevice the Android device to execute tests on 159 */ RemoteAndroidTestRunner( String packageName, String runnerName, IShellEnabledDevice remoteDevice)160 public RemoteAndroidTestRunner( 161 String packageName, String runnerName, IShellEnabledDevice remoteDevice) { 162 this(packageName, runnerName, remoteDevice, StatusReporterMode.RAW_TEXT); 163 } 164 165 /** 166 * Alternate constructor. Uses default instrumentation runner. 167 * 168 * @param packageName the Android application package that contains the tests to run 169 * @param remoteDevice the Android device to execute tests on 170 */ RemoteAndroidTestRunner(String packageName, IShellEnabledDevice remoteDevice)171 public RemoteAndroidTestRunner(String packageName, IShellEnabledDevice remoteDevice) { 172 this(packageName, null, remoteDevice); 173 } 174 175 @Override getPackageName()176 public String getPackageName() { 177 return mPackageName; 178 } 179 180 @Override getRunnerName()181 public String getRunnerName() { 182 if (mRunnerName == null) { 183 return DEFAULT_RUNNER_NAME; 184 } 185 return mRunnerName; 186 } 187 188 /** Returns the complete instrumentation component path. */ getRunnerPath()189 protected String getRunnerPath() { 190 return getPackageName() + RUNNER_SEPARATOR + getRunnerName(); 191 } 192 193 @Override setClassName(String className)194 public void setClassName(String className) { 195 // The class name may contain the dollar sign, so needs to be quoted. 196 addInstrumentationArg(CLASS_ARG_NAME, "'" + className + "'"); 197 } 198 199 @Override setClassNames(String[] classNames)200 public void setClassNames(String[] classNames) { 201 StringBuilder classArgBuilder = new StringBuilder(); 202 for (int i = 0; i < classNames.length; i++) { 203 if (i != 0) { 204 classArgBuilder.append(CLASS_SEPARATOR); 205 } 206 classArgBuilder.append(classNames[i]); 207 } 208 setClassName(classArgBuilder.toString()); 209 } 210 211 @Override setMethodName(String className, String testName)212 public void setMethodName(String className, String testName) { 213 setClassName(className + METHOD_SEPARATOR + testName); 214 } 215 216 @Override setTestPackageName(String packageName)217 public void setTestPackageName(String packageName) { 218 addInstrumentationArg(PACKAGE_ARG_NAME, packageName); 219 } 220 221 @Override addInstrumentationArg(String name, String value)222 public void addInstrumentationArg(String name, String value) { 223 if (name == null || value == null) { 224 throw new IllegalArgumentException("name or value arguments cannot be null"); 225 } 226 mArgMap.put(name, value); 227 } 228 229 @Override removeInstrumentationArg(String name)230 public void removeInstrumentationArg(String name) { 231 if (name == null) { 232 throw new IllegalArgumentException("name argument cannot be null"); 233 } 234 mArgMap.remove(name); 235 } 236 237 @Override addBooleanArg(String name, boolean value)238 public void addBooleanArg(String name, boolean value) { 239 addInstrumentationArg(name, Boolean.toString(value)); 240 } 241 242 @Override setLogOnly(boolean logOnly)243 public void setLogOnly(boolean logOnly) { 244 addBooleanArg(LOG_ARG_NAME, logOnly); 245 } 246 247 @Override setDebug(boolean debug)248 public void setDebug(boolean debug) { 249 addBooleanArg(DEBUG_ARG_NAME, debug); 250 } 251 252 @Override setAdditionalTestOutputLocation(String additionalTestDataPath)253 public void setAdditionalTestOutputLocation(String additionalTestDataPath) { 254 addInstrumentationArg("additionalTestOutputDir", additionalTestDataPath); 255 } 256 257 @Override setCoverage(boolean coverage)258 public void setCoverage(boolean coverage) { 259 addBooleanArg(COVERAGE_ARG_NAME, coverage); 260 } 261 262 @Override setCoverageReportLocation(String reportPath)263 public void setCoverageReportLocation(String reportPath) { 264 addInstrumentationArg("coverageFile", reportPath); 265 } 266 267 @Override getCoverageOutputType()268 public CoverageOutput getCoverageOutputType() { 269 return CoverageOutput.FILE; 270 } 271 272 @Override setTestSize(TestSize size)273 public void setTestSize(TestSize size) { 274 addInstrumentationArg(SIZE_ARG_NAME, "small"); 275 } 276 277 @Override setTestCollection(boolean collect)278 public void setTestCollection(boolean collect) { 279 if (collect) { 280 // skip test execution 281 setLogOnly(true); 282 // force a timeout for test collection 283 setMaxTimeToOutputResponse(TEST_COLLECTION_TIMEOUT, TimeUnit.MILLISECONDS); 284 if (getApiLevel() < 16) { 285 // On older platforms, collecting tests can fail for large volume of tests. 286 // Insert a small delay between each test to prevent this 287 addInstrumentationArg(DELAY_MSEC_ARG_NAME, "15" /* ms */); 288 } 289 } else { 290 setLogOnly(false); 291 // restore timeout to its original set value 292 setMaxTimeToOutputResponse(mMaxTimeToOutputResponseMs, TimeUnit.MILLISECONDS); 293 if (getApiLevel() < 16) { 294 // remove delay 295 removeInstrumentationArg(DELAY_MSEC_ARG_NAME); 296 } 297 } 298 } 299 300 /** 301 * Attempts to retrieve the Api level of the Android device 302 * 303 * @return the api level or -1 if the communication with the device wasn't successful 304 */ getApiLevel()305 private int getApiLevel() { 306 try { 307 return Integer.parseInt( 308 mRemoteDevice.getSystemProperty(IDevice.PROP_BUILD_API_LEVEL).get()); 309 } catch (Exception e) { 310 return -1; 311 } 312 } 313 314 @Deprecated 315 @Override setMaxtimeToOutputResponse(int maxTimeToOutputResponse)316 public void setMaxtimeToOutputResponse(int maxTimeToOutputResponse) { 317 setMaxTimeToOutputResponse(maxTimeToOutputResponse, TimeUnit.MILLISECONDS); 318 } 319 320 @Override setMaxTimeToOutputResponse(long maxTimeToOutputResponse, TimeUnit maxTimeUnits)321 public void setMaxTimeToOutputResponse(long maxTimeToOutputResponse, TimeUnit maxTimeUnits) { 322 mMaxTimeToOutputResponseMs = maxTimeUnits.toMillis(maxTimeToOutputResponse); 323 } 324 325 @Override setMaxTimeout(long maxTimeout, TimeUnit maxTimeUnits)326 public void setMaxTimeout(long maxTimeout, TimeUnit maxTimeUnits) { 327 mMaxTimeoutMs = maxTimeUnits.toMillis(maxTimeout); 328 } 329 330 @Override setRunName(String runName)331 public void setRunName(String runName) { 332 mRunName = runName; 333 } 334 335 @Override run(ITestRunListener... listeners)336 public void run(ITestRunListener... listeners) 337 throws TimeoutException, 338 AdbCommandRejectedException, 339 ShellCommandUnresponsiveException, 340 IOException { 341 run(Arrays.asList(listeners)); 342 } 343 344 @Override run(Collection<ITestRunListener> listeners)345 public void run(Collection<ITestRunListener> listeners) 346 throws TimeoutException, 347 AdbCommandRejectedException, 348 ShellCommandUnresponsiveException, 349 IOException { 350 final String runCaseCommandStr = getAmInstrumentCommand(); 351 CLog.i("Running %1$s on %2$s", runCaseCommandStr, mRemoteDevice.getName()); 352 String runName = mRunName == null ? mPackageName : mRunName; 353 mParser = createParser(runName, listeners); 354 try { 355 mRemoteDevice.executeShellCommand( 356 runCaseCommandStr, 357 mParser, 358 mMaxTimeoutMs, 359 mMaxTimeToOutputResponseMs, 360 TimeUnit.MILLISECONDS); 361 } catch (IOException e) { 362 Log.w( 363 LOG_TAG, 364 String.format( 365 "IOException %1$s when running tests %2$s on %3$s", 366 e.toString(), getPackageName(), mRemoteDevice.getName())); 367 // rely on parser to communicate results to listeners 368 mParser.handleTestRunFailed(e.toString()); 369 throw e; 370 } catch (ShellCommandUnresponsiveException e) { 371 Log.w( 372 LOG_TAG, 373 String.format( 374 "ShellCommandUnresponsiveException %1$s when running tests %2$s on" 375 + " %3$s", 376 e.toString(), getPackageName(), mRemoteDevice.getName())); 377 mParser.handleTestRunFailed( 378 String.format( 379 "Failed to receive adb shell test output within %1$d ms. Test may have " 380 + "timed out, or adb connection to device became unresponsive", 381 mMaxTimeToOutputResponseMs)); 382 throw e; 383 } catch (TimeoutException e) { 384 Log.w( 385 LOG_TAG, 386 String.format( 387 "TimeoutException when running tests %1$s on %2$s", 388 getPackageName(), mRemoteDevice.getName())); 389 mParser.handleTestRunFailed(e.toString()); 390 throw e; 391 } catch (AdbCommandRejectedException e) { 392 Log.w( 393 LOG_TAG, 394 String.format( 395 "AdbCommandRejectedException %1$s when running tests %2$s on %3$s", 396 e.toString(), getPackageName(), mRemoteDevice.getName())); 397 mParser.handleTestRunFailed(e.toString()); 398 throw e; 399 } 400 } 401 402 /** 403 * Create the {@link InstrumentationResultParser} that will be used to parse the instrumentation 404 * output. 405 * 406 * @param runName The name of the run to use. 407 * @param listeners The listeners where to report the results. 408 * @return An instance of {@link InstrumentationResultParser}. 409 */ 410 @NonNull createParser( @onNull String runName, @NonNull Collection<ITestRunListener> listeners)411 public IInstrumentationResultParser createParser( 412 @NonNull String runName, @NonNull Collection<ITestRunListener> listeners) { 413 return mStatusReporterMode.createInstrumentationResultParser(runName, listeners); 414 } 415 416 @NonNull getAmInstrumentCommand()417 public String getAmInstrumentCommand() { 418 return String.format( 419 "am instrument -w %1$s %2$s %3$s %4$s", 420 mStatusReporterMode.getAmInstrumentCommandArg(), 421 getRunOptions(), 422 getArgsCommand(), 423 getRunnerPath()); 424 } 425 426 /** Returns options for the am instrument command. */ 427 @NonNull getRunOptions()428 public String getRunOptions() { 429 return mRunOptions; 430 } 431 432 /** 433 * Sets options for the am instrument command. See com/android/commands/am/Am.java for full list 434 * of options. 435 */ setRunOptions(@onNull String options)436 public void setRunOptions(@NonNull String options) { 437 mRunOptions = options; 438 } 439 440 @Override cancel()441 public void cancel() { 442 if (mParser != null) { 443 mParser.cancel(); 444 } 445 } 446 447 /** 448 * Returns the full instrumentation command line syntax for the provided instrumentation 449 * arguments. Returns an empty string if no arguments were specified. 450 */ getArgsCommand()451 protected String getArgsCommand() { 452 StringBuilder commandBuilder = new StringBuilder(); 453 for (Entry<String, String> argPair : mArgMap.entrySet()) { 454 final String argCmd = 455 String.format(" -e %1$s %2$s", argPair.getKey(), argPair.getValue()); 456 commandBuilder.append(argCmd); 457 } 458 return commandBuilder.toString(); 459 } 460 } 461