Docker, Selenium and Allure video attachment support

Docker в последнее время у всех на слуху. Многим автоматизаторам наверняка уже удалось пощупать публичные Selenium images. В данной статье мы не будем говорить о том, что такое Docker. Не будем также вдаваться в аспекты базовой конфигурации Selenium Grid контейнеров. Это скучно, ибо во всемирной паутине собрано уже немало гайдов на сей счет.

Ввиду того, что сами контейнеры в большинстве случаев запускаются в форме демонов, порой встает острая необходимость каким-то образом визуализировать то, что происходит внутри во время UI tests execution phase.

Спору нет, можно подключаться vnc viewer’ом к активной сессии и воочию наблюдать за происходящим. Но это скучно. Я не хочу быть прикованным к экрану, жертвуя чашечкой отменного чая / кофе. :smiley:

Можно пойти “дедовским” путем, и снимать скриншоты на каждый чих. И в целом, скорее всего этого будет почти всегда достаточно. Но мы ведь не для этого затеяли разговор о докере, ведь так? :slight_smile:

Следуя путем развития корпорации добра, где видео запись всего, что происходит во время выполнения, - вполне обыденное явление, нам бы наверняка хотелось бы поддерживать нечто подобное на уровне контейнеров. В целом, данная идея уже была реализована активистами при помощи средств supervisord. Но, как впоследствии признался один из авторов, его команда предпочла бы чистый java approach ранее выбранному bash / docker-based.

Дабы слишком не томить, любители почитать на буржуйском могут сразу же обратиться к оригиналу. Всем остальным ниже приведу краткую выдержку на русском.

Как вы уже поняли, основной целью является добавление поддержки видео записи активной selenium-сессии с минимальным вмешательством в содержимое самого контейнера.

Реализация будет состоять из 3-х частей:

  1. Модификация selenium-server-standalone.
  2. Модификация базовых selenium образов.
  3. Клиентский код по активации видео записи с последующей вставкой в 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, а также - в статье-оригинале.

Ввиду того, что процесс записи по сути бесконечен, нам следует позаботиться о двух вещах:

  1. Неблокирующей записи.
  2. Безопасном ее завершении.

Первый пункт достигается использованием 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 обработчику.

Вот собственно и все. Ссылки на исходники доступны в оригинальной статье. Ну а сам результат можно наблюдать из следующего короткого демо:

14 лайков

Я не до конца понимаю откуда взялся этот jar. Можно Вас попросить пояснить непонимающему человеку? :slight_smile:

Прошу прощения. Разобрался. Вопрос снимается как дурацкий ибо я протупил.