Merge "Create a Feature Flag to allow enabling early device release"
diff --git a/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java b/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java
index 1929e6f..aedd63f 100644
--- a/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java
+++ b/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java
@@ -24,6 +24,7 @@
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.FailureDescription;
 import com.android.tradefed.result.ITestInvocationListener;
 import com.android.tradefed.result.InputStreamSource;
 import com.android.tradefed.result.LogDataType;
@@ -253,6 +254,11 @@
     }
 
     @Override
+    public final void testRunFailed(FailureDescription failure) {
+        mForwarder.testRunFailed(failure);
+    }
+
+    @Override
     public final void testRunStopped(long elapsedTime) {
         mForwarder.testRunStopped(elapsedTime);
     }
@@ -305,6 +311,20 @@
     }
 
     @Override
+    public final void testFailed(TestDescription test, FailureDescription failure) {
+        mSkipTestCase = shouldSkip(test);
+        if (!mSkipTestCase) {
+            try {
+                onTestFail(mTestData, test);
+            } catch (Throwable t) {
+                // Prevent exception from messing up the status reporting.
+                CLog.e(t);
+            }
+        }
+        mForwarder.testFailed(test, failure);
+    }
+
+    @Override
     public final void testEnded(TestDescription test, HashMap<String, Metric> testMetrics) {
         testEnded(test, System.currentTimeMillis(), testMetrics);
     }
diff --git a/src/com/android/tradefed/invoker/ShardListener.java b/src/com/android/tradefed/invoker/ShardListener.java
index 7cfc0d1..6292a62 100644
--- a/src/com/android/tradefed/invoker/ShardListener.java
+++ b/src/com/android/tradefed/invoker/ShardListener.java
@@ -20,6 +20,7 @@
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
 import com.android.tradefed.result.CollectingTestListener;
+import com.android.tradefed.result.FailureDescription;
 import com.android.tradefed.result.ILogSaverListener;
 import com.android.tradefed.result.ITestInvocationListener;
 import com.android.tradefed.result.InputStreamSource;
@@ -152,6 +153,17 @@
 
     /** {@inheritDoc} */
     @Override
+    public void testRunFailed(FailureDescription failure) {
+        super.testRunFailed(failure);
+        CLog.logAndDisplay(
+                LogLevel.ERROR,
+                "FAILED: %s failed with message: %s",
+                getCurrentRunResults().getName(),
+                failure.toString());
+    }
+
+    /** {@inheritDoc} */
+    @Override
     public void testRunEnded(long elapsedTime, HashMap<String, Metric> runMetrics) {
         super.testRunEnded(elapsedTime, runMetrics);
         CLog.logAndDisplay(
diff --git a/src/com/android/tradefed/postprocessor/BasePostProcessor.java b/src/com/android/tradefed/postprocessor/BasePostProcessor.java
index 983d435..727cafc 100644
--- a/src/com/android/tradefed/postprocessor/BasePostProcessor.java
+++ b/src/com/android/tradefed/postprocessor/BasePostProcessor.java
@@ -20,6 +20,7 @@
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.metrics.proto.MetricMeasurement.DataType;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.FailureDescription;
 import com.android.tradefed.result.ILogSaver;
 import com.android.tradefed.result.ILogSaverListener;
 import com.android.tradefed.result.ITestInvocationListener;
@@ -142,6 +143,11 @@
     }
 
     @Override
+    public final void testRunFailed(FailureDescription failure) {
+        mForwarder.testRunFailed(failure);
+    }
+
+    @Override
     public final void testRunStopped(long elapsedTime) {
         mForwarder.testRunStopped(elapsedTime);
     }
@@ -200,6 +206,11 @@
     }
 
     @Override
+    public final void testFailed(TestDescription test, FailureDescription failure) {
+        mForwarder.testFailed(test, failure);
+    }
+
+    @Override
     public final void testEnded(TestDescription test, Map<String, String> testMetrics) {
         testEnded(test, System.currentTimeMillis(), testMetrics);
     }
diff --git a/src/com/android/tradefed/result/proto/ProtoResultReporter.java b/src/com/android/tradefed/result/proto/ProtoResultReporter.java
index 7459b34..c66527d 100644
--- a/src/com/android/tradefed/result/proto/ProtoResultReporter.java
+++ b/src/com/android/tradefed/result/proto/ProtoResultReporter.java
@@ -20,6 +20,7 @@
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.FailureDescription;
 import com.android.tradefed.result.ILogSaverListener;
 import com.android.tradefed.result.ITestInvocationListener;
 import com.android.tradefed.result.LogFile;
@@ -293,6 +294,26 @@
     }
 
     @Override
+    public final void testRunFailed(FailureDescription failure) {
+        TestRecord.Builder current = mLatestChild.peek();
+        DebugInfo.Builder debugBuilder = DebugInfo.newBuilder();
+        debugBuilder.setErrorMessage(failure.toString());
+        if (failure.getFailureStatus() != null) {
+            debugBuilder.setFailureStatus(failure.getFailureStatus());
+        }
+        if (TestStatus.UNKNOWN.equals(current.getStatus())) {
+            current.setDebugInfo(debugBuilder.build());
+        } else {
+            // We are in a test case and we need the run parent.
+            TestRecord.Builder test = mLatestChild.pop();
+            TestRecord.Builder run = mLatestChild.peek();
+            run.setDebugInfo(debugBuilder.build());
+            // Re-add the test
+            mLatestChild.add(test);
+        }
+    }
+
+    @Override
     public final void testRunEnded(long elapsedTimeMillis, HashMap<String, Metric> runMetrics) {
         TestRecord.Builder runBuilder = mLatestChild.pop();
         long startTime = timeStampToMillis(runBuilder.getStartTime());
@@ -373,6 +394,21 @@
     }
 
     @Override
+    public final void testFailed(TestDescription test, FailureDescription failure) {
+        TestRecord.Builder testBuilder = mLatestChild.peek();
+
+        testBuilder.setStatus(TestStatus.FAIL);
+        DebugInfo.Builder debugBuilder = DebugInfo.newBuilder();
+        // FIXME: extract the error message from the trace
+        debugBuilder.setErrorMessage(failure.toString());
+        debugBuilder.setTrace(failure.toString());
+        if (failure.getFailureStatus() != null) {
+            debugBuilder.setFailureStatus(failure.getFailureStatus());
+        }
+        testBuilder.setDebugInfo(debugBuilder.build());
+    }
+
+    @Override
     public final void testIgnored(TestDescription test) {
         TestRecord.Builder testBuilder = mLatestChild.peek();
         testBuilder.setStatus(TestStatus.IGNORED);
diff --git a/src/com/android/tradefed/result/suite/FormattedGeneratorReporter.java b/src/com/android/tradefed/result/suite/FormattedGeneratorReporter.java
index bedd4a4..dbd521f 100644
--- a/src/com/android/tradefed/result/suite/FormattedGeneratorReporter.java
+++ b/src/com/android/tradefed/result/suite/FormattedGeneratorReporter.java
@@ -15,13 +15,28 @@
  */
 package com.android.tradefed.result.suite;
 
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IConfigurationReceiver;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.targetprep.TargetSetupError;
+import com.android.tradefed.testtype.IRemoteTest;
+import com.android.tradefed.testtype.suite.retry.ResultsPlayer;
 
 /** Reporter that allows to generate reports in a particular format. TODO: fix logged file */
-public abstract class FormattedGeneratorReporter extends SuiteResultReporter {
+public abstract class FormattedGeneratorReporter extends SuiteResultReporter
+        implements IConfigurationReceiver {
 
     private Throwable mTestHarnessError = null;
+    private IConfiguration mConfiguration;
+
+    @Override
+    public final void setConfiguration(IConfiguration configuration) {
+        mConfiguration = configuration;
+    }
+
+    public final IConfiguration getConfiguration() {
+        return mConfiguration;
+    }
 
     /** {@inheritDoc} */
     @Override
@@ -31,11 +46,19 @@
 
         // If invocation failed due to a test harness error and did not see any tests
         if (mTestHarnessError != null) {
-            CLog.e(
-                    "Invocation failed and we couldn't ensure results are consistent, skip "
-                            + "generating the formatted report due to:");
-            CLog.e(mTestHarnessError);
-            return;
+            Boolean replaySuccess = null;
+            for (IRemoteTest test : mConfiguration.getTests()) {
+                if (test instanceof ResultsPlayer) {
+                    replaySuccess = ((ResultsPlayer) test).completed();
+                }
+            }
+            if (replaySuccess != null && !replaySuccess) {
+                CLog.e(
+                        "Invocation failed and previous session results couldn't be copied, skip "
+                                + "generating the formatted report due to:");
+                CLog.e(mTestHarnessError);
+                return;
+            }
         }
 
         SuiteResultHolder holder = generateResultHolder();
diff --git a/src/com/android/tradefed/sandbox/SandboxInvocationRunner.java b/src/com/android/tradefed/sandbox/SandboxInvocationRunner.java
index ab0033f..5628636 100644
--- a/src/com/android/tradefed/sandbox/SandboxInvocationRunner.java
+++ b/src/com/android/tradefed/sandbox/SandboxInvocationRunner.java
@@ -57,6 +57,9 @@
         try {
             CommandResult result = sandbox.run(config, listener);
             if (!CommandStatus.SUCCESS.equals(result.getStatus())) {
+                CLog.e(
+                        "Sandbox finished with status: %s and exit code: %s",
+                        result.getStatus(), result.getExitCode());
                 handleStderrException(result.getStderr());
             }
         } finally {
diff --git a/src/com/android/tradefed/testtype/suite/retry/ResultsPlayer.java b/src/com/android/tradefed/testtype/suite/retry/ResultsPlayer.java
index c1a10d7..7da2c6c 100644
--- a/src/com/android/tradefed/testtype/suite/retry/ResultsPlayer.java
+++ b/src/com/android/tradefed/testtype/suite/retry/ResultsPlayer.java
@@ -15,6 +15,7 @@
  */
 package com.android.tradefed.testtype.suite.retry;
 
+import com.android.annotations.VisibleForTesting;
 import com.android.ddmlib.Log.LogLevel;
 import com.android.tradefed.config.IConfiguration;
 import com.android.tradefed.config.IConfigurationReceiver;
@@ -50,12 +51,18 @@
 
     private Map<TestRunResult, ReplayModuleHolder> mModuleResult;
     private IConfiguration mConfiguration;
+    private boolean mCompleted;
 
     /** Ctor. */
     public ResultsPlayer() {
         mModuleResult = new LinkedHashMap<>();
     }
 
+    @VisibleForTesting
+    public ResultsPlayer(boolean completed) {
+        mCompleted = completed;
+    }
+
     @Override
     public void run(TestInformation testInfo, ITestInvocationListener listener)
             throws DeviceNotAvailableException {
@@ -115,6 +122,7 @@
                 "Done replaying results in %s",
                 TimeUtil.formatElapsedTime(System.currentTimeMillis() - startReplay));
         mModuleResult.clear();
+        mCompleted = true;
     }
 
     /**
@@ -146,6 +154,11 @@
         mConfiguration = configuration;
     }
 
+    /** Returns whether or not the ResultsReplayer is done replaying the results. */
+    public boolean completed() {
+        return mCompleted;
+    }
+
     private void forwardTestResults(
             TestRunResult module,
             Collection<Entry<TestDescription, TestResult>> testSet,
diff --git a/src/com/android/tradefed/util/sl4a/Sl4aClient.java b/src/com/android/tradefed/util/sl4a/Sl4aClient.java
index b066c91..b2610a2 100644
--- a/src/com/android/tradefed/util/sl4a/Sl4aClient.java
+++ b/src/com/android/tradefed/util/sl4a/Sl4aClient.java
@@ -240,14 +240,14 @@
      * @throws IOException
      */
     private synchronized Object sendThroughSocket(String message) throws IOException {
-        CLog.d("preparing sending: '%s' to device %s", message, mDevice.getSerialNumber());
+        CLog.v("preparing sending: '%s' to device %s", message, mDevice.getSerialNumber());
         PrintWriter out = new PrintWriter(mSocket.getOutputStream(), false);
         out.print(message);
         out.print('\n');
         out.flush();
         BufferedReader in = new BufferedReader(new InputStreamReader(mSocket.getInputStream()));
         String response = in.readLine();
-        CLog.d("response: '%s' from device %s", response, mDevice.getSerialNumber());
+        CLog.v("response: '%s' from device %s", response, mDevice.getSerialNumber());
         try {
             JSONObject resp = new JSONObject(response);
             if (!resp.isNull("error")) {
diff --git a/test_framework/com/android/tradefed/device/metric/HostStatsdMetricCollector.java b/test_framework/com/android/tradefed/device/metric/HostStatsdMetricCollector.java
index 4f2e029..d86de45 100644
--- a/test_framework/com/android/tradefed/device/metric/HostStatsdMetricCollector.java
+++ b/test_framework/com/android/tradefed/device/metric/HostStatsdMetricCollector.java
@@ -43,48 +43,48 @@
     @Option(name = "binary-stats-config", description = "Path to the binary Statsd config file")
     private File mBinaryConfig;
 
+    @Option(name = "per-run", description = "Collect Metrics at per-test level or per-run level")
+    private boolean mPerRun = true;
+
     /** Map from device serial number to config ID on that device */
     private Map<String, Long> mDeviceConfigIds = new HashMap<>();
 
+    /**
+     * Counting the test in the same run. It is used to distinguish the statsd result file from
+     * multiple tests
+     */
+    private int mTestCount = 1;
+
     @Override
     public void onTestRunStart(DeviceMetricData runData) {
-        mDeviceConfigIds.clear();
-        for (ITestDevice device : getDevices()) {
-            String serialNumber = device.getSerialNumber();
-            try {
-                long configId = pushBinaryStatsConfig(device, mBinaryConfig);
-                CLog.d(
-                        "Pushed binary stats config to device %s with config id: %d",
-                        serialNumber, configId);
-                mDeviceConfigIds.put(serialNumber, configId);
-            } catch (IOException | DeviceNotAvailableException e) {
-                CLog.e("Failed to push stats config to device %s, error: %s", serialNumber, e);
-            }
+        if (mPerRun) {
+            mDeviceConfigIds.clear();
+            startCollection();
+        }
+    }
+
+    @Override
+    public void onTestStart(DeviceMetricData testData) {
+        if (!mPerRun) {
+            mDeviceConfigIds.clear();
+            startCollection();
+        }
+    }
+
+    @Override
+    public void onTestEnd(
+            DeviceMetricData testData, final Map<String, Metric> currentTestCaseMetrics) {
+        if (!mPerRun) {
+            stopCollection(testData);
+            mTestCount++;
         }
     }
 
     @Override
     public void onTestRunEnd(
             DeviceMetricData runData, final Map<String, Metric> currentRunMetrics) {
-        for (ITestDevice device : getDevices()) {
-            String serialNumber = device.getSerialNumber();
-            if (mDeviceConfigIds.containsKey(serialNumber)) {
-                long configId = mDeviceConfigIds.get(serialNumber);
-                try {
-                    InputStreamSource dataStream = getReportByteStream(device, configId);
-                    CLog.d(
-                            "Retrieved stats report from device %s for config %d",
-                            serialNumber, configId);
-                    processStatsReport(device, dataStream, runData);
-                    testLog(
-                            String.format("device_%s_stats_report", serialNumber),
-                            LogDataType.PB,
-                            dataStream);
-                    removeConfig(device, configId);
-                } catch (DeviceNotAvailableException e) {
-                    CLog.e("Device %s not available: %s", serialNumber, e);
-                }
-            }
+        if (mPerRun) {
+            stopCollection(runData);
         }
     }
 
@@ -114,5 +114,47 @@
      * @param runData The destination where the processed metrics will be stored
      */
     protected void processStatsReport(
-            ITestDevice device, InputStreamSource dataStream, DeviceMetricData runData) {}
+            ITestDevice device, InputStreamSource dataStream, DeviceMetricData runData) {
+        // Empty method by default
+    }
+
+    private void startCollection() {
+        for (ITestDevice device : getDevices()) {
+            String serialNumber = device.getSerialNumber();
+            try {
+                long configId = pushBinaryStatsConfig(device, mBinaryConfig);
+                CLog.d(
+                        "Pushed binary stats config to device %s with config id: %d",
+                        serialNumber, configId);
+                mDeviceConfigIds.put(serialNumber, configId);
+            } catch (IOException | DeviceNotAvailableException e) {
+                CLog.e("Failed to push stats config to device %s, error: %s", serialNumber, e);
+            }
+        }
+    }
+
+    private void stopCollection(DeviceMetricData metricData) {
+        for (ITestDevice device : getDevices()) {
+            String serialNumber = device.getSerialNumber();
+            if (mDeviceConfigIds.containsKey(serialNumber)) {
+                long configId = mDeviceConfigIds.get(serialNumber);
+                try (InputStreamSource dataStream = getReportByteStream(device, configId)) {
+                    CLog.d(
+                            "Retrieved stats report from device %s for config %d",
+                            serialNumber, configId);
+                    removeConfig(device, configId);
+                    String reportName =
+                            mPerRun
+                                    ? String.format("device_%s_stats_report", serialNumber)
+                                    : String.format(
+                                            "device_%s_stats_report_test_%d",
+                                            serialNumber, mTestCount);
+                    testLog(reportName, LogDataType.PB, dataStream);
+                    processStatsReport(device, dataStream, metricData);
+                } catch (DeviceNotAvailableException e) {
+                    CLog.e("Device %s not available: %s", serialNumber, e);
+                }
+            }
+        }
+    }
 }
diff --git a/test_framework/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java b/test_framework/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
index 2239bf8..18d142b 100644
--- a/test_framework/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
+++ b/test_framework/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
@@ -105,6 +105,7 @@
             installer(testInfo, testAppFileNames);
             if (containsApex(testAppFileNames)
                     || containsPersistentApk(testAppFileNames, testInfo)) {
+                RunUtil.getDefault().sleep(mApexStagingWaitTime);
                 device.reboot();
             }
             if (mTestApexInfoList.isEmpty()) {
diff --git a/test_result_interfaces/com/android/tradefed/result/MultiFailureDescription.java b/test_result_interfaces/com/android/tradefed/result/MultiFailureDescription.java
index 55ad8dc..10ec246 100644
--- a/test_result_interfaces/com/android/tradefed/result/MultiFailureDescription.java
+++ b/test_result_interfaces/com/android/tradefed/result/MultiFailureDescription.java
@@ -63,14 +63,20 @@
 
     @Override
     public @Nullable FailureStatus getFailureStatus() {
-        throw new UnsupportedOperationException(
-                "Cannot call #getFailureStatus on MultiFailureDescription");
+        if (mFailures.isEmpty()) {
+            return null;
+        }
+        // Default to the first reported failure
+        return mFailures.get(0).getFailureStatus();
     }
 
     @Override
     public String getErrorMessage() {
-        throw new UnsupportedOperationException(
-                "Cannot call #getErrorMessage on MultiFailureDescription");
+        if (mFailures.isEmpty()) {
+            return null;
+        }
+        // Default to the first reported failure
+        return mFailures.get(0).getErrorMessage();
     }
 
     @Override
diff --git a/tests/src/com/android/tradefed/device/metric/HostStatsdMetricCollectorTest.java b/tests/src/com/android/tradefed/device/metric/HostStatsdMetricCollectorTest.java
index 8056036..8f13ba9 100644
--- a/tests/src/com/android/tradefed/device/metric/HostStatsdMetricCollectorTest.java
+++ b/tests/src/com/android/tradefed/device/metric/HostStatsdMetricCollectorTest.java
@@ -20,6 +20,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.mockito.MockitoAnnotations.initMocks;
@@ -31,6 +32,7 @@
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
 import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.TestDescription;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -43,15 +45,15 @@
 
 import java.io.File;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 /** Unit Tests for {@link HostStatsdMetricCollector}. */
 @RunWith(JUnit4.class)
 public class HostStatsdMetricCollectorTest {
     private static final String STATSD_CONFIG = "statsd.config";
-    private static final String[] DEVICE_SERIALS = new String[] {"device_1", "device_2"};
     private static final long CONFIG_ID = 54321L;
 
     @Mock private IInvocationContext mContext;
@@ -59,34 +61,58 @@
     @Spy private HostStatsdMetricCollector mCollector;
     @Rule public TemporaryFolder mFolder = new TemporaryFolder();
 
-    @Before
-    public void setUp() throws IOException {
-        initMocks(this);
-    }
+    private TestDescription mTest = new TestDescription("Foo", "Bar");
+    private List<ITestDevice> mDevices =
+            Stream.of("device_1", "device_2").map(this::mockDevice).collect(Collectors.toList());
+    private HashMap<String, Metric> mMetrics = new HashMap<>();
 
-    /** Test that a binary config is pushed and report is dumped from multiple devices. */
-    @Test
-    public void testMetricCollection_binaryConfig_multiDevice()
-            throws IOException, ConfigurationException, DeviceNotAvailableException {
+    @Before
+    public void setUp() throws IOException, ConfigurationException, DeviceNotAvailableException {
+        initMocks(this);
         OptionSetter options = new OptionSetter(mCollector);
         options.setOptionValue(
                 "binary-stats-config", mFolder.newFile(STATSD_CONFIG).getAbsolutePath());
 
-        List<ITestDevice> devices = new ArrayList<>();
-        for (String serial : DEVICE_SERIALS) {
-            devices.add(mockDevice(serial));
-        }
-        when(mContext.getDevices()).thenReturn(devices);
+        when(mContext.getDevices()).thenReturn(mDevices);
         doReturn(CONFIG_ID)
                 .when(mCollector)
                 .pushBinaryStatsConfig(any(ITestDevice.class), any(File.class));
+    }
 
-        HashMap<String, Metric> runMetrics = new HashMap<>();
+    /** Test at per-test level that a binary config is pushed and report is dumped */
+    @Test
+    public void testCollect_perTest()
+            throws IOException, DeviceNotAvailableException, ConfigurationException {
+        OptionSetter options = new OptionSetter(mCollector);
+        options.setOptionValue("per-run", "false");
+
         mCollector.init(mContext, mListener);
-        mCollector.testRunStarted("collect-metrics", 1);
-        mCollector.testRunEnded(0L, runMetrics);
+        mCollector.testRunStarted("collect-metrics", 2);
+        mCollector.testStarted(mTest);
+        mCollector.testEnded(mTest, mMetrics);
+        mCollector.testStarted(mTest);
+        mCollector.testEnded(mTest, mMetrics);
+        mCollector.testRunEnded(0L, mMetrics);
 
-        for (ITestDevice device : devices) {
+        for (ITestDevice device : mDevices) {
+            verify(mCollector, times(2)).pushBinaryStatsConfig(eq(device), any(File.class));
+            verify(mCollector, times(2)).getReportByteStream(eq(device), anyLong());
+            verify(mCollector, times(2)).removeConfig(eq(device), anyLong());
+        }
+    }
+
+    /** Test at per-run level that a binary config is pushed and report is dumped at run level. */
+    @Test
+    public void testCollect_perRun() throws IOException, DeviceNotAvailableException {
+        mCollector.init(mContext, mListener);
+        mCollector.testRunStarted("collect-metrics", 2);
+        mCollector.testStarted(mTest);
+        mCollector.testEnded(mTest, mMetrics);
+        mCollector.testStarted(mTest);
+        mCollector.testEnded(mTest, mMetrics);
+        mCollector.testRunEnded(0L, mMetrics);
+
+        for (ITestDevice device : mDevices) {
             verify(mCollector).pushBinaryStatsConfig(eq(device), any(File.class));
             verify(mCollector).getReportByteStream(eq(device), anyLong());
             verify(mCollector).removeConfig(eq(device), anyLong());
diff --git a/tests/src/com/android/tradefed/result/suite/FormattedGeneratorReporterTest.java b/tests/src/com/android/tradefed/result/suite/FormattedGeneratorReporterTest.java
index 1a96516..961bb0f 100644
--- a/tests/src/com/android/tradefed/result/suite/FormattedGeneratorReporterTest.java
+++ b/tests/src/com/android/tradefed/result/suite/FormattedGeneratorReporterTest.java
@@ -18,6 +18,8 @@
 import com.android.tradefed.build.BuildInfo;
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.IConfiguration;
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.invoker.InvocationContext;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
@@ -25,7 +27,9 @@
 import com.android.tradefed.targetprep.TargetSetupError;
 import com.android.tradefed.testtype.Abi;
 import com.android.tradefed.testtype.IAbi;
+import com.android.tradefed.testtype.StubTest;
 import com.android.tradefed.testtype.suite.ModuleDefinition;
+import com.android.tradefed.testtype.suite.retry.ResultsPlayer;
 
 import org.junit.Assert;
 import org.junit.Before;
@@ -44,12 +48,14 @@
     private FormattedGeneratorReporter mReporter;
     private IInvocationContext mContext;
     private IBuildInfo mBuildInfo;
+    private IConfiguration mConfig;
 
     @Before
     public void setUp() {
         mContext = new InvocationContext();
         mBuildInfo = new BuildInfo();
         mContext.addDeviceBuildInfo("default", mBuildInfo);
+        mConfig = new Configuration("stub", "stub");
     }
 
     /** Test the value in the result holder when no module or tests actually ran. */
@@ -176,6 +182,8 @@
                         return null;
                     }
                 };
+        mConfig.setTest(new ResultsPlayer(false));
+        mReporter.setConfiguration(mConfig);
         mReporter.invocationStarted(mContext);
         DeviceDescriptor descriptor = null;
         mReporter.invocationFailed(new TargetSetupError("Invocation failed.", descriptor));
@@ -198,11 +206,46 @@
                         return null;
                     }
                 };
+        mConfig.setTest(new ResultsPlayer(false));
+        mReporter.setConfiguration(mConfig);
         mReporter.invocationStarted(mContext);
         mReporter.invocationFailed(new NullPointerException("Invocation failed."));
         mReporter.invocationEnded(500L);
     }
 
+    @Test
+    public void testFinalizedResults_notRetry() {
+        mReporter =
+                new FormattedGeneratorReporter() {
+
+                    @Override
+                    public void finalizeResults(
+                            IFormatterGenerator generator, SuiteResultHolder resultHolder) {
+                        validateSuiteHolder(
+                                resultHolder, 1, 1, 1, 0, 0L, mContext, new HashMap<>());
+                    }
+
+                    @Override
+                    public IFormatterGenerator createFormatter() {
+                        return null;
+                    }
+
+                    @Override
+                    protected long getCurrentTime() {
+                        return 0L;
+                    }
+                };
+        mConfig.setTest(new StubTest());
+        mReporter.setConfiguration(mConfig);
+        mReporter.invocationStarted(mContext);
+        mReporter.testRunStarted("run1", 1);
+        mReporter.testStarted(new TestDescription("class", "method"));
+        mReporter.testEnded(new TestDescription("class", "method"), new HashMap<String, Metric>());
+        mReporter.testRunEnded(450L, new HashMap<String, Metric>());
+        mReporter.invocationFailed(new NullPointerException("Invocation failed."));
+        mReporter.invocationEnded(500L);
+    }
+
     /** Validate the information inside the suite holder. */
     private void validateSuiteHolder(
             SuiteResultHolder holder,