1 /*
2  * Copyright (C) 2016 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 android.support.test.aupt;
18 
19 import android.app.Service;
20 import android.content.Context;
21 import android.content.ContextWrapper;
22 import android.content.Intent;
23 import android.content.IntentFilter;
24 import android.os.Bundle;
25 import android.os.Environment;
26 import android.os.IBinder;
27 import android.os.SystemClock;
28 import android.support.test.uiautomator.UiDevice;
29 import android.test.AndroidTestRunner;
30 import android.test.InstrumentationTestCase;
31 import android.test.InstrumentationTestRunner;
32 import android.util.Log;
33 
34 import androidx.test.InstrumentationRegistry;
35 
36 import junit.framework.AssertionFailedError;
37 import junit.framework.Test;
38 import junit.framework.TestCase;
39 import junit.framework.TestListener;
40 import junit.framework.TestResult;
41 import junit.framework.TestSuite;
42 
43 import java.io.BufferedReader;
44 import java.io.File;
45 import java.io.FileInputStream;
46 import java.io.IOException;
47 import java.io.InputStreamReader;
48 import java.text.SimpleDateFormat;
49 import java.util.ArrayList;
50 import java.util.Arrays;
51 import java.util.Comparator;
52 import java.util.Date;
53 import java.util.HashMap;
54 import java.util.List;
55 import java.util.Map;
56 import java.util.Random;
57 import java.util.concurrent.TimeUnit;
58 import java.util.concurrent.TimeoutException;
59 
60 /**
61  * Ultra-fancy TestRunner to use when running AUPT: supports
62  *
63  * - Picking up tests from dexed JARs
64  * - Running tests for multiple iterations or in a custom order
65  * - Terminating tests after UI errors, timeouts, or when dependent processes die
66  * - Injecting additional information into custom TestCase subclasses
67  * - Passing through continuous metric-collection to a DataCollector instance
68  * - Collecting bugreports and heapdumps
69  *
70  */
71 public class AuptTestRunner extends InstrumentationTestRunner {
72     /* Constants */
73     private static final String LOG_TAG = AuptTestRunner.class.getSimpleName();
74     private static final Long ANR_DELAY = 30000L;
75     private static final Long DEFAULT_SUITE_TIMEOUT = 0L;
76     private static final Long DEFAULT_TEST_TIMEOUT = 10L;
77     private static final SimpleDateFormat SCREENSHOT_DATE_FORMAT =
78         new SimpleDateFormat("dd-mm-yy:HH:mm:ss:SSS");
79 
80     /* Keep a pointer to our argument bundle around for testing */
81     private Bundle mParams;
82 
83     /* Primitive Parameters */
84     private boolean mDeleteOldFiles;
85     private long mFileRetainCount;
86     private boolean mGenerateAnr;
87     private boolean mRecordMeminfo;
88     private long mIterations;
89     private long mSeed;
90 
91     /* Dumpheap Parameters */
92     private boolean mDumpheapEnabled;
93     private long mDumpheapInterval;
94     private long mDumpheapThreshold;
95     private long mMaxDumpheaps;
96 
97     /* String Parameters */
98     private List<String> mJars = new ArrayList<>();
99     private List<String> mMemoryTrackedProcesses = new ArrayList<>();
100     private List<String> mFinishCommands;
101 
102     /* Other Parameters */
103     private File mResultsDirectory;
104 
105     /* Helpers */
106     private Scheduler mScheduler;
107     private DataCollector mDataCollector;
108     private DexTestRunner mRunner;
109 
110     /* Logging */
111     private ProcessStatusTracker mProcessTracker;
112     private List<MemHealthRecord> mMemHealthRecords = new ArrayList<>();
113     private Map<String, Long> mDumpheapCount = new HashMap<>();
114     private Map<String, Long> mLastDumpheap = new HashMap<>();
115 
116     /* Test Initialization */
117     @Override
onCreate(Bundle params)118     public void onCreate(Bundle params) {
119         // Support the newer AndroidX test initialization.
120         InstrumentationRegistry.registerInstance(this, params);
121 
122         mParams = params;
123 
124         // Parse out primitive parameters
125         mIterations = parseLongParam("iterations", 1);
126         mRecordMeminfo = parseBoolParam("record_meminfo", false);
127         mDumpheapEnabled = parseBoolParam("enableDumpheap", false);
128         mDumpheapThreshold = parseLongParam("dumpheapThreshold", 200 * 1024 * 1024);
129         mDumpheapInterval = parseLongParam("dumpheapInterval", 60 * 60 * 1000);
130         mMaxDumpheaps = parseLongParam("maxDumpheaps", 5);
131         mSeed = parseLongParam("seed", new Random().nextLong());
132 
133         // Option: -e finishCommand 'a;b;c;d'
134         String finishCommandArg = parseStringParam("finishCommand", null);
135         mFinishCommands =
136                 finishCommandArg == null
137                         ? Arrays.<String>asList()
138                         : Arrays.asList(finishCommandArg.split("\\s*;\\s*"));
139 
140         // Option: -e shuffle true
141         mScheduler = parseBoolParam("shuffle", false)
142                 ? Scheduler.shuffled(new Random(mSeed), mIterations)
143                 : Scheduler.sequential(mIterations);
144 
145         // Option: -e jars aupt-app-tests.jar:...
146         mJars.addAll(DexTestRunner.parseDexedJarPaths(parseStringParam("jars", "")));
147 
148         // Option: -e trackMemory com.pkg1,com.pkg2,...
149         String memoryTrackedProcesses = parseStringParam("trackMemory", null);
150 
151         if (memoryTrackedProcesses != null) {
152             mMemoryTrackedProcesses = Arrays.asList(memoryTrackedProcesses.split(","));
153         } else {
154             try {
155                 // Deprecated approach: get tracked processes from a file.
156                 String trackMemoryFileName =
157                         Environment.getExternalStorageDirectory() + "/track_memory.txt";
158 
159                 BufferedReader reader = new BufferedReader(new InputStreamReader(
160                         new FileInputStream(new File(trackMemoryFileName))));
161 
162                 mMemoryTrackedProcesses = Arrays.asList(reader.readLine().split(","));
163                 reader.close();
164             } catch (NullPointerException | IOException ex) {
165                 mMemoryTrackedProcesses = Arrays.asList();
166             }
167         }
168 
169         // Option: -e detectKill com.pkg1,...,com.pkg8
170         String processes = parseStringParam("detectKill", null);
171 
172         if (processes != null) {
173             mProcessTracker = new ProcessStatusTracker(processes.split(","));
174         } else {
175             mProcessTracker = new ProcessStatusTracker(new String[] {});
176         }
177 
178         // Option: -e outputLocation aupt_results
179         mResultsDirectory = new File(Environment.getExternalStorageDirectory(),
180                 parseStringParam("outputLocation", "aupt_results"));
181         if (!mResultsDirectory.exists() && !mResultsDirectory.mkdirs()) {
182             Log.w(LOG_TAG, "Could not find or create output directory " + mResultsDirectory);
183         }
184 
185         // Option: -e fileRetainCount 1
186         mFileRetainCount = parseLongParam("fileRetainCount", -1);
187         mDeleteOldFiles = (mFileRetainCount != -1);
188 
189         // Primary logging infrastructure
190         mDataCollector = new DataCollector(
191                 TimeUnit.MINUTES.toMillis(parseLongParam("bugreportInterval", 0)),
192                 TimeUnit.MINUTES.toMillis(parseLongParam("jankInterval", 0)),
193                 TimeUnit.MINUTES.toMillis(parseLongParam("meminfoInterval", 0)),
194                 TimeUnit.MINUTES.toMillis(parseLongParam("cpuinfoInterval", 0)),
195                 TimeUnit.MINUTES.toMillis(parseLongParam("fragmentationInterval", 0)),
196                 TimeUnit.MINUTES.toMillis(parseLongParam("ionInterval", 0)),
197                 TimeUnit.MINUTES.toMillis(parseLongParam("pagetypeinfoInterval", 0)),
198                 TimeUnit.MINUTES.toMillis(parseLongParam("traceInterval", 0)),
199                 TimeUnit.MINUTES.toMillis(parseLongParam("bugreportzInterval", 0)),
200                 mResultsDirectory, this);
201 
202         // Make our TestRunner and make sure we injectInstrumentation.
203         mRunner = new DexTestRunner(this, mScheduler, mJars,
204                 TimeUnit.MINUTES.toMillis(parseLongParam("testCaseTimeout", DEFAULT_TEST_TIMEOUT)),
205                 TimeUnit.MINUTES.toMillis(parseLongParam("suiteTimeout", DEFAULT_SUITE_TIMEOUT))) {
206             @Override
207             public void runTest(TestResult result) {
208                 for (TestCase test: mTestCases) {
209                     injectInstrumentation(test);
210                 }
211 
212                 try {
213                     super.runTest(result);
214                 } finally {
215                     mDataCollector.stop();
216                 }
217             }
218         };
219 
220         // Aupt's TestListeners
221         mRunner.addTestListener(new PeriodicHeapDumper());
222         mRunner.addTestListener(new MemHealthRecorder());
223         mRunner.addTestListener(new DcimCleaner());
224         mRunner.addTestListener(new PidChecker());
225         mRunner.addTestListener(new TimeoutStackDumper());
226         mRunner.addTestListener(new MemInfoDumper());
227         mRunner.addTestListener(new FinishCommandRunner());
228         mRunner.addTestListenerIf(parseBoolParam("generateANR", false), new ANRTrigger());
229         mRunner.addTestListenerIf(parseBoolParam("quitOnError", false), new QuitOnErrorListener());
230         mRunner.addTestListenerIf(parseBoolParam("checkBattery", false), new BatteryChecker());
231         mRunner.addTestListenerIf(parseBoolParam("screenshots", false), new Screenshotter());
232 
233         // Start our loggers
234         mDataCollector.start();
235 
236         // Start the test
237         super.onCreate(params);
238     }
239 
240     /* Option-parsing helpers */
241 
parseLongParam(String key, long alternative)242     private long parseLongParam(String key, long alternative) throws NumberFormatException {
243         if (mParams.containsKey(key)) {
244             return Long.parseLong(mParams.getString(key));
245         } else {
246             return alternative;
247         }
248     }
249 
parseBoolParam(String key, boolean alternative)250     private boolean parseBoolParam(String key, boolean alternative)
251             throws NumberFormatException {
252         if (mParams.containsKey(key)) {
253             return Boolean.parseBoolean(mParams.getString(key));
254         } else {
255             return alternative;
256         }
257     }
258 
parseStringParam(String key, String alternative)259     private String parseStringParam(String key, String alternative) {
260         if (mParams.containsKey(key)) {
261             return mParams.getString(key);
262         } else {
263             return alternative;
264         }
265     }
266 
267     /* Utility methods */
268 
269     /**
270      * Injects instrumentation into InstrumentationTestCase and AuptTestCase instances
271      */
injectInstrumentation(Test test)272     private void injectInstrumentation(Test test) {
273         if (InstrumentationTestCase.class.isAssignableFrom(test.getClass())) {
274             InstrumentationTestCase instrTest = (InstrumentationTestCase) test;
275 
276             instrTest.injectInstrumentation(AuptTestRunner.this);
277         }
278     }
279 
280     /* Passthrough to our DexTestRunner */
281     @Override
getAndroidTestRunner()282     protected AndroidTestRunner getAndroidTestRunner() {
283         return mRunner;
284     }
285 
286     @Override
getTargetContext()287     public Context getTargetContext() {
288         return new ContextWrapper(super.getTargetContext()) {
289             @Override
290             public ClassLoader getClassLoader() {
291                 if(mRunner != null) {
292                     return mRunner.getDexClassLoader();
293                 } else {
294                     throw new RuntimeException("DexTestRunner not initialized!");
295                 }
296             }
297         };
298     }
299 
300     /**
301      * A simple abstract instantiation of TestListener
302      *
303      * Primarily meant to work around Java 7's lack of interface-default methods.
304      */
305     abstract static class AuptListener implements TestListener {
306         /** Called when a test throws an exception. */
307         public void addError(Test test, Throwable t) {}
308 
309         /** Called when a test fails. */
310         public void addFailure(Test test, AssertionFailedError t) {}
311 
312         /** Called whenever a test ends. */
313         public void endTest(Test test) {}
314 
315         /** Called whenever a test begins. */
316         public void startTest(Test test) {}
317     }
318 
319     /**
320      * Periodically Heap-dump to assist with memory-leaks.
321      */
322     private class PeriodicHeapDumper extends AuptListener {
323         private Thread mHeapDumpThread;
324 
325         private class InternalHeapDumper implements Runnable {
326             private void recordDumpheap(String proc, long pss) throws IOException {
327                 if (!mDumpheapEnabled) {
328                     return;
329                 }
330                 Long count = mDumpheapCount.get(proc);
331                 if (count == null) {
332                     count = 0L;
333                 }
334                 Long lastDumpheap = mLastDumpheap.get(proc);
335                 if (lastDumpheap == null) {
336                     lastDumpheap = 0L;
337                 }
338                 long currentTime = SystemClock.uptimeMillis();
339                 if (pss > mDumpheapThreshold && count < mMaxDumpheaps &&
340                         currentTime - lastDumpheap > mDumpheapInterval) {
341                     recordDumpheap(proc);
342                     mDumpheapCount.put(proc, count + 1);
343                     mLastDumpheap.put(proc, currentTime);
344                 }
345             }
346 
347             private void recordDumpheap(String proc) throws IOException {
348                 long count = mDumpheapCount.get(proc);
349 
350                 String filename = String.format("dumpheap-%s-%d", proc, count);
351                 String tempFilename = "/data/local/tmp/" + filename;
352                 String finalFilename = mResultsDirectory + "/" + filename;
353 
354                 AuptTestRunner.this.getUiAutomation().executeShellCommand(
355                         String.format("am dumpheap %s %s", proc, tempFilename));
356 
357                 SystemClock.sleep(3000);
358 
359                 AuptTestRunner.this.getUiAutomation().executeShellCommand(
360                         String.format("cp %s %s", tempFilename, finalFilename));
361             }
362 
363             public void run() {
364                 try {
365                     while (true) {
366                         Thread.sleep(mDumpheapInterval);
367 
368                         for(String proc : mMemoryTrackedProcesses) {
369                             recordDumpheap(proc);
370                         }
371                     }
372                 } catch (InterruptedException iex) {
373                 } catch (IOException ioex) {
374                     Log.e(LOG_TAG, "Failed to write heap dump!", ioex);
375                 }
376             }
377         }
378 
379         @Override
380         public void startTest(Test test) {
381             mHeapDumpThread = new Thread(new InternalHeapDumper());
382             mHeapDumpThread.start();
383         }
384 
385         @Override
386         public void endTest(Test test) {
387             try {
388                 mHeapDumpThread.interrupt();
389                 mHeapDumpThread.join();
390             } catch (InterruptedException iex) { }
391         }
392     }
393 
394     /**
395      * Dump memory info on test start/stop
396      */
397     private class MemInfoDumper extends AuptListener {
398         private void dumpMemInfo() {
399             if (mRecordMeminfo) {
400                 FilesystemUtil.dumpMeminfo(AuptTestRunner.this, "MemInfoDumper");
401             }
402         }
403 
404         @Override
405         public void startTest(Test test) {
406             dumpMemInfo();
407         }
408 
409         @Override
410         public void endTest(Test test) {
411             dumpMemInfo();
412         }
413     }
414 
415     /**
416      * Record all of our MemHealthRecords
417      */
418     private class MemHealthRecorder extends AuptListener {
419         @Override
420         public void startTest(Test test) {
421             recordMemHealth();
422         }
423 
424         @Override
425         public void endTest(Test test) {
426             recordMemHealth();
427 
428             try {
429                 MemHealthRecord.saveVerbose(mMemHealthRecords,
430                         new File(mResultsDirectory, "memory-health.txt").getPath());
431                 MemHealthRecord.saveCsv(mMemHealthRecords,
432                         new File(mResultsDirectory, "memory-health-details.txt").getPath());
433 
434                 mMemHealthRecords.clear();
435             } catch (IOException ioex) {
436                 Log.e(LOG_TAG, "Error writing MemHealthRecords", ioex);
437             }
438         }
439 
440         private void recordMemHealth() {
441             try {
442                 mMemHealthRecords.addAll(MemHealthRecord.get(
443                       AuptTestRunner.this,
444                       mMemoryTrackedProcesses,
445                       System.currentTimeMillis(),
446                       getForegroundProcs()));
447             } catch (IOException ioex) {
448                 Log.e(LOG_TAG, "Error collecting MemHealthRecords", ioex);
449             }
450         }
451 
452         private List<String> getForegroundProcs() {
453             List<String> foregroundProcs = new ArrayList<String>();
454             try {
455                 String compactMeminfo = MemHealthRecord.getProcessOutput(AuptTestRunner.this,
456                         "dumpsys meminfo -c");
457 
458                 for (String line : compactMeminfo.split("\\r?\\n")) {
459                     if (line.contains("proc,fore")) {
460                         String proc = line.split(",")[2];
461                         foregroundProcs.add(proc);
462                     }
463                 }
464             } catch (IOException e) {
465                 Log.e(LOG_TAG, "Error while getting foreground process", e);
466             } finally {
467                 return foregroundProcs;
468             }
469         }
470     }
471 
472     /**
473      * Kills application and dumps UI Hierarchy on test error
474      */
475     private class QuitOnErrorListener extends AuptListener {
476         @Override
477         public void addError(Test test, Throwable t) {
478             Log.e(LOG_TAG, "Caught exception from a test", t);
479 
480             if ((t instanceof AuptTerminator)) {
481                 throw (AuptTerminator)t;
482             } else {
483 
484                 // Check if our exception is caused by process dependency
485                 if (test instanceof AuptTestCase) {
486                     mProcessTracker.setUiAutomation(getUiAutomation());
487                     mProcessTracker.verifyRunningProcess();
488                 }
489 
490                 // If that didn't throw, then dump our hierarchy
491                 Log.v(LOG_TAG, "Dumping UI hierarchy");
492                 try {
493                     UiDevice.getInstance(AuptTestRunner.this).dumpWindowHierarchy(
494                             new File("/data/local/tmp/error_dump.xml"));
495                 } catch (IOException e) {
496                     Log.w(LOG_TAG, "Failed to create UI hierarchy dump for UI error", e);
497                 }
498             }
499 
500             // Quit on an error
501             throw new AuptTerminator(t.getMessage(), t);
502         }
503 
504         @Override
505         public void addFailure(Test test, AssertionFailedError t) {
506             // Quit on an error
507             throw new AuptTerminator(t.getMessage(), t);
508         }
509     }
510 
511     /**
512      * Makes sure the processes this test requires are all alive
513      */
514     private class PidChecker extends AuptListener {
515         @Override
516         public void startTest(Test test) {
517             mProcessTracker.setUiAutomation(getUiAutomation());
518             mProcessTracker.verifyRunningProcess();
519         }
520 
521         @Override
522         public void endTest(Test test) {
523             mProcessTracker.verifyRunningProcess();
524         }
525     }
526 
527     /**
528      * Initialization for tests that touch the camera
529      */
530     private class DcimCleaner extends AuptListener {
531         @Override
532         public void startTest(Test test) {
533             if (!mDeleteOldFiles) {
534                 return;
535             }
536 
537             File dcimFolder = new File(Environment.getExternalStorageDirectory(), "DCIM");
538             File cameraFolder = new File(dcimFolder, "Camera");
539 
540             if (dcimFolder.exists()) {
541                 if (cameraFolder.exists()) {
542                     File[] allMediaFiles = cameraFolder.listFiles();
543                     Arrays.sort(allMediaFiles, new Comparator<File>() {
544                         public int compare(File f1, File f2) {
545                             return Long.valueOf(f1.lastModified()).compareTo(f2.lastModified());
546                         }
547                     });
548                     for (int i = 0; i < allMediaFiles.length - mFileRetainCount; i++) {
549                         allMediaFiles[i].delete();
550                     }
551                 } else {
552                     Log.w(LOG_TAG, "No Camera folder found to delete from.");
553                 }
554             } else {
555                 Log.w(LOG_TAG, "No DCIM folder found to delete from.");
556             }
557         }
558     }
559 
560     /**
561      * Makes sure the battery hasn't died before and after each test.
562      */
563     private class BatteryChecker extends AuptListener {
564         private static final double BATTERY_THRESHOLD = 0.05;
565 
566         private void checkBattery() {
567             Intent batteryIntent = getContext().registerReceiver(null,
568                     new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
569             int rawLevel = batteryIntent.getIntExtra("level", -1);
570             int scale = batteryIntent.getIntExtra("scale", -1);
571 
572             if (rawLevel < 0 || scale <= 0) {
573                 return;
574             }
575 
576             double level = (double) rawLevel / (double) scale;
577             if (level < BATTERY_THRESHOLD) {
578                 throw new AuptTerminator(String.format("Current battery level %f lower than %f",
579                         level,
580                         BATTERY_THRESHOLD));
581             }
582         }
583 
584         @Override
585         public void startTest(Test test) {
586             checkBattery();
587         }
588     }
589 
590     /**
591      * Generates heap dumps when a test times out
592      */
593     private class TimeoutStackDumper extends AuptListener {
594         private String getStackTraces() {
595             StringBuilder sb = new StringBuilder();
596             Map<Thread, StackTraceElement[]> stacks = Thread.getAllStackTraces();
597             for (Thread t : stacks.keySet()) {
598                 sb.append(t.toString()).append('\n');
599                 for (StackTraceElement ste : t.getStackTrace()) {
600                     sb.append("\tat ").append(ste.toString()).append('\n');
601                 }
602                 sb.append('\n');
603             }
604             return sb.toString();
605         }
606 
607         @Override
608         public void addError(Test test, Throwable t) {
609             if (t instanceof TimeoutException) {
610                 Log.d("THREAD_DUMP", getStackTraces());
611             }
612         }
613     }
614 
615     /** Generates ANRs when a test takes too long. */
616     private class ANRTrigger extends AuptListener {
617         @Override
618         public void addError(Test test, Throwable t) {
619             if (t instanceof TimeoutException) {
620                 Context ctx = getTargetContext();
621                 Log.d(LOG_TAG, "About to induce artificial ANR for debugging");
622                 ctx.startService(new Intent(ctx, AnrGenerator.class));
623 
624                 try {
625                     Thread.sleep(ANR_DELAY);
626                 } catch (InterruptedException e) {
627                     throw new RuntimeException("Interrupted while waiting for AnrGenerator...");
628                 }
629             }
630         }
631 
632         /** Service that hangs to trigger an ANR. */
633         private class AnrGenerator extends Service {
634             @Override
635             public IBinder onBind(Intent intent) {
636                 return null;
637             }
638 
639             @Override
640             public int onStartCommand(Intent intent, int flags, int id) {
641                 Log.i(LOG_TAG, "in service start -- about to hang");
642                 try {
643                     Thread.sleep(ANR_DELAY);
644                 } catch (InterruptedException e) {
645                     Log.wtf(LOG_TAG, e);
646                 }
647                 Log.i(LOG_TAG, "service hang finished -- stopping and returning");
648                 stopSelf();
649                 return START_NOT_STICKY;
650             }
651         }
652     }
653 
654     /**
655      * Collect a screenshot on test failure.
656      */
657     private class Screenshotter extends AuptListener {
658         private void collectScreenshot(Test test, String suffix) {
659             UiDevice device = UiDevice.getInstance(AuptTestRunner.this);
660 
661             if (device == null) {
662                 Log.w(LOG_TAG, "Couldn't collect screenshot on test failure");
663                 return;
664             }
665 
666             String testName =
667                     test instanceof TestCase
668                     ? ((TestCase) test).getName()
669                     : (test instanceof TestSuite ? ((TestSuite) test).getName() : test.toString());
670 
671             String fileName =
672                     mResultsDirectory.getPath()
673                             + "/" + testName.replace('.', '_')
674                             + suffix + ".png";
675 
676             device.takeScreenshot(new File(fileName));
677         }
678 
679         @Override
680         public void addError(Test test, Throwable t) {
681             collectScreenshot(test,
682                     "_failure_screenshot_" + SCREENSHOT_DATE_FORMAT.format(new Date()));
683         }
684 
685         @Override
686         public void addFailure(Test test, AssertionFailedError t) {
687             collectScreenshot(test,
688                     "_failure_screenshot_" + SCREENSHOT_DATE_FORMAT.format(new Date()));
689         }
690     }
691 
692     /** Runs a command when a test finishes. */
693     private class FinishCommandRunner extends AuptListener {
694         @Override
695         public void endTest(Test test) {
696             for (String command : mFinishCommands) {
697                 AuptTestRunner.this.getUiAutomation().executeShellCommand(command);
698             }
699         }
700     }
701 }
702