Add a screen record collector and tests.
Bug: 144698400
Test: included
Change-Id: I691eb138cca9470187dcf9e849e5065c3c6e8ca9
(cherry picked from commit b34f56c18a19bc4c3b356bbfda0a1a90aed5bafe)
diff --git a/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java b/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java
new file mode 100644
index 0000000..86abe33
--- /dev/null
+++ b/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2019 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 android.device.collectors;
+
+import android.device.collectors.annotations.OptionClass;
+import android.os.SystemClock;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.HashMap;
+
+import org.junit.runner.Description;
+
+/**
+ * A {@link BaseMetricListener} that captures video of the screen.
+ *
+ * <p>This class needs external storage permission. See {@link BaseMetricListener} how to grant
+ * external storage permission, especially at install time.
+ */
+@OptionClass(alias = "screen-record-collector")
+public class ScreenRecordCollector extends BaseMetricListener {
+ private static final long VIDEO_TAIL_BUFFER = 2000;
+
+ static final String OUTPUT_DIR = "run_listeners/videos";
+
+ private UiDevice mDevice;
+ private File mDestDir;
+
+ // Tracks the test iterations to ensure that each failure gets unique filenames.
+ // Key: test description; value: number of iterations.
+ private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
+
+ @Override
+ public void onTestRunStart(DataRecord runData, Description description) {
+ mDestDir = createAndEmptyDirectory(OUTPUT_DIR);
+ }
+
+ @Override
+ public void onTestStart(DataRecord testData, Description description) {
+ if (mDestDir == null) {
+ return;
+ }
+
+ // Track the number of iteration for this test.
+ amendIterations(description);
+ // Start the screen recording operation.
+ startScreenRecordThread(getOutputFile(description).getAbsolutePath());
+ }
+
+ @Override
+ public void onTestEnd(DataRecord testData, Description description) {
+ // Skip if not directory.
+ if (mDestDir == null) {
+ return;
+ }
+
+ // Add some extra time to the video end.
+ SystemClock.sleep(getTailBuffer());
+ // Ctrl + C all screen record processes.
+ killScreenRecordProcesses();
+
+ // Add the output file to the data record.
+ File output = getOutputFile(description);
+ testData.addFileMetric(String.format("%s_%s", getTag(), output.getName()), output);
+
+ // TODO(b/144869954): Delete when tests pass.
+ }
+
+ /** Updates the number of iterations performed for a given test {@link Description}. */
+ private void amendIterations(Description description) {
+ String testName = description.getDisplayName();
+ mTestIterations.computeIfPresent(testName, (name, iterations) -> iterations + 1);
+ mTestIterations.computeIfAbsent(testName, name -> 1);
+ }
+
+ private File getOutputFile(Description description) {
+ final String baseName =
+ String.format("%s.%s", description.getClassName(), description.getMethodName());
+ // Omit the iteration number for the first iteration.
+ int iteration = mTestIterations.get(description.getDisplayName());
+ final String fileName =
+ String.format(
+ "%s-video.mp4",
+ iteration == 1
+ ? baseName
+ : String.join("-", baseName, String.valueOf(iteration)));
+ return Paths.get(mDestDir.getAbsolutePath(), fileName).toFile();
+ }
+
+ /** Spawns a thread to start screen recording that will save to the provided {@code path}. */
+ public void startScreenRecordThread(String path) {
+ Log.d(getTag(), String.format("Recording screen to %s", path));
+ new Thread("test-screenrecord-thread") {
+ @Override
+ public void run() {
+ try {
+ // Make sure not to block on this background command.
+ getDevice().executeShellCommand(String.format("screenrecord %s", path));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to start screen recording.");
+ }
+ }
+ }.start();
+ }
+
+ /** Kills all screen recording processes that are actively running on the device. */
+ public void killScreenRecordProcesses() {
+ try {
+ // Identify the screenrecord PIDs and send SIGINT 2 (Ctrl + C) to each.
+ String[] pids = getDevice().executeShellCommand("pidof screenrecord").split(" ");
+ for (String pid : pids) {
+ // Avoid empty process ids, because of weird splitting behavior.
+ if (pid.isEmpty()) {
+ continue;
+ }
+
+ getDevice().executeShellCommand(String.format("kill -2 %s", pid));
+ Log.d(getTag(), String.format("Sent SIGINT 2 to screenrecord process (%s)", pid));
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to kill screen recording process.");
+ }
+ }
+
+ /** Returns a buffer duration for the end of the video. */
+ @VisibleForTesting
+ public long getTailBuffer() {
+ return VIDEO_TAIL_BUFFER;
+ }
+
+ /** Returns the currently active {@link UiDevice}. */
+ public UiDevice getDevice() {
+ if (mDevice == null) {
+ mDevice = UiDevice.getInstance(getInstrumentation());
+ }
+ return mDevice;
+ }
+}
diff --git a/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java b/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java
new file mode 100644
index 0000000..d7198c1
--- /dev/null
+++ b/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2019 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 android.device.collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.AdditionalMatchers.not;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.matches;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.Instrumentation;
+import android.device.collectors.util.SendToInstrumentation;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.test.uiautomator.UiDevice;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Android Unit tests for {@link ScreenRecordCollector}.
+ *
+ * <p>To run: atest CollectorDeviceLibTest:android.device.collectors.ScreenRecordCollectorTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class ScreenRecordCollectorTest {
+
+ private static final int NUM_TEST_CASE = 10;
+
+ private File mLogDir;
+ private Description mRunDesc;
+ private Description mTestDesc;
+ private ScreenRecordCollector mListener;
+
+ @Mock private Instrumentation mInstrumentation;
+
+ @Mock private UiDevice mDevice;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mLogDir = new File("tmp/");
+ mRunDesc = Description.createSuiteDescription("run");
+ mTestDesc = Description.createTestDescription("run", "test");
+ }
+
+ @After
+ public void tearDown() {
+ if (mLogDir != null) {
+ mLogDir.delete();
+ }
+ }
+
+ private ScreenRecordCollector initListener() throws IOException {
+ ScreenRecordCollector listener = spy(new ScreenRecordCollector());
+ listener.setInstrumentation(mInstrumentation);
+ doReturn(mLogDir).when(listener).createAndEmptyDirectory(anyString());
+ doReturn(0L).when(listener).getTailBuffer();
+ doReturn(mDevice).when(listener).getDevice();
+ doReturn("1234").when(mDevice).executeShellCommand(eq("pidof screenrecord"));
+ doReturn("").when(mDevice).executeShellCommand(not(eq("pidof screenrecord")));
+ return listener;
+ }
+
+ /**
+ * Test that screen recording is properly started and ended for each test over the course of a
+ * test run.
+ */
+ @Test
+ public void testScreenRecord() throws Exception {
+ mListener = initListener();
+
+ // Verify output directories are created on test run start.
+ mListener.testRunStarted(mRunDesc);
+ verify(mListener).createAndEmptyDirectory(ScreenRecordCollector.OUTPUT_DIR);
+
+ // Walk through a number of test cases to simulate behavior.
+ for (int i = 1; i <= NUM_TEST_CASE; i++) {
+ // Verify a thread is started when the test starts.
+ mListener.testStarted(mTestDesc);
+ verify(mListener, times(i)).startScreenRecordThread(anyString());
+ // Delay verification by 100 ms to ensure the thread was started.
+ SystemClock.sleep(100);
+ verify(mDevice, times(i)).executeShellCommand(matches("screenrecord .*"));
+
+ // Alternate between pass and fail for variety.
+ if (i % 2 == 0) {
+ mListener.testFailure(new Failure(mTestDesc, new RuntimeException("I failed")));
+ }
+
+ // Verify all processes are killed when the test ends.
+ mListener.testFinished(mTestDesc);
+ verify(mListener, times(i)).killScreenRecordProcesses();
+ verify(mDevice, times(i)).executeShellCommand(eq("pidof screenrecord"));
+ verify(mDevice, times(i)).executeShellCommand(matches("kill -2 1234"));
+ }
+
+ // Verify files are reported
+ mListener.testRunFinished(new Result());
+
+ Bundle resultBundle = new Bundle();
+ mListener.instrumentationRunFinished(System.out, resultBundle, new Result());
+
+ ArgumentCaptor<Bundle> capture = ArgumentCaptor.forClass(Bundle.class);
+ Mockito.verify(mInstrumentation, times(NUM_TEST_CASE))
+ .sendStatus(
+ Mockito.eq(SendToInstrumentation.INST_STATUS_IN_PROGRESS),
+ capture.capture());
+ List<Bundle> capturedBundle = capture.getAllValues();
+ assertEquals(NUM_TEST_CASE, capturedBundle.size());
+
+ int videoCount = 0;
+ for (Bundle bundle : capturedBundle) {
+ for (String key : bundle.keySet()) {
+ if (key.contains("mp4")) videoCount++;
+ }
+ }
+ assertEquals(NUM_TEST_CASE, videoCount);
+ }
+
+ /** Test that screen recording is properly done for multiple tests and labels iterations. */
+ @Test
+ public void testMultipleScreenRecords() throws Exception {
+ mListener = initListener();
+
+ // Run through a sequence of `NUM_TEST_CASE` failing tests.
+ mListener.testRunStarted(mRunDesc);
+ verify(mListener).createAndEmptyDirectory(ScreenRecordCollector.OUTPUT_DIR);
+
+ // Walk through a number of test cases to simulate behavior.
+ for (int i = 1; i <= NUM_TEST_CASE; i++) {
+ mListener.testStarted(mTestDesc);
+ mListener.testFinished(mTestDesc);
+ }
+ mListener.testRunFinished(new Result());
+
+ // Verify that videos are saved with iterations.
+ InOrder videoVerifier = inOrder(mListener);
+ // The first video should not have an iteration number.
+ videoVerifier.verify(mListener).startScreenRecordThread(matches("^.*[^1].mp4$"));
+ // The subsequent videos should have an iteration number.
+ for (int i = 1; i < NUM_TEST_CASE; i++) {
+ videoVerifier
+ .verify(mListener)
+ .startScreenRecordThread(endsWith(String.format("%d-video.mp4", i + 1)));
+ }
+ }
+}