Skip to content
This repository was archived by the owner on Oct 7, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>=<VALUE>.
Expand Down
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -45,7 +46,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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "_";
}
1 change: 1 addition & 0 deletions spoon-runner/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions spoon-runner/src/main/java/com/squareup/spoon/CliArgs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,23 @@ public enum Status {
private final StackTrace exception;
private final long duration;
private final List<File> screenshots;
private final List<File> videos;
private final List<File> files;
private final File animatedGif;
private final File combinedVideo;
private final List<LogCatMessage> log;

private DeviceTestResult(Status status, StackTrace exception, long duration,
List<File> screenshots, File animatedGif, List<LogCatMessage> log, List<File> files) {
List<File> screenshots, List<File> videos, File animatedGif,
File combinedVideo, List<LogCatMessage> log, List<File> 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));
}

Expand All @@ -58,11 +63,21 @@ public List<File> getScreenshots() {
return screenshots;
}

/** Videos taken during test. */
public List<File> 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<File> getFiles() {
return files;
Expand All @@ -75,11 +90,13 @@ public List<LogCatMessage> getLog() {
public static class Builder {
private final List<File> screenshots = new ArrayList<>();
private final List<File> files = new ArrayList<>();
private final List<File> 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<LogCatMessage> log;

public Builder markTestAsFailed(String message) {
Expand Down Expand Up @@ -138,6 +155,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);
Expand All @@ -151,12 +174,19 @@ 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();
}
return new DeviceTestResult(status, exception, duration,
screenshots, animatedGif, log, files);
screenshots, videos, animatedGif, combinedVideo, log, files);
}
}
}
114 changes: 114 additions & 0 deletions spoon-runner/src/main/java/com/squareup/spoon/ScreenRecorder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.squareup.spoon;

import java.io.Closeable;
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://goo.gl/6deC5j.
*/
final class ScreenRecorder implements Closeable {

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 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 final Future<?> mRecordingTask;
private final AtomicBoolean mDone = new AtomicBoolean();

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 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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;

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, Closeable {

private final IDevice device;
private final String deviceDirectoryPath;
private final ExecutorService executorService;
private final boolean debug;

private final Map<TestIdentifier, ScreenRecorder> screenRecorders = new ConcurrentHashMap<>();

ScreenRecorderTestRunListener(
IDevice device,
String deviceDirectoryPath,
boolean debug) {
this(device, deviceDirectoryPath, Executors.newSingleThreadExecutor(), debug);
}

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 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<String, String> testMetrics) {
closeQuietly(screenRecorders.remove(test));
}

@Override
public void testRunFailed(String errorMessage) {
}

@Override
public void testRunStopped(long elapsedTime) {
}

@Override
public void testRunEnded(long elapsedTime, Map<String, String> runMetrics) {
}

@Override
public void close() throws IOException {
executorService.shutdown();
}

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;
}
}
}
Loading