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