Merge "Allow noisy-dry-run and dry-run to use the DryRunKeyStore" into pi-dev
diff --git a/atest/test_finders/test_finder_utils.py b/atest/test_finders/test_finder_utils.py
index db6d218..d613e52 100644
--- a/atest/test_finders/test_finder_utils.py
+++ b/atest/test_finders/test_finder_utils.py
@@ -76,6 +76,7 @@
 
 # VTS xml parsing constants.
 _VTS_TEST_MODULE = 'test-module-name'
+_VTS_MODULE = 'module-name'
 _VTS_BINARY_SRC = 'binary-test-source'
 _VTS_PUSH_GROUP = 'push-group'
 _VTS_PUSH = 'push'
@@ -499,7 +500,7 @@
     for tag in option_tags:
         value = tag.attrib[_XML_VALUE].strip()
         name = tag.attrib[_XML_NAME].strip()
-        if name == _VTS_TEST_MODULE:
+        if name in [_VTS_TEST_MODULE, _VTS_MODULE]:
             if module_info.is_module(value):
                 targets.add(value)
             else:
diff --git a/prod-tests/res/config/longevity/metrics-collector.xml b/prod-tests/res/config/longevity/metrics-collectors.xml
similarity index 60%
rename from prod-tests/res/config/longevity/metrics-collector.xml
rename to prod-tests/res/config/longevity/metrics-collectors.xml
index 0a27514..3c328b7 100644
--- a/prod-tests/res/config/longevity/metrics-collector.xml
+++ b/prod-tests/res/config/longevity/metrics-collectors.xml
@@ -26,8 +26,23 @@
 
     <!-- Collect graphicsstats dump every 4 minutes. -->
     <option name="metric-collection-intervals" key="jank" value="240000" />
-    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.GfxInfoMetricCollector" />
+    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.GraphicsStatsMetricCollector" />
 
+    <!-- Collect bugreport every 1 hour. -->
+    <option name="metric-collection-intervals" key="bugreportz" value="3600000" />
+    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.BugreportzMetricCollector" />
+
+    <!-- Collect ion audio and system heap info every 15 minutes. -->
+    <option name="metric-collection-intervals" key="ion" value="900000" />
+    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.IonHeapInfoMetricCollector" />
+
+    <!-- Collect pagetype info every 10 minutes. -->
+    <option name="metric-collection-intervals" key="pagetypeinfo" value="600000" />
+    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.PagetypeInfoMetricCollector" />
+
+    <!-- Collect trace every 20 minutes. -->
+    <option name="metric-collection-intervals" key="trace" value="1200000" />
+    <option name="metric-collector-command-classes" value="com.android.tradefed.device.metric.TraceMetricCollector" />
     <!-- Add more if there are requests. -->
   </metrics_collector>
 </configuration>
diff --git a/prod-tests/res/config/longevity/preparers.xml b/prod-tests/res/config/longevity/preparers.xml
new file mode 100644
index 0000000..5168a62
--- /dev/null
+++ b/prod-tests/res/config/longevity/preparers.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2018 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+<!-- Preparer configuration for longevity runs. -->
+<configuration description="Preparer configuration for longevity runs.">
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="PhoneLongevityTests.apk" />
+    </target_preparer>
+</configuration>
diff --git a/prod-tests/res/config/longevity/launch.xml b/prod-tests/res/config/longevity/test.xml
similarity index 70%
rename from prod-tests/res/config/longevity/launch.xml
rename to prod-tests/res/config/longevity/test.xml
index c200a57..b5db8b1 100644
--- a/prod-tests/res/config/longevity/launch.xml
+++ b/prod-tests/res/config/longevity/test.xml
@@ -13,14 +13,8 @@
      See the License for the specific language governing permissions and
      limitations under the License.
 -->
-<!-- Base configuration for longevity runs. -->
-<configuration description="Base configuration for longevity runs.">
-    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
-        <option name="test-file-name" value="PhoneLongevityTests.apk" />
-    </target_preparer>
-
-    <template-include name="preparers" default="empty" />
-
+<!-- Test configuration for longevity runs. -->
+<configuration description="Test configuration for longevity runs.">
     <!-- Shuffle and run the longevity tests 100 times. The test timeout is
          5 minutes and the test suite timeout is 10 mins.
     -->
@@ -29,15 +23,10 @@
         <option name="class" value="android.longevity.platform.PhoneSuite" />
         <option name="instrumentation-arg" key="iterations" value="100" />
         <option name="instrumentation-arg" key="shuffle" value="true" />
+        <!-- Test timeouts in 5 minutes. -->
         <option name="instrumentation-arg" key="timeout_msec" value="300000" />
+        <!-- Suite timeouts in 10 hours. -->
         <option name="instrumentation-arg" key="suite-timeout_msec" value="36000000" />
         <option name="instrumentation-arg" key="min-battery" value="0.05" />
     </test>
-
-    <logger class="com.android.tradefed.log.FileLogger">
-        <option name="log-level" value="VERBOSE" />
-        <option name="log-level-display" value="VERBOSE" />
-    </logger>
-
-    <template-include name="metrics_collector" default="empty" />
 </configuration>
diff --git a/src/com/android/tradefed/result/suite/SuiteResultHolder.java b/src/com/android/tradefed/result/suite/SuiteResultHolder.java
index 510f32a..63b80e2 100644
--- a/src/com/android/tradefed/result/suite/SuiteResultHolder.java
+++ b/src/com/android/tradefed/result/suite/SuiteResultHolder.java
@@ -29,8 +29,6 @@
 
     /** The collection of all results from the invocation. */
     public Collection<TestRunResult> runResults;
-    /** A map of logged file by <file name, file url>. */
-    public Map<String, String> loggedFiles;
     /** A map of each module's abi. */
     public Map<String, IAbi> modulesAbi;
 
diff --git a/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java b/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
index 20d5e18..3a71fa3 100644
--- a/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
+++ b/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
@@ -16,6 +16,7 @@
 package com.android.tradefed.result.suite;
 
 import com.android.ddmlib.testrunner.TestResult.TestStatus;
+import com.android.tradefed.result.LogFile;
 import com.android.tradefed.result.TestDescription;
 import com.android.tradefed.result.TestResult;
 import com.android.tradefed.result.TestRunResult;
@@ -213,7 +214,7 @@
             serializer.attribute(NS, DONE_ATTR, Boolean.toString(module.isRunComplete()));
             serializer.attribute(
                     NS, PASS_ATTR, Integer.toString(module.getNumTestsInState(TestStatus.PASSED)));
-            serializeTestCases(serializer, module.getTestResults(), holder.loggedFiles);
+            serializeTestCases(serializer, module.getTestResults());
             serializer.endTag(NS, MODULE_TAG);
         }
         serializer.endDocument();
@@ -221,9 +222,7 @@
     }
 
     private static void serializeTestCases(
-            XmlSerializer serializer,
-            Map<TestDescription, TestResult> results,
-            Map<String, String> loggedFiles)
+            XmlSerializer serializer, Map<TestDescription, TestResult> results)
             throws IllegalArgumentException, IllegalStateException, IOException {
         // We reformat into the same format as the ResultHandler from CTS to be compatible for now.
         Map<String, Map<String, TestResult>> format = new LinkedHashMap<>();
@@ -252,7 +251,7 @@
 
                 handleTestFailure(serializer, individualResult.getValue().getStackTrace());
 
-                HandleLoggedFiles(serializer, loggedFiles, className, individualResult.getKey());
+                HandleLoggedFiles(serializer, individualResult);
 
                 for (Entry<String, String> metric :
                         individualResult.getValue().getMetrics().entrySet()) {
@@ -290,31 +289,32 @@
 
     /** Add files captured by {@link TestFailureListener} on test failures. */
     private static void HandleLoggedFiles(
-            XmlSerializer serializer,
-            Map<String, String> loggedFiles,
-            String className,
-            String testName)
+            XmlSerializer serializer, Entry<String, TestResult> testResult)
             throws IllegalArgumentException, IllegalStateException, IOException {
-        if (loggedFiles == null) {
+        Map<String, LogFile> loggedFiles = testResult.getValue().getLoggedFiles();
+        if (loggedFiles == null || loggedFiles.isEmpty()) {
             return;
         }
-        // TODO: If possible handle a little more generically.
-        String testId = new TestDescription(className, testName).toString();
         for (String key : loggedFiles.keySet()) {
-            if (key.startsWith(testId)) {
-                if (key.endsWith("bugreport")) {
+            switch (loggedFiles.get(key).getType()) {
+                case BUGREPORT:
                     serializer.startTag(NS, BUGREPORT_TAG);
-                    serializer.text(loggedFiles.get(key));
+                    serializer.text(loggedFiles.get(key).getUrl());
                     serializer.endTag(NS, BUGREPORT_TAG);
-                } else if (key.endsWith("logcat")) {
+                    break;
+                case LOGCAT:
                     serializer.startTag(NS, LOGCAT_TAG);
-                    serializer.text(loggedFiles.get(key));
+                    serializer.text(loggedFiles.get(key).getUrl());
                     serializer.endTag(NS, LOGCAT_TAG);
-                } else if (key.endsWith("screenshot")) {
+                    break;
+                case PNG:
+                case JPEG:
                     serializer.startTag(NS, SCREENSHOT_TAG);
-                    serializer.text(loggedFiles.get(key));
+                    serializer.text(loggedFiles.get(key).getUrl());
                     serializer.endTag(NS, SCREENSHOT_TAG);
-                }
+                    break;
+                default:
+                    break;
             }
         }
     }
diff --git a/src/com/android/tradefed/testtype/suite/ModuleDefinition.java b/src/com/android/tradefed/testtype/suite/ModuleDefinition.java
index a016ab5..cd1dbc8 100644
--- a/src/com/android/tradefed/testtype/suite/ModuleDefinition.java
+++ b/src/com/android/tradefed/testtype/suite/ModuleDefinition.java
@@ -22,6 +22,7 @@
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.DeviceUnresponsiveException;
 import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.StubDevice;
 import com.android.tradefed.device.metric.IMetricCollector;
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.invoker.InvocationContext;
@@ -441,6 +442,14 @@
 
     private void reportFailure(ITestInvocationListener listener, String errorMessage) {
         listener.testRunFailed(errorMessage);
+        for (ITestDevice device : mModuleInvocationContext.getDevices()) {
+            if (device.getIDevice() instanceof StubDevice) {
+                continue;
+            }
+            device.logBugreport(
+                    String.format("module-failure-bugreport-%s", device.getSerialNumber()),
+                    listener);
+        }
     }
 
     /** Helper to log the device events. */
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index 232f2bc..b03c28a 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -118,6 +118,7 @@
 import com.android.tradefed.result.TestSummaryTest;
 import com.android.tradefed.result.XmlResultReporterTest;
 import com.android.tradefed.result.suite.FormattedGeneratorReporterTest;
+import com.android.tradefed.result.suite.XmlSuiteResultFormatterTest;
 import com.android.tradefed.sandbox.SandboxConfigDumpTest;
 import com.android.tradefed.sandbox.SandboxConfigUtilTest;
 import com.android.tradefed.sandbox.SandboxInvocationRunnerTest;
@@ -410,6 +411,7 @@
 
     // result.suite
     FormattedGeneratorReporterTest.class,
+    XmlSuiteResultFormatterTest.class,
 
     // targetprep
     AllTestAppsInstallSetupTest.class,
diff --git a/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java b/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java
new file mode 100644
index 0000000..144a1d7
--- /dev/null
+++ b/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tradefed.result.suite;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.LogFile;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.result.TestRunResult;
+import com.android.tradefed.testtype.Abi;
+import com.android.tradefed.testtype.IAbi;
+import com.android.tradefed.util.FileUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+import java.io.File;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpressionException;
+import javax.xml.xpath.XPathFactory;
+
+/** Unit tests for {@link XmlSuiteResultFormatter}. */
+@RunWith(JUnit4.class)
+public class XmlSuiteResultFormatterTest {
+    private XmlSuiteResultFormatter mFormatter;
+    private SuiteResultHolder mResultHolder;
+    private IInvocationContext mContext;
+    private File mResultDir;
+
+    @Before
+    public void setUp() throws Exception {
+        mFormatter = new XmlSuiteResultFormatter();
+        mResultHolder = new SuiteResultHolder();
+        mContext = new InvocationContext();
+        mResultDir = FileUtil.createTempDir("result-dir");
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        FileUtil.recursiveDelete(mResultDir);
+    }
+
+    /** Check that the basic overall structure is good an contains all the information. */
+    @Test
+    public void testBasicFormat() throws Exception {
+        mResultHolder.context = mContext;
+
+        Collection<TestRunResult> runResults = new ArrayList<>();
+        runResults.add(createFakeResult("module1", 2, 0));
+        runResults.add(createFakeResult("module2", 1, 0));
+        mResultHolder.runResults = runResults;
+
+        Map<String, IAbi> modulesAbi = new HashMap<>();
+        modulesAbi.put("module1", new Abi("armeabi-v7a", "32"));
+        modulesAbi.put("module2", new Abi("armeabi-v7a", "32"));
+        mResultHolder.modulesAbi = modulesAbi;
+
+        mResultHolder.completeModules = 2;
+        mResultHolder.totalModules = 2;
+        mResultHolder.passedTests = 2;
+        mResultHolder.failedTests = 0;
+        mResultHolder.startTime = 0L;
+        mResultHolder.endTime = 10L;
+        File res = mFormatter.writeResults(mResultHolder, mResultDir);
+        String content = FileUtil.readStringFromFile(res);
+        assertXmlContainsNode(content, "Result");
+        // Verify that the summary has been populated
+        assertXmlContainsNode(content, "Result/Summary");
+        assertXmlContainsAttribute(content, "Result/Summary", "pass", "2");
+        assertXmlContainsAttribute(content, "Result/Summary", "failed", "0");
+        assertXmlContainsAttribute(content, "Result/Summary", "modules_done", "2");
+        assertXmlContainsAttribute(content, "Result/Summary", "modules_total", "2");
+        // Verify that each module results are available
+        assertXmlContainsNode(content, "Result/Module");
+        assertXmlContainsAttribute(content, "Result/Module", "name", "module1");
+        assertXmlContainsAttribute(content, "Result/Module", "abi", "armeabi-v7a");
+        assertXmlContainsAttribute(content, "Result/Module", "runtime", "10");
+        assertXmlContainsAttribute(content, "Result/Module", "done", "true");
+        assertXmlContainsAttribute(content, "Result/Module", "pass", "2");
+        // Verify the test cases that passed are present
+        assertXmlContainsNode(content, "Result/Module/TestCase");
+        assertXmlContainsAttribute(content, "Result/Module/TestCase", "name", "com.class.module1");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module1.method0");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module1.method1");
+
+        assertXmlContainsAttribute(content, "Result/Module", "name", "module2");
+        assertXmlContainsAttribute(content, "Result/Module", "pass", "1");
+        assertXmlContainsAttribute(content, "Result/Module/TestCase", "name", "com.class.module2");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module2.method0");
+    }
+
+    /** Check that the test failures are properly reported. */
+    @Test
+    public void testFailuresReporting() throws Exception {
+        mResultHolder.context = mContext;
+
+        Collection<TestRunResult> runResults = new ArrayList<>();
+        runResults.add(createFakeResult("module1", 2, 1));
+        mResultHolder.runResults = runResults;
+
+        Map<String, IAbi> modulesAbi = new HashMap<>();
+        modulesAbi.put("module1", new Abi("armeabi-v7a", "32"));
+        mResultHolder.modulesAbi = modulesAbi;
+
+        mResultHolder.completeModules = 2;
+        mResultHolder.totalModules = 1;
+        mResultHolder.passedTests = 1;
+        mResultHolder.failedTests = 1;
+        mResultHolder.startTime = 0L;
+        mResultHolder.endTime = 10L;
+        File res = mFormatter.writeResults(mResultHolder, mResultDir);
+        String content = FileUtil.readStringFromFile(res);
+
+        assertXmlContainsNode(content, "Result/Module");
+        assertXmlContainsAttribute(content, "Result/Module/TestCase", "name", "com.class.module1");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module1.method0");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module1.method1");
+        // Check that failures are showing in the xml for the test cases
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test", "name", "module1.failed0");
+        assertXmlContainsAttribute(
+                content, "Result/Module/TestCase/Test/Failure", "message", "module1 failed.");
+        assertXmlContainsValue(
+                content,
+                "Result/Module/TestCase/Test/Failure/StackTrace",
+                "module1 failed.\nstack\nstack");
+    }
+
+    /** Check that the logs for each test case are reported. */
+    @Test
+    public void testLogReporting() throws Exception {
+        mResultHolder.context = mContext;
+
+        Collection<TestRunResult> runResults = new ArrayList<>();
+        runResults.add(createResultWithLog("module1", 1, LogDataType.LOGCAT));
+        runResults.add(createResultWithLog("module2", 1, LogDataType.BUGREPORT));
+        runResults.add(createResultWithLog("module3", 1, LogDataType.PNG));
+        mResultHolder.runResults = runResults;
+
+        Map<String, IAbi> modulesAbi = new HashMap<>();
+        modulesAbi.put("module1", new Abi("armeabi-v7a", "32"));
+        mResultHolder.modulesAbi = modulesAbi;
+
+        mResultHolder.completeModules = 2;
+        mResultHolder.totalModules = 2;
+        mResultHolder.passedTests = 2;
+        mResultHolder.failedTests = 0;
+        mResultHolder.startTime = 0L;
+        mResultHolder.endTime = 10L;
+        File res = mFormatter.writeResults(mResultHolder, mResultDir);
+        String content = FileUtil.readStringFromFile(res);
+        // One logcat and one bugreport are found in the report
+        assertXmlContainsValue(content, "Result/Module/TestCase/Test/Logcat", "http:url/module1");
+        assertXmlContainsValue(
+                content, "Result/Module/TestCase/Test/BugReport", "http:url/module2");
+        assertXmlContainsValue(
+                content, "Result/Module/TestCase/Test/Screenshot", "http:url/module3");
+    }
+
+    private TestRunResult createResultWithLog(String runName, int count, LogDataType type) {
+        TestRunResult fakeRes = new TestRunResult();
+        fakeRes.testRunStarted(runName, count);
+        for (int i = 0; i < count; i++) {
+            TestDescription description =
+                    new TestDescription("com.class." + runName, runName + ".method" + i);
+            fakeRes.testStarted(description);
+            fakeRes.testLogSaved(
+                    runName + "log" + i, new LogFile("path", "http:url/" + runName, type));
+            fakeRes.testEnded(description, new HashMap<>());
+        }
+        fakeRes.testRunEnded(10L, new HashMap<>());
+        return fakeRes;
+    }
+
+    private TestRunResult createFakeResult(String runName, int passed, int failed) {
+        TestRunResult fakeRes = new TestRunResult();
+        fakeRes.testRunStarted(runName, passed + failed);
+        for (int i = 0; i < passed; i++) {
+            TestDescription description =
+                    new TestDescription("com.class." + runName, runName + ".method" + i);
+            fakeRes.testStarted(description);
+            fakeRes.testEnded(description, new HashMap<>());
+        }
+        for (int i = 0; i < failed; i++) {
+            TestDescription description =
+                    new TestDescription("com.class." + runName, runName + ".failed" + i);
+            fakeRes.testStarted(description);
+            fakeRes.testFailed(description, runName + " failed.\nstack\nstack");
+            fakeRes.testEnded(description, new HashMap<>());
+        }
+        fakeRes.testRunEnded(10L, new HashMap<>());
+        return fakeRes;
+    }
+
+    /** Return all XML nodes that match the given xPathExpression. */
+    private NodeList getXmlNodes(String xml, String xPathExpression)
+            throws XPathExpressionException {
+
+        InputSource inputSource = new InputSource(new StringReader(xml));
+        XPath xpath = XPathFactory.newInstance().newXPath();
+        return (NodeList) xpath.evaluate(xPathExpression, inputSource, XPathConstants.NODESET);
+    }
+
+    /** Assert that the XML contains a node matching the given xPathExpression. */
+    private NodeList assertXmlContainsNode(String xml, String xPathExpression)
+            throws XPathExpressionException {
+        NodeList nodes = getXmlNodes(xml, xPathExpression);
+        assertNotNull(
+                String.format("XML '%s' returned null for xpath '%s'.", xml, xPathExpression),
+                nodes);
+        assertTrue(
+                String.format(
+                        "XML '%s' should have returned at least 1 node for xpath '%s', "
+                                + "but returned %s nodes instead.",
+                        xml, xPathExpression, nodes.getLength()),
+                nodes.getLength() >= 1);
+        return nodes;
+    }
+
+    /**
+     * Assert that the XML contains a node matching the given xPathExpression and that the node has
+     * a given value.
+     */
+    private void assertXmlContainsValue(String xml, String xPathExpression, String value)
+            throws XPathExpressionException {
+        NodeList nodes = assertXmlContainsNode(xml, xPathExpression);
+        boolean found = false;
+
+        for (int i = 0; i < nodes.getLength(); i++) {
+            Element element = (Element) nodes.item(i);
+            if (element.getTextContent().equals(value)) {
+                found = true;
+                break;
+            }
+        }
+
+        assertTrue(
+                String.format(
+                        "xPath '%s' should contain value '%s' but does not. XML: '%s'",
+                        xPathExpression, value, xml),
+                found);
+    }
+
+    /**
+     * Assert that the XML contains a node matching the given xPathExpression and that the node has
+     * a given value.
+     */
+    private void assertXmlContainsAttribute(
+            String xml, String xPathExpression, String attributeName, String attributeValue)
+            throws XPathExpressionException {
+        NodeList nodes = assertXmlContainsNode(xml, xPathExpression);
+        boolean found = false;
+
+        for (int i = 0; i < nodes.getLength(); i++) {
+            Element element = (Element) nodes.item(i);
+            String value = element.getAttribute(attributeName);
+            if (attributeValue.equals(value)) {
+                found = true;
+                break;
+            }
+        }
+
+        assertTrue(
+                String.format(
+                        "xPath '%s' should contain attribute '%s' but does not. XML: '%s'",
+                        xPathExpression, attributeName, xml),
+                found);
+    }
+}
diff --git a/tests/src/com/android/tradefed/testtype/suite/ModuleDefinitionTest.java b/tests/src/com/android/tradefed/testtype/suite/ModuleDefinitionTest.java
index 3c36056..a1bf887 100644
--- a/tests/src/com/android/tradefed/testtype/suite/ModuleDefinitionTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/ModuleDefinitionTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 
+import com.android.ddmlib.IDevice;
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.command.remote.DeviceDescriptor;
 import com.android.tradefed.config.Configuration;
@@ -25,6 +26,7 @@
 import com.android.tradefed.config.IConfiguration;
 import com.android.tradefed.config.OptionSetter;
 import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.DeviceUnresponsiveException;
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.invoker.TestInvocation;
@@ -95,6 +97,7 @@
         private String mRunName;
         private int mNumTest;
         private boolean mShouldThrow;
+        private boolean mDeviceUnresponsive = false;
 
         public TestObject(String runName, int numTest, boolean shouldThrow) {
             mRunName = runName;
@@ -102,6 +105,14 @@
             mShouldThrow = shouldThrow;
         }
 
+        public TestObject(
+                String runName, int numTest, boolean shouldThrow, boolean deviceUnresponsive) {
+            mRunName = runName;
+            mNumTest = numTest;
+            mShouldThrow = shouldThrow;
+            mDeviceUnresponsive = deviceUnresponsive;
+        }
+
         @Override
         public void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
             listener.testRunStarted(mRunName, mNumTest);
@@ -111,6 +122,9 @@
                 if (mShouldThrow && i == mNumTest / 2) {
                     throw new DeviceNotAvailableException();
                 }
+                if (mDeviceUnresponsive) {
+                    throw new DeviceUnresponsiveException();
+                }
                 listener.testEnded(test, Collections.emptyMap());
             }
             listener.testRunEnded(0, Collections.emptyMap());
@@ -169,7 +183,7 @@
      * Helper for replaying mocks.
      */
     private void replayMocks() {
-        EasyMock.replay(mMockListener, mMockLogSaver, mMockLogSaverListener);
+        EasyMock.replay(mMockListener, mMockLogSaver, mMockLogSaverListener, mMockDevice);
         for (IRemoteTest test : mTestList) {
             EasyMock.replay(test);
         }
@@ -186,7 +200,7 @@
      * Helper for verifying mocks.
      */
     private void verifyMocks() {
-        EasyMock.verify(mMockListener, mMockLogSaver, mMockLogSaverListener);
+        EasyMock.verify(mMockListener, mMockLogSaver, mMockLogSaverListener, mMockDevice);
         for (IRemoteTest test : mTestList) {
             EasyMock.verify(test);
         }
@@ -661,9 +675,64 @@
         // Only a screenshot is capture, logcat for that module was disabled.
         mMockListener.testLog(
                 EasyMock.anyObject(), EasyMock.eq(LogDataType.PNG), EasyMock.anyObject());
-        EasyMock.replay(mMockDevice);
         replayMocks();
         mModule.run(mMockListener, failureListener);
-        EasyMock.verify(mMockDevice);
+        verifyMocks();
+    }
+
+    /** Test when the test yields a DeviceUnresponsive exception. */
+    @Test
+    public void testRun_partialRun_deviceUnresponsive() throws Exception {
+        final int testCount = 4;
+        List<IRemoteTest> testList = new ArrayList<>();
+        testList.add(new TestObject("run1", testCount, false, true));
+        mModule =
+                new ModuleDefinition(
+                        MODULE_NAME,
+                        testList,
+                        mMapDeviceTargetPreparer,
+                        mMultiTargetPrepList,
+                        new Configuration("", ""));
+        mModule.getModuleInvocationContext().addAllocatedDevice(DEFAULT_DEVICE_NAME, mMockDevice);
+        mModule.getModuleInvocationContext()
+                .addDeviceBuildInfo(DEFAULT_DEVICE_NAME, mMockBuildInfo);
+        mModule.setBuild(mMockBuildInfo);
+        mModule.setDevice(mMockDevice);
+        EasyMock.expect(mMockPrep.isDisabled()).andReturn(false);
+        // no isTearDownDisabled() expected for setup
+        mMockPrep.setUp(EasyMock.eq(mMockDevice), EasyMock.eq(mMockBuildInfo));
+        EasyMock.expect(mMockCleaner.isDisabled()).andStubReturn(false);
+        mMockCleaner.setUp(EasyMock.eq(mMockDevice), EasyMock.eq(mMockBuildInfo));
+        EasyMock.expect(mMockCleaner.isTearDownDisabled()).andStubReturn(false);
+        mMockCleaner.tearDown(
+                EasyMock.eq(mMockDevice), EasyMock.eq(mMockBuildInfo), EasyMock.isNull());
+        mMockListener.testRunStarted(MODULE_NAME, testCount);
+        for (int i = 0; i < 1; i++) {
+            mMockListener.testStarted((TestDescription) EasyMock.anyObject(), EasyMock.anyLong());
+            mMockListener.testEnded(
+                    (TestDescription) EasyMock.anyObject(),
+                    EasyMock.anyLong(),
+                    EasyMock.anyObject());
+        }
+        mMockListener.testFailed(EasyMock.anyObject(), EasyMock.anyObject());
+        mMockListener.testRunFailed(EasyMock.anyObject());
+        mMockListener.testRunEnded(EasyMock.anyLong(), (Map<String, String>) EasyMock.anyObject());
+
+        // There was a module failure so a bugreport should be captured.
+        EasyMock.expect(mMockDevice.getIDevice()).andStubReturn(EasyMock.createMock(IDevice.class));
+        EasyMock.expect(mMockDevice.getSerialNumber()).andReturn("SERIAL");
+        EasyMock.expect(
+                        mMockDevice.logBugreport(
+                                EasyMock.eq("module-failure-bugreport-SERIAL"),
+                                EasyMock.anyObject()))
+                .andReturn(true);
+
+        replayMocks();
+        // DeviceUnresponsive should not throw since it indicates that the device was recovered.
+        mModule.run(mMockListener);
+        // Only one module
+        assertEquals(1, mModule.getTestsResults().size());
+        assertEquals(0, mModule.getTestsResults().get(0).getNumCompleteTests());
+        verifyMocks();
     }
 }