From 4b4d7c83120b75d05e1848da706d718401ab6558 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 17:34:10 -0800 Subject: [PATCH 1/9] [Build] Update to stable AGP --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 41d04e17..1881b172 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ buildscript { ] dependencies { - classpath 'com.android.tools.build:gradle:3.0.0-beta7' + classpath 'com.android.tools.build:gradle:3.0.1' classpath 'com.github.jengelman.gradle.plugins:shadow:1.2.4' classpath 'net.ltgt.gradle:gradle-errorprone-plugin:0.0.10' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" From 33cc5327cdbec483f4a9ef3d3e7ac26354971704 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 20:26:56 -0800 Subject: [PATCH 2/9] [WIP] --- build.gradle | 1 + .../squareup/spoon/internal/Constants.java | 1 + spoon-runner/build.gradle | 1 + .../com/squareup/spoon/DeviceTestResult.java | 15 +++ .../com/squareup/spoon/ScreenRecorder.java | 115 ++++++++++++++++++ .../spoon/ScreenRecorderTestRunListener.java | 87 +++++++++++++ .../com/squareup/spoon/SpoonDeviceRunner.java | 73 ++++++++++- .../java/com/squareup/spoon/SpoonUtils.java | 35 ++++++ 8 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java create mode 100644 spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java diff --git a/build.gradle b/build.gradle index 1881b172..d7036702 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,7 @@ buildscript { 'commonsIo': 'commons-io:commons-io:2.5', 'ddmlib': 'com.android.tools.ddms:ddmlib:25.3.0', 'animatedGifLib': 'com.madgag:animated-gif-lib:1.2', + 'isoParser': 'com.googlecode.mp4parser:isoparser:1.1.22', 'guava': 'com.google.guava:guava:21.0', 'lesscss': 'org.lesscss:lesscss:1.3.3', 'mustache': 'com.github.spullara.mustache.java:compiler:0.8.14', diff --git a/spoon-common/src/main/java/com/squareup/spoon/internal/Constants.java b/spoon-common/src/main/java/com/squareup/spoon/internal/Constants.java index 306e165d..f17cb7a6 100644 --- a/spoon-common/src/main/java/com/squareup/spoon/internal/Constants.java +++ b/spoon-common/src/main/java/com/squareup/spoon/internal/Constants.java @@ -2,6 +2,7 @@ public interface Constants { String SPOON_SCREENSHOTS = "spoon-screenshots"; + String SPOON_VIDEOS = "spoon-videos"; String SPOON_FILES = "spoon-files"; String NAME_SEPARATOR = "_"; } diff --git a/spoon-runner/build.gradle b/spoon-runner/build.gradle index 4a94f5d3..a6d1ee99 100644 --- a/spoon-runner/build.gradle +++ b/spoon-runner/build.gradle @@ -28,6 +28,7 @@ dependencies { compile deps.commonsIo compile deps.ddmlib compile deps.animatedGifLib + compile deps.isoParser compile deps.guava compile deps.mustache compile deps.lesscss diff --git a/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java b/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java index bce465a3..0c5f0da4 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java @@ -75,11 +75,13 @@ public List getLog() { public static class Builder { private final List screenshots = new ArrayList<>(); private final List files = new ArrayList<>(); + private final List videos = new ArrayList<>(); private Status status = Status.PASS; private StackTrace exception; private long start; private long duration = -1; private File animatedGif; + private File combinedVideo; private List log; public Builder markTestAsFailed(String message) { @@ -138,6 +140,12 @@ public Builder addScreenshot(File screenshot) { return this; } + public Builder addVideo(File video) { + checkNotNull(video); + videos.add(video); + return this; + } + public Builder addFile(File file) { checkNotNull(file); files.add(file); @@ -151,6 +159,13 @@ public Builder setAnimatedGif(File animatedGif) { return this; } + public Builder setCombinedVideo(File combinedVideo) { + checkNotNull(combinedVideo); + checkArgument(this.combinedVideo == null, "Combined video already set."); + this.combinedVideo = combinedVideo; + return this; + } + public DeviceTestResult build() { if (log == null) { log = Collections.emptyList(); diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java new file mode 100644 index 00000000..c06293a9 --- /dev/null +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java @@ -0,0 +1,115 @@ +package com.squareup.spoon; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.google.common.base.Charsets; +import com.google.common.base.Joiner; + +import static com.squareup.spoon.SpoonLogger.logDebug; +import static com.squareup.spoon.SpoonLogger.logError; + +import com.android.ddmlib.IDevice; +import com.android.ddmlib.IShellOutputReceiver; + +/** + * For more information on Android's {@code screenrecord} executable see: + * https://developer.android.com/studio/command-line/adb.html#screenrecord, + * https://android.googlesource.com/platform/frameworks/av/+/android-cts-4.4_r1/cmds/screenrecord/screenrecord.cpp + */ +final class ScreenRecorder implements Closeable { + + static ScreenRecorder open( + IDevice device, File deviceOutputDirectory, ExecutorService executorService, boolean debug) { + return new ScreenRecorder( + device, + deviceOutputDirectory, + DEFAULT_RECORD_BUFFER_DURATION_SECONDS, + DEFAULT_RECORD_BITRATE_MBPS, + executorService, + debug); + } + + private static final String COMMAND_SCREEN_RECORD = "screenrecord"; + private static final String PREFIX_ARGUMENT = "--"; + private static final String ARGUMENT_TIME_LIMIT = PREFIX_ARGUMENT + "time-limit"; + private static final String ARGUMENT_VERBOSE = PREFIX_ARGUMENT + "verbose"; + private static final String ARGUMENT_BITRATE = PREFIX_ARGUMENT + "bit-rate"; + private static final long DEFAULT_RECORD_BUFFER_DURATION_SECONDS = 5L; + private static final int DEFAULT_RECORD_BITRATE_MBPS = 3000000; + + private final Future mRecordingTask; + private final AtomicBoolean mDone = new AtomicBoolean(); + + private ScreenRecorder( + final IDevice device, + File deviceOutputDirectory, + final long recordBufferDurationSeconds, + final int recordBitRateMbps, + ExecutorService executorService, + boolean debug) { + mRecordingTask = executorService.submit(() -> { + long recordingIndex = 0; + while (!mDone.get()) { + try { + File recordingFile = new File(deviceOutputDirectory, "recording_" + recordingIndex + ".mp4"); + String command = Joiner.on(' ').join(new Object[] { + COMMAND_SCREEN_RECORD, + ARGUMENT_TIME_LIMIT, recordBufferDurationSeconds, + ARGUMENT_BITRATE, recordBitRateMbps, + ARGUMENT_VERBOSE, + recordingFile.getAbsolutePath() + }); + logDebug(debug, "Executing command: [%s]", command); + StringBuilder outputBuffer = new StringBuilder(); + CountDownLatch completionLatch = new CountDownLatch(1); + device.executeShellCommand(command, new IShellOutputReceiver() { + @Override + public void addOutput(byte[] data, int offset, int length) { + if (!isCancelled()) { + outputBuffer.append(new String(data, offset, length, Charsets.UTF_8)); + } + } + + @Override + public void flush() { + completionLatch.countDown(); + } + + @Override + public boolean isCancelled() { + return mDone.get(); + } + }); + if (!mDone.get()) { + completionLatch.await(recordBufferDurationSeconds * 2, TimeUnit.SECONDS); + } + logDebug(debug, "Finished command execution with result: [%S]", outputBuffer.toString()); + } catch (Throwable e) { + logError("Failed to record: %s", e); + } + recordingIndex++; + } + }); + } + + @Override + public void close() throws IOException { + if (mDone.compareAndSet(false, true)) { + try { + mRecordingTask.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + throw new IOException(e); + } + } + } +} \ No newline at end of file diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java new file mode 100644 index 00000000..fb6f7cf8 --- /dev/null +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java @@ -0,0 +1,87 @@ +package com.squareup.spoon; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; + +import static org.apache.commons.io.IOUtils.closeQuietly; + +import com.android.ddmlib.CollectingOutputReceiver; +import com.android.ddmlib.IDevice; +import com.android.ddmlib.testrunner.ITestRunListener; +import com.android.ddmlib.testrunner.TestIdentifier; + +final class ScreenRecorderTestRunListener implements ITestRunListener { + + private final IDevice device; + private final String deviceFilesDir; + private final ExecutorService executorService; + private final boolean debug; + + private final Map screenRecorders = new ConcurrentHashMap<>(); + + ScreenRecorderTestRunListener( + IDevice device, + String deviceFilesDir, + ExecutorService executorService, + boolean debug) { + this.device = device; + this.deviceFilesDir = deviceFilesDir; + this.executorService = executorService; + this.debug = debug; + } + + @Override + public void testRunStarted(String runName, int testCount) { + } + + @Override + public void testStarted(TestIdentifier test) { + screenRecorders.put(test, ScreenRecorder.open(device, createDeviceDirectoryFor(test), executorService, debug)); + } + + @Override + public void testFailed(TestIdentifier test, String trace) { + } + + @Override + public void testAssumptionFailure(TestIdentifier test, String trace) { + } + + @Override + public void testIgnored(TestIdentifier test) { + } + + @Override + public void testEnded(TestIdentifier test, Map testMetrics) { + closeQuietly(screenRecorders.remove(test)); + } + + @Override + public void testRunFailed(String errorMessage) { + } + + @Override + public void testRunStopped(long elapsedTime) { + } + + @Override + public void testRunEnded(long elapsedTime, Map runMetrics) { + } + + private File createDeviceDirectoryFor(TestIdentifier testIdentifier) { + try { + CollectingOutputReceiver outputReceiver = new CollectingOutputReceiver(); + device.executeShellCommand("echo $EXTERNAL_STORAGE", outputReceiver); + File deviceTestsDirectory = new File(outputReceiver.getOutput().trim(), deviceFilesDir); + File deviceTestDirectory = new File( + deviceTestsDirectory, testIdentifier.getClassName() + "/" + testIdentifier.getTestName()); + outputReceiver = new CollectingOutputReceiver(); + device.executeShellCommand("mkdir -p " + deviceTestDirectory.getAbsolutePath(), outputReceiver); + return deviceTestDirectory; + } catch (Exception e) { + throw new RuntimeException("Could not get external storage path", e); + } + } +} diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index fb88b302..6911b87e 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -29,6 +29,8 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static com.android.ddmlib.FileListingService.FileEntry; @@ -38,20 +40,24 @@ import static com.squareup.spoon.SpoonLogger.logError; import static com.squareup.spoon.SpoonLogger.logInfo; import static com.squareup.spoon.SpoonUtils.createAnimatedGif; +import static com.squareup.spoon.SpoonUtils.createCombinedVideo; import static com.squareup.spoon.SpoonUtils.obtainDirectoryFileEntry; import static com.squareup.spoon.SpoonUtils.obtainRealDevice; import static com.squareup.spoon.internal.Constants.SPOON_FILES; import static com.squareup.spoon.internal.Constants.SPOON_SCREENSHOTS; +import static com.squareup.spoon.internal.Constants.SPOON_VIDEOS; import static java.util.Collections.emptyMap; /** Represents a single device and the test configuration to be executed. */ public final class SpoonDeviceRunner { private static final String DEVICE_SCREENSHOT_DIR = "app_" + SPOON_SCREENSHOTS; + private static final String DEVICE_VIDEO_DIR = "app_" + SPOON_VIDEOS; private static final String DEVICE_FILE_DIR = "app_" + SPOON_FILES; - private static final String[] DEVICE_DIRS = {DEVICE_SCREENSHOT_DIR, DEVICE_FILE_DIR}; + private static final String[] DEVICE_DIRS = {DEVICE_SCREENSHOT_DIR, DEVICE_VIDEO_DIR, DEVICE_FILE_DIR}; static final String TEMP_DIR = "work"; static final String JUNIT_DIR = "junit-reports"; static final String IMAGE_DIR = "image"; + static final String VIDEO_DIR = "video"; static final String FILE_DIR = "file"; static final String COVERAGE_FILE = "coverage.ec"; static final String COVERAGE_DIR = "coverage"; @@ -71,6 +77,7 @@ public final class SpoonDeviceRunner { private final File work; private final File junitReport; private final File imageDir; + private final File videoDir; private final File coverageDir; private final File fileDir; private final SpoonInstrumentationInfo instrumentationInfo; @@ -120,6 +127,7 @@ public final class SpoonDeviceRunner { this.work = FileUtils.getFile(output, TEMP_DIR, serial); this.junitReport = FileUtils.getFile(output, JUNIT_DIR, serial + ".xml"); this.imageDir = FileUtils.getFile(output, IMAGE_DIR, serial); + this.videoDir = FileUtils.getFile(output, VIDEO_DIR, serial); this.fileDir = FileUtils.getFile(output, FILE_DIR, serial); this.coverageDir = FileUtils.getFile(output, COVERAGE_DIR, serial); this.testRunListeners = testRunListeners; @@ -226,11 +234,12 @@ public DeviceResult run(AndroidDebugBridge adb) { List listeners = new ArrayList<>(); listeners.add(new SpoonTestRunListener(result, debug)); listeners.add(new XmlTestRunListener(junitReport)); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + listeners.add(new ScreenRecorderTestRunListener(device, DEVICE_VIDEO_DIR, executorService, debug)); if (testRunListeners != null) { listeners.addAll(testRunListeners); } MultiRunITestListener multiRunListener = new MultiRunITestListener(listeners); - result.startTests(); multiRunListener.multiRunStarted(recorder.runName(), recorder.testCount()); if (singleInstrumentationCall) { @@ -256,6 +265,7 @@ public DeviceResult run(AndroidDebugBridge adb) { } multiRunListener.multiRunEnded(); result.endTests(); + executorService.shutdown(); mapLogsToTests(deviceLogger, result); @@ -267,6 +277,7 @@ public DeviceResult run(AndroidDebugBridge adb) { } cleanScreenshotsDirectory(result); + cleanVideosDirectory(result); cleanFilesDirectory(result); } catch (Exception e) { @@ -380,6 +391,15 @@ private void cleanScreenshotsDirectory(DeviceResult.Builder result) throws IOExc } } + private void cleanVideosDirectory(DeviceResult.Builder result) throws IOException { + File videosDir = new File(work, DEVICE_VIDEO_DIR); + if (videosDir.exists()) { + videoDir.mkdirs(); + handleVideos(result, videosDir); + FileUtils.deleteDirectory(videosDir); + } + } + private void cleanFilesDirectory(DeviceResult.Builder result) throws IOException { File testFilesDir = new File(work, DEVICE_FILE_DIR); if (testFilesDir.exists()) { @@ -450,6 +470,55 @@ private void handleImages(DeviceResult.Builder result, File screenshotDir) throw } } + private void handleVideos(DeviceResult.Builder result, File videosDir) throws IOException { + logDebug(debug, "Moving videos to the video folder on [%s]", serial); + // Move all children of the screenshot directory into the image folder. + File[] classNameDirs = videosDir.listFiles(); + if (classNameDirs != null) { + Multimap testVideos = ArrayListMultimap.create(); + for (File classNameDir : classNameDirs) { + String className = classNameDir.getName(); + File destDir = new File(videoDir, className); + FileUtils.copyDirectory(classNameDir, destDir); + + // Get a sorted list of all videos from the device run. + List videos = new ArrayList<>( + FileUtils.listFiles(destDir, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE)); + Collections.sort(videos); + + // Iterate over each screenshot and associate it with its corresponding method result. + for (File video : videos) { + String methodName = video.getParentFile().getName(); + + DeviceTest testIdentifier = new DeviceTest(className, methodName); + DeviceTestResult.Builder builder = result.getMethodResultBuilder(testIdentifier); + if (builder != null) { + builder.addVideo(video); + testVideos.put(testIdentifier, video); + } else { + logError("Unable to find test for %s", testIdentifier); + } + } + } + + logDebug(debug, "Generating combined video for [%s] [%s]", serial, testVideos); + // Don't generate animations if the switch is present + if (true) { + // Make combined videos for all the tests which have videos. + for (DeviceTest deviceTest : testVideos.keySet()) { + List videos = new ArrayList<>(testVideos.get(deviceTest)); + if (videos.size() == 1) { + continue; // Do not make a combined video if there is only one video. + } + File video = FileUtils.getFile(videoDir, deviceTest.getClassName(), + deviceTest.getMethodName() + ".mp4"); + createCombinedVideo(videos, video); + result.getMethodResultBuilder(deviceTest).setCombinedVideo(video); + } + } + } + } + private void handleFiles(DeviceResult.Builder result, File testFileDir) throws IOException { File[] classNameDirs = testFileDir.listFiles(); if (classNameDirs != null) { diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java index 93393de0..c7feaee8 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java @@ -3,26 +3,36 @@ import com.android.ddmlib.AndroidDebugBridge; import com.android.ddmlib.DdmPreferences; import com.android.ddmlib.IDevice; +import com.coremedia.iso.boxes.Container; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.googlecode.mp4parser.authoring.Movie; +import com.googlecode.mp4parser.authoring.Track; +import com.googlecode.mp4parser.authoring.builder.DefaultMp4Builder; +import com.googlecode.mp4parser.authoring.container.mp4.MovieCreator; +import com.googlecode.mp4parser.authoring.tracks.AppendTrack; import com.madgag.gif.fmsware.AnimatedGifEncoder; import java.awt.Color; import java.awt.image.BufferedImage; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.time.Duration; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import javax.imageio.ImageIO; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; import static com.android.ddmlib.FileListingService.FileEntry; import static com.android.ddmlib.FileListingService.TYPE_DIRECTORY; @@ -148,6 +158,31 @@ static void createAnimatedGif(List testScreenshots, File animatedGif) thro encoder.finish(); } + static void createCombinedVideo(List testVideos, File video) throws IOException { + FileOutputStream fileOutputStream = new FileOutputStream(video); + try { + List movies = new ArrayList<>(); + for (File recording : testVideos) { + movies.add(MovieCreator.build(recording.getAbsolutePath())); + } + List videoTracks = new LinkedList<>(); + for (Movie movie : movies) { + for (Track track : movie.getTracks()) { + if (!track.getHandler().startsWith("vide") || track.getSyncSamples() == null) { + continue; + } + videoTracks.add(track); + } + } + Movie movie = new Movie(); + movie.addTrack(new AppendTrack(videoTracks.toArray(new Track[videoTracks.size()]))); + Container out = new DefaultMp4Builder().build(movie); + out.writeContainer(fileOutputStream.getChannel()); + } finally { + IOUtils.closeQuietly(fileOutputStream); + } + } + private static void waitForAdb(AndroidDebugBridge adb, Duration timeOut) { long timeOutMs = timeOut.toMillis(); long sleepTimeMs = TimeUnit.SECONDS.toMillis(1); From 5e215519e61d4394385b85f8e525121f361a0916 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 21:15:44 -0800 Subject: [PATCH 3/9] [WIP] --- .../com/squareup/spoon/ScreenRecorder.java | 154 +++++++++--------- .../spoon/ScreenRecorderTestRunListener.java | 139 ++++++++-------- .../com/squareup/spoon/SpoonDeviceRunner.java | 12 +- 3 files changed, 155 insertions(+), 150 deletions(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java index c06293a9..17dfadaa 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java @@ -1,7 +1,6 @@ package com.squareup.spoon; import java.io.Closeable; -import java.io.File; import java.io.IOException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -26,90 +25,89 @@ */ final class ScreenRecorder implements Closeable { - static ScreenRecorder open( - IDevice device, File deviceOutputDirectory, ExecutorService executorService, boolean debug) { - return new ScreenRecorder( - device, - deviceOutputDirectory, - DEFAULT_RECORD_BUFFER_DURATION_SECONDS, - DEFAULT_RECORD_BITRATE_MBPS, - executorService, - debug); - } - - private static final String COMMAND_SCREEN_RECORD = "screenrecord"; - private static final String PREFIX_ARGUMENT = "--"; - private static final String ARGUMENT_TIME_LIMIT = PREFIX_ARGUMENT + "time-limit"; - private static final String ARGUMENT_VERBOSE = PREFIX_ARGUMENT + "verbose"; - private static final String ARGUMENT_BITRATE = PREFIX_ARGUMENT + "bit-rate"; - private static final long DEFAULT_RECORD_BUFFER_DURATION_SECONDS = 5L; - private static final int DEFAULT_RECORD_BITRATE_MBPS = 3000000; + static ScreenRecorder open( + IDevice device, String deviceOutputDirectoryPath, ExecutorService executorService, boolean debug) { + return new ScreenRecorder( + device, + deviceOutputDirectoryPath, + DEFAULT_RECORD_BUFFER_DURATION_SECONDS, + DEFAULT_RECORD_BITRATE_MBPS, + executorService, + debug); + } - private final Future mRecordingTask; - private final AtomicBoolean mDone = new AtomicBoolean(); + private static final String COMMAND_SCREEN_RECORD = "screenrecord"; + private static final String PREFIX_ARGUMENT = "--"; + private static final String ARGUMENT_TIME_LIMIT = PREFIX_ARGUMENT + "time-limit"; + private static final String ARGUMENT_VERBOSE = PREFIX_ARGUMENT + "verbose"; + private static final String ARGUMENT_BITRATE = PREFIX_ARGUMENT + "bit-rate"; + private static final long DEFAULT_RECORD_BUFFER_DURATION_SECONDS = 180L; + private static final int DEFAULT_RECORD_BITRATE_MBPS = 3000000; - private ScreenRecorder( - final IDevice device, - File deviceOutputDirectory, - final long recordBufferDurationSeconds, - final int recordBitRateMbps, - ExecutorService executorService, - boolean debug) { - mRecordingTask = executorService.submit(() -> { - long recordingIndex = 0; - while (!mDone.get()) { - try { - File recordingFile = new File(deviceOutputDirectory, "recording_" + recordingIndex + ".mp4"); - String command = Joiner.on(' ').join(new Object[] { - COMMAND_SCREEN_RECORD, - ARGUMENT_TIME_LIMIT, recordBufferDurationSeconds, - ARGUMENT_BITRATE, recordBitRateMbps, - ARGUMENT_VERBOSE, - recordingFile.getAbsolutePath() - }); - logDebug(debug, "Executing command: [%s]", command); - StringBuilder outputBuffer = new StringBuilder(); - CountDownLatch completionLatch = new CountDownLatch(1); - device.executeShellCommand(command, new IShellOutputReceiver() { - @Override - public void addOutput(byte[] data, int offset, int length) { - if (!isCancelled()) { - outputBuffer.append(new String(data, offset, length, Charsets.UTF_8)); - } - } + private final Future mRecordingTask; + private final AtomicBoolean mDone = new AtomicBoolean(); - @Override - public void flush() { - completionLatch.countDown(); - } + private ScreenRecorder( + IDevice device, + String deviceOutputDirectoryPath, + long recordBufferDurationSeconds, + int recordBitRateMbps, + ExecutorService executorService, + boolean debug) { + mRecordingTask = executorService.submit(() -> { + int recordingIndex = 0; + while (!mDone.get()) { + try { + String command = Joiner.on(' ').join(new Object[]{ + COMMAND_SCREEN_RECORD, + ARGUMENT_TIME_LIMIT, recordBufferDurationSeconds, + ARGUMENT_BITRATE, recordBitRateMbps, + ARGUMENT_VERBOSE, + deviceOutputDirectoryPath + '/' + COMMAND_SCREEN_RECORD + '_' + recordingIndex + ".mp4" + }); + logDebug(debug, "Executing command: [%s]", command); + StringBuilder outputBuffer = new StringBuilder(); + CountDownLatch completionLatch = new CountDownLatch(1); + device.executeShellCommand(command, new IShellOutputReceiver() { + @Override + public void addOutput(byte[] data, int offset, int length) { + if (!isCancelled()) { + outputBuffer.append(new String(data, offset, length, Charsets.UTF_8)); + } + } - @Override - public boolean isCancelled() { - return mDone.get(); - } - }); - if (!mDone.get()) { - completionLatch.await(recordBufferDurationSeconds * 2, TimeUnit.SECONDS); - } - logDebug(debug, "Finished command execution with result: [%S]", outputBuffer.toString()); - } catch (Throwable e) { - logError("Failed to record: %s", e); - } - recordingIndex++; + @Override + public void flush() { + completionLatch.countDown(); } - }); - } - @Override - public void close() throws IOException { - if (mDone.compareAndSet(false, true)) { - try { - mRecordingTask.get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (ExecutionException e) { - throw new IOException(e); + @Override + public boolean isCancelled() { + return mDone.get(); } + }); + if (!mDone.get()) { + completionLatch.await(recordBufferDurationSeconds * 2, TimeUnit.SECONDS); + } + logDebug(debug, "Finished command execution with result: [%S]", outputBuffer.toString()); + } catch (Throwable e) { + logError("Failed to record: %s", e); } + recordingIndex++; + } + }); + } + + @Override + public void close() throws IOException { + if (mDone.compareAndSet(false, true)) { + try { + mRecordingTask.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + throw new IOException(e); + } } + } } \ No newline at end of file diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java index fb6f7cf8..29c2c1fb 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java @@ -1,10 +1,10 @@ package com.squareup.spoon; -import java.io.File; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import static com.squareup.spoon.SpoonLogger.logError; import static org.apache.commons.io.IOUtils.closeQuietly; import com.android.ddmlib.CollectingOutputReceiver; @@ -14,74 +14,75 @@ final class ScreenRecorderTestRunListener implements ITestRunListener { - private final IDevice device; - private final String deviceFilesDir; - private final ExecutorService executorService; - private final boolean debug; - - private final Map screenRecorders = new ConcurrentHashMap<>(); - - ScreenRecorderTestRunListener( - IDevice device, - String deviceFilesDir, - ExecutorService executorService, - boolean debug) { - this.device = device; - this.deviceFilesDir = deviceFilesDir; - this.executorService = executorService; - this.debug = debug; + private final IDevice device; + private final String deviceDirectoryPath; + private final ExecutorService executorService; + private final boolean debug; + + private final Map screenRecorders = new ConcurrentHashMap<>(); + + ScreenRecorderTestRunListener( + IDevice device, + String deviceDirectoryPath, + ExecutorService executorService, + boolean debug) { + this.device = device; + this.deviceDirectoryPath = deviceDirectoryPath; + this.executorService = executorService; + this.debug = debug; + } + + @Override + public void testRunStarted(String runName, int testCount) { + } + + @Override + public void testStarted(TestIdentifier test) { + String deviceDirectory = createDeviceDirectoryFor(test); + if (deviceDirectory != null) { + screenRecorders.put(test, ScreenRecorder.open(device, deviceDirectory, executorService, debug)); } - - @Override - public void testRunStarted(String runName, int testCount) { - } - - @Override - public void testStarted(TestIdentifier test) { - screenRecorders.put(test, ScreenRecorder.open(device, createDeviceDirectoryFor(test), executorService, debug)); - } - - @Override - public void testFailed(TestIdentifier test, String trace) { - } - - @Override - public void testAssumptionFailure(TestIdentifier test, String trace) { - } - - @Override - public void testIgnored(TestIdentifier test) { - } - - @Override - public void testEnded(TestIdentifier test, Map testMetrics) { - closeQuietly(screenRecorders.remove(test)); - } - - @Override - public void testRunFailed(String errorMessage) { - } - - @Override - public void testRunStopped(long elapsedTime) { - } - - @Override - public void testRunEnded(long elapsedTime, Map runMetrics) { - } - - private File createDeviceDirectoryFor(TestIdentifier testIdentifier) { - try { - CollectingOutputReceiver outputReceiver = new CollectingOutputReceiver(); - device.executeShellCommand("echo $EXTERNAL_STORAGE", outputReceiver); - File deviceTestsDirectory = new File(outputReceiver.getOutput().trim(), deviceFilesDir); - File deviceTestDirectory = new File( - deviceTestsDirectory, testIdentifier.getClassName() + "/" + testIdentifier.getTestName()); - outputReceiver = new CollectingOutputReceiver(); - device.executeShellCommand("mkdir -p " + deviceTestDirectory.getAbsolutePath(), outputReceiver); - return deviceTestDirectory; - } catch (Exception e) { - throw new RuntimeException("Could not get external storage path", e); - } + } + + @Override + public void testFailed(TestIdentifier test, String trace) { + } + + @Override + public void testAssumptionFailure(TestIdentifier test, String trace) { + } + + @Override + public void testIgnored(TestIdentifier test) { + } + + @Override + public void testEnded(TestIdentifier test, Map testMetrics) { + closeQuietly(screenRecorders.remove(test)); + } + + @Override + public void testRunFailed(String errorMessage) { + } + + @Override + public void testRunStopped(long elapsedTime) { + } + + @Override + public void testRunEnded(long elapsedTime, Map runMetrics) { + } + + private String createDeviceDirectoryFor(TestIdentifier testIdentifier) { + try { + String deviceTestDirectory = deviceDirectoryPath + '/' + + testIdentifier.getClassName() + '/' + testIdentifier.getTestName(); + CollectingOutputReceiver outputReceiver = new CollectingOutputReceiver(); + device.executeShellCommand("mkdir -p " + deviceTestDirectory, outputReceiver); + return deviceTestDirectory; + } catch (Exception e) { + logError("Failed to create device directory for test [%s] due to [%s]", testIdentifier, e); + return null; } + } } diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index 6911b87e..679641a0 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -235,7 +235,12 @@ public DeviceResult run(AndroidDebugBridge adb) { listeners.add(new SpoonTestRunListener(result, debug)); listeners.add(new XmlTestRunListener(junitReport)); ExecutorService executorService = Executors.newSingleThreadExecutor(); - listeners.add(new ScreenRecorderTestRunListener(device, DEVICE_VIDEO_DIR, executorService, debug)); + try { + listeners.add(new ScreenRecorderTestRunListener( + device, getExternalStoragePath(device, DEVICE_VIDEO_DIR), executorService, debug)); + } catch (Exception e) { + logError("Failed to setup a screen recorder: [%s]", e); + } if (testRunListeners != null) { listeners.addAll(testRunListeners); } @@ -501,14 +506,15 @@ private void handleVideos(DeviceResult.Builder result, File videosDir) throws IO } } - logDebug(debug, "Generating combined video for [%s] [%s]", serial, testVideos); + logDebug(debug, "Generating combined video for [%s]", serial); // Don't generate animations if the switch is present if (true) { // Make combined videos for all the tests which have videos. for (DeviceTest deviceTest : testVideos.keySet()) { List videos = new ArrayList<>(testVideos.get(deviceTest)); if (videos.size() == 1) { - continue; // Do not make a combined video if there is only one video. + result.getMethodResultBuilder(deviceTest).setCombinedVideo(videos.get(0)); + continue; } File video = FileUtils.getFile(videoDir, deviceTest.getClassName(), deviceTest.getMethodName() + ".mp4"); From 889fa13cc049335703f0cff5b1d5082afee16327 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 21:19:50 -0800 Subject: [PATCH 4/9] [Quality] Fix Checkstyle issues --- .../src/main/java/com/squareup/spoon/ScreenRecorder.java | 9 +++++---- .../squareup/spoon/ScreenRecorderTestRunListener.java | 7 ++++--- .../main/java/com/squareup/spoon/SpoonDeviceRunner.java | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java index 17dfadaa..7f75492b 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java @@ -20,13 +20,13 @@ /** * For more information on Android's {@code screenrecord} executable see: - * https://developer.android.com/studio/command-line/adb.html#screenrecord, - * https://android.googlesource.com/platform/frameworks/av/+/android-cts-4.4_r1/cmds/screenrecord/screenrecord.cpp + * https://developer.android.com/studio/command-line/adb.html#screenrecord, https://goo.gl/6deC5j. */ final class ScreenRecorder implements Closeable { static ScreenRecorder open( - IDevice device, String deviceOutputDirectoryPath, ExecutorService executorService, boolean debug) { + IDevice device, String deviceOutputDirectoryPath, + ExecutorService executorService, boolean debug) { return new ScreenRecorder( device, deviceOutputDirectoryPath, @@ -63,7 +63,8 @@ private ScreenRecorder( ARGUMENT_TIME_LIMIT, recordBufferDurationSeconds, ARGUMENT_BITRATE, recordBitRateMbps, ARGUMENT_VERBOSE, - deviceOutputDirectoryPath + '/' + COMMAND_SCREEN_RECORD + '_' + recordingIndex + ".mp4" + deviceOutputDirectoryPath + '/' + + COMMAND_SCREEN_RECORD + '_' + recordingIndex + ".mp4" }); logDebug(debug, "Executing command: [%s]", command); StringBuilder outputBuffer = new StringBuilder(); diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java index 29c2c1fb..c77eacc1 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java @@ -40,7 +40,8 @@ public void testRunStarted(String runName, int testCount) { public void testStarted(TestIdentifier test) { String deviceDirectory = createDeviceDirectoryFor(test); if (deviceDirectory != null) { - screenRecorders.put(test, ScreenRecorder.open(device, deviceDirectory, executorService, debug)); + screenRecorders.put( + test, ScreenRecorder.open(device, deviceDirectory, executorService, debug)); } } @@ -75,8 +76,8 @@ public void testRunEnded(long elapsedTime, Map runMetrics) { private String createDeviceDirectoryFor(TestIdentifier testIdentifier) { try { - String deviceTestDirectory = deviceDirectoryPath + '/' + - testIdentifier.getClassName() + '/' + testIdentifier.getTestName(); + String deviceTestDirectory = deviceDirectoryPath + '/' + + testIdentifier.getClassName() + '/' + testIdentifier.getTestName(); CollectingOutputReceiver outputReceiver = new CollectingOutputReceiver(); device.executeShellCommand("mkdir -p " + deviceTestDirectory, outputReceiver); return deviceTestDirectory; diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index 679641a0..92e605fd 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -53,7 +53,8 @@ public final class SpoonDeviceRunner { private static final String DEVICE_SCREENSHOT_DIR = "app_" + SPOON_SCREENSHOTS; private static final String DEVICE_VIDEO_DIR = "app_" + SPOON_VIDEOS; private static final String DEVICE_FILE_DIR = "app_" + SPOON_FILES; - private static final String[] DEVICE_DIRS = {DEVICE_SCREENSHOT_DIR, DEVICE_VIDEO_DIR, DEVICE_FILE_DIR}; + private static final String[] DEVICE_DIRS = + {DEVICE_SCREENSHOT_DIR, DEVICE_VIDEO_DIR, DEVICE_FILE_DIR}; static final String TEMP_DIR = "work"; static final String JUNIT_DIR = "junit-reports"; static final String IMAGE_DIR = "image"; From c718ed39526a949229358b0cff6b6d65b0bc6404 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 21:51:58 -0800 Subject: [PATCH 5/9] [WIP] Add extra CLI arguments --- README.md | 3 ++ .../main/java/com/squareup/spoon/CliArgs.kt | 5 +++ .../com/squareup/spoon/SpoonDeviceRunner.java | 38 ++++++++++++------- .../java/com/squareup/spoon/SpoonRunner.java | 32 +++++++++++++--- .../src/main/java/com/squareup/spoon/main.kt | 2 + test-app/build.gradle | 1 + 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d420254a..e7b72ae6 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,9 @@ Options: --fail-if-no-device-connected Fail if no device is connected --sequential Execute the tests device by device --init-script Path to a script that you want to run before each device + --disable-gif Disable GIF generation + --record-video Record device screen video + --disable-combined-video Disable combining multiple videos into one --grant-all Grant all runtime permissions during installation on Marshmallow and above devices --e Arguments to pass to the Instrumentation Runner. This can be used multiple times for multiple entries. Usage: --e =. diff --git a/spoon-runner/src/main/java/com/squareup/spoon/CliArgs.kt b/spoon-runner/src/main/java/com/squareup/spoon/CliArgs.kt index e7ca600e..244c8981 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/CliArgs.kt +++ b/spoon-runner/src/main/java/com/squareup/spoon/CliArgs.kt @@ -59,6 +59,11 @@ internal class CliArgs(parser: ArgParser) { val disableGif by parser.flagging("--disable-gif", help = "Disable GIF generation") + val recordVideo by parser.flagging("--record-video", help = "Record device screen video") + + val disabledCombinedVideo by parser.flagging("--disable-combined-video", + help = "Disable combining multiple videos into one") + val adbTimeout by parser.storing("--adb-timeout", help = "Maximum execution time per test. Parsed by java.time.Duration.", transform = Duration::parse).default(null) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index 92e605fd..15431ee1 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -70,6 +70,8 @@ public final class SpoonDeviceRunner { private final int numShards; private final boolean debug; private final boolean noAnimations; + private final boolean recordVideo; + private final boolean noCombinedVideo; private final Duration adbTimeout; private final ImmutableMap instrumentationArgs; private final String className; @@ -103,11 +105,11 @@ public final class SpoonDeviceRunner { * @param testRunListeners Additional TestRunListener or empty list. */ SpoonDeviceRunner(File testApk, List otherApks, File output, String serial, int shardIndex, - int numShards, boolean debug, boolean noAnimations, Duration adbTimeout, - SpoonInstrumentationInfo instrumentationInfo, Map instrumentationArgs, - String className, String methodName, IRemoteAndroidTestRunner.TestSize testSize, - List testRunListeners, boolean codeCoverage, boolean grantAll, - boolean singleInstrumentationCall) { + int numShards, boolean debug, boolean noAnimations, boolean recordVideo, + boolean noCombinedVideo, Duration adbTimeout, SpoonInstrumentationInfo instrumentationInfo, + Map instrumentationArgs, String className, String methodName, + IRemoteAndroidTestRunner.TestSize testSize, List testRunListeners, + boolean codeCoverage, boolean grantAll, boolean singleInstrumentationCall) { this.testApk = testApk; this.otherApks = otherApks; this.serial = serial; @@ -115,6 +117,8 @@ public final class SpoonDeviceRunner { this.numShards = numShards; this.debug = debug; this.noAnimations = noAnimations; + this.recordVideo = recordVideo; + this.noCombinedVideo = noCombinedVideo; this.adbTimeout = adbTimeout; this.instrumentationArgs = ImmutableMap.copyOf(instrumentationArgs != null ? instrumentationArgs : Collections.emptyMap()); @@ -235,13 +239,18 @@ public DeviceResult run(AndroidDebugBridge adb) { List listeners = new ArrayList<>(); listeners.add(new SpoonTestRunListener(result, debug)); listeners.add(new XmlTestRunListener(junitReport)); - ExecutorService executorService = Executors.newSingleThreadExecutor(); - try { - listeners.add(new ScreenRecorderTestRunListener( - device, getExternalStoragePath(device, DEVICE_VIDEO_DIR), executorService, debug)); - } catch (Exception e) { - logError("Failed to setup a screen recorder: [%s]", e); + + ExecutorService screenRecorderExecutor = null; + if (recordVideo) { + try { + screenRecorderExecutor = Executors.newSingleThreadExecutor(); + listeners.add(new ScreenRecorderTestRunListener( + device, getExternalStoragePath(device, DEVICE_VIDEO_DIR), screenRecorderExecutor, debug)); + } catch (Exception e) { + logError("Failed to setup a screen recorder: [%s]", e); + } } + if (testRunListeners != null) { listeners.addAll(testRunListeners); } @@ -271,7 +280,10 @@ public DeviceResult run(AndroidDebugBridge adb) { } multiRunListener.multiRunEnded(); result.endTests(); - executorService.shutdown(); + + if (screenRecorderExecutor != null) { + screenRecorderExecutor.shutdown(); + } mapLogsToTests(deviceLogger, result); @@ -509,7 +521,7 @@ private void handleVideos(DeviceResult.Builder result, File videosDir) throws IO logDebug(debug, "Generating combined video for [%s]", serial); // Don't generate animations if the switch is present - if (true) { + if (!noCombinedVideo) { // Make combined videos for all the tests which have videos. for (DeviceTest deviceTest : testVideos.keySet()) { List videos = new ArrayList<>(testVideos.get(deviceTest)); diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonRunner.java index ecfb6b7f..7fee8ef0 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonRunner.java @@ -49,6 +49,8 @@ public final class SpoonRunner { private final File output; private final boolean debug; private final boolean noAnimations; + private final boolean recordVideo; + private final boolean noCombinedVideo; private final Duration adbTimeout; private final ImmutableMap instrumentationArgs; private final String className; @@ -66,7 +68,8 @@ public final class SpoonRunner { private final boolean singleInstrumentationCall; private SpoonRunner(String title, File androidSdk, File testApk, List otherApks, - File output, boolean debug, boolean noAnimations, Duration adbTimeout, Set serials, + File output, boolean debug, boolean noAnimations, boolean recordVideo, + boolean noCombinedVideo, Duration adbTimeout, Set serials, Set skipDevices, boolean shard, Map instrumentationArgs, String className, String methodName, IRemoteAndroidTestRunner.TestSize testSize, boolean allowNoDevices, List testRunListeners, boolean sequential, @@ -79,6 +82,8 @@ private SpoonRunner(String title, File androidSdk, File testApk, List othe this.output = output; this.debug = debug; this.noAnimations = noAnimations; + this.recordVideo = recordVideo; + this.noCombinedVideo = noCombinedVideo; this.adbTimeout = adbTimeout; this.instrumentationArgs = ImmutableMap.copyOf(instrumentationArgs != null ? instrumentationArgs : emptyMap()); @@ -294,8 +299,9 @@ static boolean parseOverallSuccess(SpoonSummary summary) { private SpoonDeviceRunner getTestRunner(String serial, int shardIndex, int numShards, SpoonInstrumentationInfo testInfo) { return new SpoonDeviceRunner(testApk, otherApks, output, serial, shardIndex, numShards, debug, - noAnimations, adbTimeout, testInfo, instrumentationArgs, className, methodName, testSize, - testRunListeners, codeCoverage, grantAll, singleInstrumentationCall); + noAnimations, recordVideo, noCombinedVideo, adbTimeout, testInfo, instrumentationArgs, + className, methodName, testSize, testRunListeners, codeCoverage, grantAll, + singleInstrumentationCall); } /** Build a test suite for the specified devices and configuration. */ @@ -312,6 +318,8 @@ public static class Builder { private String className; private String methodName; private boolean noAnimations; + private boolean recordVideo; + private boolean noCombinedVideo; private IRemoteAndroidTestRunner.TestSize testSize; private Duration adbTimeout = DEFAULT_ADB_TIMEOUT; private boolean allowNoDevices; @@ -374,6 +382,18 @@ public Builder setNoAnimations(boolean noAnimations) { return this; } + /** Whether or not device screen video should be taken. **/ + public Builder setRecordVideo(boolean recordVideo) { + this.recordVideo = recordVideo; + return this; + } + + /** Whether or not multiple device screen videos should be combined into one. **/ + public Builder setNoCombinedVideo(boolean noCombinedVideo) { + this.noCombinedVideo = noCombinedVideo; + return this; + } + /** Set ADB timeout. */ public Builder setAdbTimeout(Duration value) { this.adbTimeout = value; @@ -475,9 +495,9 @@ public SpoonRunner build() { } return new SpoonRunner(title, androidSdk, testApk, otherApks, output, debug, noAnimations, - adbTimeout, serials, skipDevices, shard, instrumentationArgs, className, methodName, - testSize, allowNoDevices, testRunListeners, sequential, initScript, grantAll, - terminateAdb, codeCoverage, singleInstrumentationCall); + recordVideo, noCombinedVideo, adbTimeout, serials, skipDevices, shard, + instrumentationArgs, className, methodName, testSize, allowNoDevices, testRunListeners, + sequential, initScript, grantAll, terminateAdb, codeCoverage, singleInstrumentationCall); } } diff --git a/spoon-runner/src/main/java/com/squareup/spoon/main.kt b/spoon-runner/src/main/java/com/squareup/spoon/main.kt index e3b5c108..9ebe104f 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/main.kt +++ b/spoon-runner/src/main/java/com/squareup/spoon/main.kt @@ -24,6 +24,8 @@ fun main(vararg args: String) { cli.initScript?.let(this::setInitScript) setGrantAll(cli.grantAll) setNoAnimations(cli.disableGif) + setRecordVideo(cli.recordVideo) + setNoCombinedVideo(cli.disabledCombinedVideo) cli.adbTimeout?.let(this::setAdbTimeout) cli.serials.forEach { addDevice(it) } cli.skipSerials.forEach { skipDevice(it) } diff --git a/test-app/build.gradle b/test-app/build.gradle index fd14eca7..d6853ad8 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -75,6 +75,7 @@ android.testVariants.all { testVariant -> variantOutputs[0].outputFile, '--output', outputDir, '--debug', + '--record-video' ] logger.info("Spoon args: $args") } From 070a0723bae108888309d86ce8f8a410712e0022 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 21:54:56 -0800 Subject: [PATCH 6/9] [Nit] Update commentx --- .../src/main/java/com/squareup/spoon/SpoonDeviceRunner.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index 15431ee1..36bd605b 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -490,7 +490,7 @@ private void handleImages(DeviceResult.Builder result, File screenshotDir) throw private void handleVideos(DeviceResult.Builder result, File videosDir) throws IOException { logDebug(debug, "Moving videos to the video folder on [%s]", serial); - // Move all children of the screenshot directory into the image folder. + // Move all children of the videos directory into the video folder. File[] classNameDirs = videosDir.listFiles(); if (classNameDirs != null) { Multimap testVideos = ArrayListMultimap.create(); From 03a80d514ccad067cff91203ff549c95375bd9a1 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 21:56:45 -0800 Subject: [PATCH 7/9] [Nit] Use static import --- spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java index c7feaee8..8b272822 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java @@ -36,6 +36,7 @@ import static com.android.ddmlib.FileListingService.FileEntry; import static com.android.ddmlib.FileListingService.TYPE_DIRECTORY; +import static org.apache.commons.io.IOUtils.closeQuietly; /** Utilities for executing instrumentation tests on devices. */ public final class SpoonUtils { @@ -179,7 +180,7 @@ static void createCombinedVideo(List testVideos, File video) throws IOExce Container out = new DefaultMp4Builder().build(movie); out.writeContainer(fileOutputStream.getChannel()); } finally { - IOUtils.closeQuietly(fileOutputStream); + closeQuietly(fileOutputStream); } } From dd16c5d4df0687bab112456c7951ac2a3e4e3683 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 22:37:05 -0800 Subject: [PATCH 8/9] [WIP] Add videos to HTML reports --- .../com/squareup/spoon/DeviceTestResult.java | 19 +++++++++++++++-- .../com/squareup/spoon/html/HtmlDevice.java | 16 ++++++++++++-- .../com/squareup/spoon/html/HtmlTest.java | 17 +++++++++++++-- .../com/squareup/spoon/html/HtmlUtils.java | 21 +++++++++++++++++++ .../src/main/resources/page/device.html | 10 ++++++++- .../src/main/resources/page/test.html | 10 ++++++++- 6 files changed, 85 insertions(+), 8 deletions(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java b/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java index 0c5f0da4..2101b38f 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/DeviceTestResult.java @@ -23,18 +23,23 @@ public enum Status { private final StackTrace exception; private final long duration; private final List screenshots; + private final List videos; private final List files; private final File animatedGif; + private final File combinedVideo; private final List log; private DeviceTestResult(Status status, StackTrace exception, long duration, - List screenshots, File animatedGif, List log, List files) { + List screenshots, List videos, File animatedGif, + File combinedVideo, List log, List files) { this.status = status; this.exception = exception; this.duration = duration; this.screenshots = unmodifiableList(new ArrayList<>(screenshots)); + this.videos = unmodifiableList(new ArrayList<>(videos)); this.files = unmodifiableList(new ArrayList<>(files)); this.animatedGif = animatedGif; + this.combinedVideo = combinedVideo; this.log = unmodifiableList(new ArrayList<>(log)); } @@ -58,11 +63,21 @@ public List getScreenshots() { return screenshots; } + /** Videos taken during test. */ + public List getVideos() { + return videos; + } + /** Animated GIF of screenshots. */ public File getAnimatedGif() { return animatedGif; } + /** Combined video of all videos. **/ + public File getCombinedVideo() { + return combinedVideo; + } + /** Arbitrary files saved from the test */ public List getFiles() { return files; @@ -171,7 +186,7 @@ public DeviceTestResult build() { log = Collections.emptyList(); } return new DeviceTestResult(status, exception, duration, - screenshots, animatedGif, log, files); + screenshots, videos, animatedGif, combinedVideo, log, files); } } } diff --git a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlDevice.java b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlDevice.java index ca691272..dc4bdb03 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlDevice.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlDevice.java @@ -84,14 +84,19 @@ static TestResult from(String serial, DeviceTest test, DeviceTestResult result, .stream() .map(screenshot -> HtmlUtils.getScreenshot(screenshot, output)) .collect(toList()); + String animatedGif = HtmlUtils.createRelativeUri(result.getAnimatedGif(), output); + List videos = result.getVideos() + .stream() + .map(video -> HtmlUtils.getVideo(video, output)) + .collect(toList()); + String combinedVideo = HtmlUtils.createRelativeUri(result.getCombinedVideo(), output); List files = result.getFiles() .stream() .map(file -> HtmlUtils.getFile(file, output)) .collect(toList()); - String animatedGif = HtmlUtils.createRelativeUri(result.getAnimatedGif(), output); HtmlUtils.ExceptionInfo exception = HtmlUtils.processStackTrace(result.getException()); return new TestResult(serial, className, methodName, classSimpleName, methodName, - testId, status, screenshots, animatedGif, exception, files); + testId, status, screenshots, animatedGif, videos, combinedVideo, exception, files); } public final String serial; @@ -106,11 +111,15 @@ static TestResult from(String serial, DeviceTest test, DeviceTestResult result, public final List files; public final boolean hasFiles; public final String animatedGif; + public final List videos; + public final boolean hasVideos; + public final String combinedVideo; public final HtmlUtils.ExceptionInfo exception; TestResult(String serial, String className, String methodName, String classSimpleName, String prettyMethodName, String testId, String status, List screenshots, String animatedGif, + List videos, String combinedVideo, HtmlUtils.ExceptionInfo exception, List files) { this.serial = serial; this.className = className; @@ -122,6 +131,9 @@ static TestResult from(String serial, DeviceTest test, DeviceTestResult result, this.hasScreenshots = !screenshots.isEmpty(); this.screenshots = screenshots; this.animatedGif = animatedGif; + this.hasVideos = !videos.isEmpty(); + this.videos = videos; + this.combinedVideo = combinedVideo; this.exception = exception; this.files = files; this.hasFiles = !files.isEmpty(); diff --git a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlTest.java b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlTest.java index b4f7dc67..0962a07d 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlTest.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlTest.java @@ -83,9 +83,15 @@ static TestResult from(String serial, String name, DeviceTestResult result, File .map(screenshot -> HtmlUtils.getScreenshot(screenshot, output)) .collect(toList()); String animatedGif = HtmlUtils.createRelativeUri(result.getAnimatedGif(), output); + List videos = result.getScreenshots() + .stream() + .map(screenshot -> HtmlUtils.getVideo(screenshot, output)) + .collect(toList()); + String combinedVideo = HtmlUtils.createRelativeUri(result.getCombinedVideo(), output); HtmlUtils.ExceptionInfo exception = HtmlUtils.processStackTrace(result.getException()); - return new TestResult(name, serial, status, screenshots, animatedGif, exception); + return new TestResult( + name, serial, status, screenshots, animatedGif, videos, combinedVideo, exception); } public final String name; @@ -94,16 +100,23 @@ static TestResult from(String serial, String name, DeviceTestResult result, File public final boolean hasScreenshots; public final List screenshots; public final String animatedGif; + public final boolean hasVideos; + public final List videos; + public final String combinedVideo; public final HtmlUtils.ExceptionInfo exception; TestResult(String name, String serial, String status, List screenshots, - String animatedGif, HtmlUtils.ExceptionInfo exception) { + String animatedGif, List videos, String combinedVideo, + HtmlUtils.ExceptionInfo exception) { this.name = name; this.serial = serial; this.status = status; this.hasScreenshots = !screenshots.isEmpty(); this.screenshots = screenshots; this.animatedGif = animatedGif; + this.hasVideos = !videos.isEmpty(); + this.videos = videos; + this.combinedVideo = combinedVideo; this.exception = exception; } diff --git a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlUtils.java b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlUtils.java index edada80a..792b0f29 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlUtils.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/html/HtmlUtils.java @@ -127,6 +127,13 @@ static Screenshot getScreenshot(File screenshot, File output) { return new Screenshot(relativePath, caption); } + /** Get a HTML representation of a video with respect to {@code output} directory. */ + static Video getVideo(File video, File output) { + String relativePath = createRelativeUri(video, output); + String name = video.getName(); + return new Video(relativePath, name); + } + public static HtmlUtils.SavedFile getFile(File file, File output) { return new SavedFile(createRelativeUri(file, output), file.getName()); } @@ -193,6 +200,20 @@ static final class Screenshot { } } + static final class Video { + private static final AtomicLong ID = new AtomicLong(0); + + private final long id; + public final String path; + public final String name; + + Video(String path, String name) { + this.id = ID.incrementAndGet(); + this.path = path; + this.name = name; + } + } + static final class SavedFile { private static final AtomicLong ID = new AtomicLong(0); private final long id; diff --git a/spoon-runner/src/main/resources/page/device.html b/spoon-runner/src/main/resources/page/device.html index 7eed3fbd..fe871ac2 100644 --- a/spoon-runner/src/main/resources/page/device.html +++ b/spoon-runner/src/main/resources/page/device.html @@ -61,12 +61,20 @@

{{#animatedGif}} + + + + + + {{/animatedGif}} + {{#combinedVideo}} + - {{/animatedGif}} + {{/combinedVideo}}

{{#exception}}
diff --git a/spoon-runner/src/main/resources/page/test.html b/spoon-runner/src/main/resources/page/test.html index 6c7fdd65..f5faba1a 100644 --- a/spoon-runner/src/main/resources/page/test.html +++ b/spoon-runner/src/main/resources/page/test.html @@ -38,12 +38,20 @@

{{#hasScreenshots}} + + + + + + {{/hasScreenshots}} + {{#combinedVideo}} + - {{/hasScreenshots}} + {{/combinedVideo}}

{{#exception}}
From 21438a2b75b7b1d65eed69050aef0cd80a738a89 Mon Sep 17 00:00:00 2001 From: jokubasdargissc Date: Thu, 18 Jan 2018 22:46:00 -0800 Subject: [PATCH 9/9] [Quality] Fix checkstyle issues --- .../spoon/ScreenRecorderTestRunListener.java | 17 ++++++++++++++++- .../com/squareup/spoon/SpoonDeviceRunner.java | 18 ++++++------------ .../java/com/squareup/spoon/SpoonUtils.java | 1 - 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java index c77eacc1..32f4770b 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorderTestRunListener.java @@ -1,8 +1,11 @@ package com.squareup.spoon; +import java.io.Closeable; +import java.io.IOException; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import static com.squareup.spoon.SpoonLogger.logError; import static org.apache.commons.io.IOUtils.closeQuietly; @@ -12,7 +15,7 @@ import com.android.ddmlib.testrunner.ITestRunListener; import com.android.ddmlib.testrunner.TestIdentifier; -final class ScreenRecorderTestRunListener implements ITestRunListener { +final class ScreenRecorderTestRunListener implements ITestRunListener, Closeable { private final IDevice device; private final String deviceDirectoryPath; @@ -21,6 +24,13 @@ final class ScreenRecorderTestRunListener implements ITestRunListener { private final Map screenRecorders = new ConcurrentHashMap<>(); + ScreenRecorderTestRunListener( + IDevice device, + String deviceDirectoryPath, + boolean debug) { + this(device, deviceDirectoryPath, Executors.newSingleThreadExecutor(), debug); + } + ScreenRecorderTestRunListener( IDevice device, String deviceDirectoryPath, @@ -74,6 +84,11 @@ public void testRunStopped(long elapsedTime) { public void testRunEnded(long elapsedTime, Map runMetrics) { } + @Override + public void close() throws IOException { + executorService.shutdown(); + } + private String createDeviceDirectoryFor(TestIdentifier testIdentifier) { try { String deviceTestDirectory = deviceDirectoryPath + '/' diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java index 36bd605b..910bcca2 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonDeviceRunner.java @@ -29,8 +29,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import static com.android.ddmlib.FileListingService.FileEntry; @@ -47,6 +45,7 @@ import static com.squareup.spoon.internal.Constants.SPOON_SCREENSHOTS; import static com.squareup.spoon.internal.Constants.SPOON_VIDEOS; import static java.util.Collections.emptyMap; +import static org.apache.commons.io.IOUtils.closeQuietly; /** Represents a single device and the test configuration to be executed. */ public final class SpoonDeviceRunner { @@ -239,18 +238,16 @@ public DeviceResult run(AndroidDebugBridge adb) { List listeners = new ArrayList<>(); listeners.add(new SpoonTestRunListener(result, debug)); listeners.add(new XmlTestRunListener(junitReport)); - - ExecutorService screenRecorderExecutor = null; + ScreenRecorderTestRunListener screenRecorderTestRunListener = null; if (recordVideo) { try { - screenRecorderExecutor = Executors.newSingleThreadExecutor(); - listeners.add(new ScreenRecorderTestRunListener( - device, getExternalStoragePath(device, DEVICE_VIDEO_DIR), screenRecorderExecutor, debug)); + screenRecorderTestRunListener = new ScreenRecorderTestRunListener( + device, getExternalStoragePath(device, DEVICE_VIDEO_DIR), debug); + listeners.add(screenRecorderTestRunListener); } catch (Exception e) { logError("Failed to setup a screen recorder: [%s]", e); } } - if (testRunListeners != null) { listeners.addAll(testRunListeners); } @@ -280,10 +277,7 @@ public DeviceResult run(AndroidDebugBridge adb) { } multiRunListener.multiRunEnded(); result.endTests(); - - if (screenRecorderExecutor != null) { - screenRecorderExecutor.shutdown(); - } + closeQuietly(screenRecorderTestRunListener); mapLogsToTests(deviceLogger, result); diff --git a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java index 8b272822..383bdec4 100644 --- a/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java +++ b/spoon-runner/src/main/java/com/squareup/spoon/SpoonUtils.java @@ -32,7 +32,6 @@ import java.util.regex.Pattern; import javax.imageio.ImageIO; import org.apache.commons.io.FileUtils; -import org.apache.commons.io.IOUtils; import static com.android.ddmlib.FileListingService.FileEntry; import static com.android.ddmlib.FileListingService.TYPE_DIRECTORY;