Docker в последнее время у всех на слуху. Многим автоматизаторам наверняка уже удалось пощупать публичные Selenium images. В данной статье мы не будем говорить о том, что такое Docker
. Не будем также вдаваться в аспекты базовой конфигурации Selenium Grid
контейнеров. Это скучно, ибо во всемирной паутине собрано уже немало гайдов на сей счет.
Ввиду того, что сами контейнеры в большинстве случаев запускаются в форме демонов, порой встает острая необходимость каким-то образом визуализировать то, что происходит внутри во время UI tests execution phase.
Спору нет, можно подключаться vnc viewer’ом к активной сессии и воочию наблюдать за происходящим. Но это скучно. Я не хочу быть прикованным к экрану, жертвуя чашечкой отменного чая / кофе.
Можно пойти “дедовским” путем, и снимать скриншоты на каждый чих. И в целом, скорее всего этого будет почти всегда достаточно. Но мы ведь не для этого затеяли разговор о докере, ведь так?
Следуя путем развития корпорации добра, где видео запись всего, что происходит во время выполнения, - вполне обыденное явление, нам бы наверняка хотелось бы поддерживать нечто подобное на уровне контейнеров. В целом, данная идея уже была реализована активистами при помощи средств supervisord. Но, как впоследствии признался один из авторов, его команда предпочла бы чистый java approach ранее выбранному bash / docker-based
.
Дабы слишком не томить, любители почитать на буржуйском могут сразу же обратиться к оригиналу. Всем остальным ниже приведу краткую выдержку на русском.
Как вы уже поняли, основной целью является добавление поддержки видео записи активной selenium-сессии с минимальным вмешательством в содержимое самого контейнера.
Реализация будет состоять из 3-х частей:
- Модификация
selenium-server-standalone
. - Модификация базовых
selenium
образов. - Клиентский код по активации видео записи с последующей вставкой в Allure report.
Сразу оговорюсь, что html5 video attachment фича пока еще не релизнута, посему для ее использования вам придется самим собрать последний Allure snapshot.
Для записи видео в mp4 формате был выбран avconv, который заменил всем известный ffmpeg в последних официальных Ubuntu репозиториях. По сути один тул появился из другого, так что отличий между ними практически нет.
Дабы минимизировать вмешательство в docker-selenium
, саппорт avconv
будет добавлен на уровне selenium-server-standalone
. Для этого достаточно написать небольшой utility класс с соответствующими обработчиками.
public static void startVideoRecording(final VideoInfo info) {
final String outputPath = parseFileName(info.getStoragePath() + "/tmp", info.getFileName(), "mp4");
final String display = System.getenv("DISPLAY");
CompletableFuture.supplyAsync(() -> runCommand("avconv",
"-f", "x11grab",
"-an",
"-s", "1360x1020",
"-i", display,
"-vcodec", "libx264",
"-crf", String.valueOf(info.getQuality()),
"-r", String.valueOf(info.getFrameRate()),
outputPath))
.whenCompleteAsync((output, errors) -> {
LOGGER.info("Start recording output log: " + output + (errors != null ? "; ex: " + errors : ""));
LOGGER.info("Trying to copy " + outputPath + " to the main folder.");
copyFile(info.getStoragePath(), info.getFileName());
});
}
public static void stopVideoRecording() {
LOGGER.info("Stop recording output log: " + runCommand("pkill", "-INT", "avconv"));
}
Детальней о списке поддерживаемых аргументов можно прочесть в официальной документации avconv
, а также - в статье-оригинале.
Ввиду того, что процесс записи по сути бесконечен, нам следует позаботиться о двух вещах:
- Неблокирующей записи.
- Безопасном ее завершении.
Первый пункт достигается использованием CompletableFuture, снимающим зависимость с основного test execution процесса Selenium Grid
, а также позволяющим нам определить точный момент завершения видео записи. Вторая проблема решается путем отправки INT
сигнала avconv
процессу.
При этом, старт / остановка видео записи будет контролироваться специальным сервлетом, получающим соответствующие команды от прокси.
@Override
protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException, IllegalArgumentException {
process(request, response);
}
private void process(final HttpServletRequest request, final HttpServletResponse response) throws IOException, IllegalArgumentException {
final Command command = Optional.ofNullable(request.getParameter("command"))
.map(Command::valueOf)
.orElseThrow(() -> new IllegalArgumentException("Unable to find command for video recording"));
try {
switch (command) {
case START_RECORDING:
stopVideoRecording();
startVideoRecording(getVideoInfo(request));
updateResponse(response, HttpStatus.SC_OK, "Started recording");
break;
case STOP_RECORDING:
stopVideoRecording();
updateResponse(response, HttpStatus.SC_OK, "Stopped recording");
break;
}
} catch (Exception ex) {
LOGGER.severe("Unable to process recording: " + ex);
updateResponse(response, HttpStatus.SC_INTERNAL_SERVER_ERROR,
"Internal server error occurred while trying to start / stop recording: " + ex);
}
}
@Override
public void beforeSession(final TestSession session) {
super.beforeSession(session);
processRecording(session, Command.START_RECORDING);
}
@Override
public void afterSession(final TestSession session) {
super.afterSession(session);
processRecording(session, Command.STOP_RECORDING);
}
private void processRecording(final TestSession session, final Command command) {
final String videoInfo = getCapability(session, "videoInfo");
if (!videoInfo.isEmpty()) {
final String url = "http://" + this.getRemoteHost().getHost() + ":" + this.getRemoteHost().getPort() +
"/extra/" + VideoRecordingServlet.class.getSimpleName() + "?command=" + command;
switch (command) {
case START_RECORDING:
sendRecordingRequest(url, videoInfo);
break;
case STOP_RECORDING:
sendRecordingRequest(url, "");
break;
}
}
}
private void sendRecordingRequest(final String url, final String entity) {
CloseableHttpResponse response = null;
try (final CloseableHttpClient client = HttpClientBuilder.create().build()) {
final HttpPost post = new HttpPost(url);
if (!entity.isEmpty()) {
post.setEntity(new StringEntity(entity, ContentType.APPLICATION_JSON));
}
response = client.execute(post);
LOGGER.info("Node response: " + response);
} catch (Exception ex) {
LOGGER.severe("Unable to send recording request to node: " + ex);
} finally {
HttpClientUtils.closeQuietly(response);
}
}
Как вы уже наверное догадались, команды отправляются по событиям beforeSession \ afterSession
в зависимости от наличия кастомного videoInfo
capability, ожидаемого со стороны клиента в json
формате.
В целом, этого вполне достаточно, чтобы пересобрать selenium-server-standalone
с поддержкой видео записи на борту.
Далее необходимо немного модифицировать docker-selenium
образы для поддержки пересобранного грида.
Base image должен теперь стягивать selenium-server-standalone
не из официальных источников, а по заданному нами локальному пути.
#==========
# Selenium
#==========
RUN mkdir -p /opt/selenium
COPY lib/selenium-grid-2.53.0-jar-with-dependencies.jar /opt/selenium/selenium-server-standalone.jar
Для активации video recording feature
, нужно модифицировать config.json
у NodeChrome и NodeFirefox соответственно, добавив ссылки на кастомный прокси и сервлет.
{
"capabilities": [
{
"browserName": "chrome",
"maxInstances": 1,
"seleniumProtocol": "WebDriver"
}
],
"configuration": {
"proxy": "com.blogspot.notes.automation.qa.grid.HubProxy",
"servlets": "com.blogspot.notes.automation.qa.grid.VideoRecordingServlet",
"maxSession": 1,
"port": 5555,
"register": true,
"registerCycle": 5000
}
}
Ну и последним шагом является установка avconv
, входящий в состав libav-tools
, в контейнеры ChromeNodeDebug и FirefoxNodeDebug.
#=====
# VNC, avconv
#=====
RUN apt-get update -qqy \
&& apt-get -qqy install \
x11vnc \
libav-tools \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p ~/.vnc \
&& x11vnc -storepasswd secret ~/.vnc/passwd
После сборки всей цепочки новых selenium
образов, можно воспользоваться docker-compose для упрощения поднятия грида, а также минимизации взаимодействия с терминалом.
Последний шаг касается клиентской части. Давайте посмотрим, каким же образом мы сможем тригерить процесс видео записи с последующим ее прикреплением к репорту.
Как я уже ранее отмечал, кастомный прокси реагирует на спец. capability
в json формате.
private Capabilities getCapabilities() {
DesiredCapabilities capabilities = new DesiredCapabilities();
capabilities.setBrowserName(getBrowser());
capabilities.setPlatform(getDefaultPlatform());
String videoInfo = getVideoRecordingInfo();
if (!videoInfo.isEmpty()) {
capabilities.setCapability("videoInfo", videoInfo);
}
return capabilities;
}
private String getVideoRecordingInfo() {
try {
VideoInfo videoInfo = new VideoInfo(Constants.VIDEO_OUTPUT_DIR, getVideoRecordingPath(),
Constants.QUALITY, Constants.FRAME_RATE);
return new ObjectMapper().writeValueAsString(videoInfo);
} catch (Exception ignored) {
return "";
}
}
Где VideoInfo
является простым POJO, содержащим необходимую видео конфигурацию, которая в последствии трансформируется в json, и будет передана хабу вместе с остальными DesiredCapabilities
.
Для прикрепления mp4-записи к репорту, необходимо определить следующий метод:
@Attachment(value = "{0}", type = "video/mp4")
public byte[] attachVideo(String name, String path) {
try {
File mp4 = new File(WORK_DIR + "/" + path + ".mp4");
File mp4Tmp = new File(WORK_DIR + "/tmp/" + path + ".mp4");
await().atMost(Constants.VIDEO_WAIT_TIMEOUT, TimeUnit.SECONDS)
.pollDelay(1, TimeUnit.SECONDS)
.ignoreExceptions()
.until(() -> mp4.exists() && mp4.length() == mp4Tmp.length());
return Files.toByteArray(mp4);
} catch (Exception ignored) {
return new byte[0];
}
}
Следует пояснить один маленький нюанс. После завершения записи происходит стадия финализации видео, которую мы не можем никак контролировать. Время ожидания может зависеть от кол-ва используемых потоков, а также - доступных аппаратных ресурсов. Попытки преобразования незавершенного видео в массив байт приведут к невозможности его воспроизведения в репорте.
Для решения данной проблемы все видео изначально пишутся во временный каталог, после чего начинается фаза копирования в основной. Факт появления записи в основном каталоге свидетельствует о том, что финализация полностью завершена. Но мы по-прежнему не можем прерывать этот процесс до полного завершения копирования. С другой стороны, это дает нам возможность получить точный размер финализированного видео из временного каталога. Остается лишь дождаться, пока размеры обоих записей не окажутся одинаковыми, что послужит сигналом к передаче соответствующего байт массива Allure обработчику.
Вот собственно и все. Ссылки на исходники доступны в оригинальной статье. Ну а сам результат можно наблюдать из следующего короткого демо: