Skip to content

Commit

Permalink
[plugin-mobitru] Add ability to capture video recordings (#5307)
Browse files Browse the repository at this point in the history
Co-authored-by: Valery Yatsynovich <[email protected]>
  • Loading branch information
dubovik-sergey and valfirst authored Aug 20, 2024
1 parent fa31539 commit e41e651
Show file tree
Hide file tree
Showing 22 changed files with 819 additions and 36 deletions.
6 changes: 6 additions & 0 deletions docs/modules/plugins/pages/plugin-mobitru.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ a|`true` +
|`false`
|Inject special code into application to allow emulation of "touch id" action and QR code scan.

|`mobitru.video-recording-enabled`
a|`true` +
`false`
|`false`
|Enable the video recording for entire appium session. The output recording will be attached to the report.

|`mobitru.device-wait-timeout`
|The duration in {durations-format-link} format.
|`PT5M`
Expand Down
1 change: 1 addition & 0 deletions vividus-plugin-mobitru/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies {
implementation project(':vividus-http-client')
implementation project(':vividus-extension-selenium')
implementation project(':vividus-plugin-mobile-app')
implementation project(':vividus-reporter')

implementation platform(group: 'org.slf4j', name: 'slf4j-bom', version: '2.0.16')
implementation(group: 'org.slf4j', name: 'slf4j-api')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ public void returnDevice(String deviceId) throws MobitruOperationException
executeRequest(DEVICE_PATH + "/" + deviceId, HttpMethod.DELETE, UnaryOperator.identity(), HttpStatus.SC_OK);
}

public void startDeviceScreenRecording(String deviceId) throws MobitruOperationException
{
performDeviceRecordingRequest(deviceId, HttpMethod.POST, HttpStatus.SC_CREATED);
}

public byte[] stopDeviceScreenRecording(String deviceId) throws MobitruOperationException
{
return performDeviceRecordingRequest(deviceId, HttpMethod.DELETE, HttpStatus.SC_OK);
}

private byte[] performDeviceRecordingRequest(String deviceId, HttpMethod httpMethod, int status)
throws MobitruOperationException
{
String url = String.format("%s/%s/recording", DEVICE_PATH, deviceId);
return executeRequest(url, httpMethod, UnaryOperator.identity(), status);
}

public byte[] downloadDeviceScreenRecording(String recordingId) throws MobitruOperationException
{
return executeRequest("/recording/" + recordingId, HttpMethod.GET,
UnaryOperator.identity(), HttpStatus.SC_OK);
}

public void installApp(String deviceId, String appId, InstallApplicationOptions options)
throws MobitruOperationException
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ public interface MobitruFacade
*/
String takeDevice(DesiredCapabilities desiredCapabilities) throws MobitruOperationException;

/**
* Start the screen recording on the device with specified UDID.
*
* @param deviceId The UDID of the device
* @throws MobitruOperationException In case of any issues during start the recording.
*/
void startDeviceScreenRecording(String deviceId) throws MobitruOperationException;

/**
* Stop the screen recording on the device with specified UDID.
*
* @param deviceId The UDID of the device
* @return The ID of the recorded artifact.
* @throws MobitruOperationException In case of any issues during start the recording.
*/
ScreenRecording stopDeviceScreenRecording(String deviceId) throws MobitruOperationException;

/**
* Installs the desired application on the device with specified UDID
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;

Expand All @@ -35,12 +36,14 @@
import org.openqa.selenium.remote.DesiredCapabilities;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
import org.vividus.mobitru.client.exception.MobitruDeviceSearchException;
import org.vividus.mobitru.client.exception.MobitruDeviceTakeException;
import org.vividus.mobitru.client.exception.MobitruOperationException;
import org.vividus.mobitru.client.model.Application;
import org.vividus.mobitru.client.model.Device;
import org.vividus.mobitru.client.model.DeviceSearchParameters;
import org.vividus.mobitru.client.model.ScreenRecordingMetadata;
import org.vividus.util.wait.DurationBasedWaiter;
import org.vividus.util.wait.RetryTimesBasedWaiter;
import org.vividus.util.wait.WaitMode;
Expand All @@ -54,9 +57,11 @@ public class MobitruFacadeImpl implements MobitruFacade
private static final String APPIUM_UDID = "appium:udid";

private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

private static final int RETRY_TIMES = 20;
private static final Duration DOWNLOAD_RECORDING_POOLING_TIMEOUT = Duration.ofSeconds(5);
private static final int DOWNLOAD_RECORDING_RETRY_COUNT = 10;

private final MobitruClient mobitruClient;
private Duration waitForDeviceTimeout;
Expand All @@ -83,8 +88,9 @@ public String takeDevice(DesiredCapabilities desiredCapabilities) throws Mobitru
//use different API in case if udid is provided in capabilities
//it's required in some cases like if the device is already taken
LOGGER.info("Trying to take device with udid {}", deviceUdid);
return takeDevice(() -> mobitruClient.takeDeviceBySerial(String.valueOf(deviceUdid)),
() -> "Unable to take device with udid " + deviceUdid, getDefaultDeviceWaiter());
return takeDevice(getDefaultDeviceWaiter(),
() -> mobitruClient.takeDeviceBySerial(String.valueOf(deviceUdid)),
() -> "Unable to take device with udid " + deviceUdid);
}
Device device = new Device();
device.setDesiredCapabilities(desiredCapabilities.asMap());
Expand All @@ -109,32 +115,41 @@ public void returnDevice(String deviceId) throws MobitruOperationException
mobitruClient.returnDevice(deviceId);
}

@Override
public void startDeviceScreenRecording(String deviceId) throws MobitruOperationException
{
mobitruClient.startDeviceScreenRecording(deviceId);
}

@Override
public ScreenRecording stopDeviceScreenRecording(String deviceId) throws MobitruOperationException
{
byte[] receivedRecordingInfo = mobitruClient.stopDeviceScreenRecording(deviceId);
ScreenRecordingMetadata screenRecordingMetadataInfo = performMapperOperation(mapper ->
mapper.readValue(receivedRecordingInfo, ScreenRecordingMetadata.class));
String recordingId = screenRecordingMetadataInfo.recordingId();
Waiter waiter = new RetryTimesBasedWaiter(DOWNLOAD_RECORDING_POOLING_TIMEOUT,
DOWNLOAD_RECORDING_RETRY_COUNT);
byte[] recordingContent = performWaiterOperation(waiter,
() -> mobitruClient.downloadDeviceScreenRecording(recordingId), Level.DEBUG,
"download device screen recording",
e -> new MobitruOperationException("Unable to download recording with id " + recordingId, e));
return new ScreenRecording(recordingId, recordingContent);
}

private String takeDevice(Device device, Waiter deviceWaiter) throws MobitruOperationException
{
LOGGER.info("Trying to take device with configuration {}", device);
String capabilities = performMapperOperation(mapper -> mapper.writeValueAsString(device));
return takeDevice(() -> mobitruClient.takeDevice(capabilities),
() -> "Unable to take device with configuration " + capabilities, deviceWaiter);
return takeDevice(deviceWaiter, () -> mobitruClient.takeDevice(capabilities),
() -> "Unable to take device with configuration " + capabilities);
}

private String takeDevice(FailableSupplier<byte[], MobitruOperationException> takeDeviceActions,
Supplier<String> unableToTakeDeviceErrorMessage, Waiter deviceWaiter) throws MobitruOperationException
private String takeDevice(Waiter waiter, FailableSupplier<byte[], MobitruOperationException> takeDeviceOperation,
Supplier<String> unableToTakeDeviceErrorMessage) throws MobitruOperationException
{
byte[] receivedDevice = deviceWaiter.wait(() -> {
try
{
return takeDeviceActions.get();
}
catch (MobitruDeviceTakeException e)
{
LOGGER.warn("Unable to take device, retrying attempt.", e);
return null;
}
}, Objects::nonNull);
if (null == receivedDevice)
{
throw new MobitruDeviceTakeException(unableToTakeDeviceErrorMessage.get());
}
byte[] receivedDevice = performWaiterOperation(waiter, takeDeviceOperation, Level.WARN, "take device",
e -> new MobitruDeviceTakeException(unableToTakeDeviceErrorMessage.get(), e));
Device takenDevice = performMapperOperation(mapper -> mapper.readValue(receivedDevice, Device.class));
LOGGER.info("Device with configuration {} is taken", takenDevice);
return (String) takenDevice.getDesiredCapabilities().get(UDID);
Expand All @@ -150,7 +165,7 @@ private String takeDevice(List<Device> devices) throws MobitruOperationException
{
return takeDevice(devicesIterator.next(), waiter);
}
catch (MobitruDeviceTakeException e)
catch (MobitruOperationException e)
{
if (!devicesIterator.hasNext())
{
Expand Down Expand Up @@ -193,10 +208,10 @@ private String findApp(String appRealName) throws MobitruOperationException
{
byte[] appsResponse = mobitruClient.getArtifacts();
List<Application> applications = performMapperOperation(mapper -> mapper.readerForListOf(Application.class)
.readValue(appsResponse));
.readValue(appsResponse));
return applications.stream().filter(a -> appRealName.equals(a.getRealName())).findFirst().orElseThrow(
() -> new MobitruOperationException(String.format("Unable to find application with the name `%s`."
+ " The available applications are: %s",
+ " The available applications are: %s",
appRealName, applications))).getId();
}

Expand All @@ -213,6 +228,26 @@ private <R> R performMapperOperation(FailableFunction<ObjectMapper, R, IOExcepti
}
}

private byte[] performWaiterOperation(Waiter waiter, FailableSupplier<byte[], MobitruOperationException> operation,
Level logLevel, String operationTitle,
Function<MobitruOperationException, ? extends MobitruOperationException> exceptionOnMissingResult)
throws MobitruOperationException
{
AtomicReference<MobitruOperationException> lastException = new AtomicReference<>(null);
return waiter.<Optional<byte[]>, RuntimeException>wait(() -> {
try
{
return Optional.of(operation.get());
}
catch (MobitruOperationException e)
{
LOGGER.atLevel(logLevel).setCause(e).log("Unable to {}, retrying attempt", operationTitle);
lastException.set(e);
return Optional.empty();
}
}, Optional::isPresent).orElseThrow(() -> exceptionOnMissingResult.apply(lastException.get()));
}

private boolean isSearchForDevice(DesiredCapabilities desiredCapabilities)
{
Map<String, Object> capabilities = desiredCapabilities.asMap();
Expand All @@ -224,7 +259,7 @@ private boolean isSearchForDevice(DesiredCapabilities desiredCapabilities)
.filter(capabilities::containsKey).findFirst();
Validate.isTrue(conflictingCapability.isEmpty(),
"Conflicting capabilities are found. `%s` capability can not be specified along with "
+ "`mobitru-device-search:` capabilities",
+ "`mobitru-device-search:` capabilities",
conflictingCapability.orElse(null));
}
return containsSearchCapabilities;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2019-2024 the original author or authors.
*
* 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
*
* https://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 org.vividus.mobitru.client;

import java.util.Arrays;
import java.util.Objects;

public record ScreenRecording(String recordingId, byte[] content)
{
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (!(o instanceof ScreenRecording that))
{
return false;
}
return Objects.equals(recordingId, that.recordingId) && Arrays.equals(content, that.content);
}

@Override
public int hashCode()
{
return Objects.hash(recordingId, Arrays.hashCode(content));
}

@Override
public String toString()
{
StringBuilder result = new StringBuilder("ScreenRecording{recordingId=").append(recordingId).append(", ");
if (content == null)
{
result.append("content=null");
}
else
{
result.append("contentLength=").append(content.length).append(" bytes");
}
return result.append('}').toString();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2023 the original author or authors.
* Copyright 2019-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,4 +24,9 @@ public MobitruDeviceTakeException(String message)
{
super(message);
}

public MobitruDeviceTakeException(String message, Throwable cause)
{
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2023 the original author or authors.
* Copyright 2019-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -29,4 +29,9 @@ public MobitruOperationException(Throwable cause)
{
super(cause);
}

public MobitruOperationException(String message, Throwable cause)
{
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright 2019-2024 the original author or authors.
*
* 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
*
* https://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 org.vividus.mobitru.client.model;

public record ScreenRecordingMetadata(String recordingId)
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@
public class MobitruCapabilitiesAdjuster extends DesiredCapabilitiesAdjuster
{
private static final String APPIUM_UDID = "appium:udid";
private final MobitruFacade mobitruFacade;

private final InstallApplicationOptions installApplicationOptions;
private final MobitruFacade mobitruFacade;
private final MobitruSessionInfoStorage mobitruSessionInfoStorage;
private String appFileName;

public MobitruCapabilitiesAdjuster(InstallApplicationOptions installApplicationOptions, MobitruFacade mobitruFacade)
public MobitruCapabilitiesAdjuster(InstallApplicationOptions installApplicationOptions, MobitruFacade mobitruFacade,
MobitruSessionInfoStorage mobitruSessionInfoStorage)
{
this.installApplicationOptions = installApplicationOptions;
this.mobitruFacade = mobitruFacade;
this.mobitruSessionInfoStorage = mobitruSessionInfoStorage;
}

@Override
Expand All @@ -46,6 +49,7 @@ public Map<String, Object> getExtraCapabilities(DesiredCapabilities desiredCapab
{
deviceId = mobitruFacade.takeDevice(desiredCapabilities);
mobitruFacade.installApp(deviceId, appFileName, installApplicationOptions);
mobitruSessionInfoStorage.saveDeviceId(deviceId);
Map<String, Object> capabilities = desiredCapabilities.asMap();
if (capabilities.containsKey(APPIUM_UDID) || capabilities.containsKey("udid"))
{
Expand Down
Loading

0 comments on commit e41e651

Please sign in to comment.