{"id":398818,"date":"2024-06-29T14:17:53","date_gmt":"2024-06-29T14:17:53","guid":{"rendered":"http:\/\/savepearlharbor.com\/?p=398818"},"modified":"-0001-11-30T00:00:00","modified_gmt":"2024-06-29T14:17:53","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=398818","title":{"rendered":"<span>How to Customize UI Artefacts for Selenide + Selenoid + Allure (with TestOPS)<\/span>"},"content":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>Initially, when my team at work was formed, we decided to use JVM stack to implement UI autotests, namely:<\/p>\n<ul>\n<li>\n<p>Kotlin \u2014 a programming language;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/\">JUnit5<\/a>\u00a0\u2014 a core of autotest design;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/ru.selenide.org\/\">Selenide<\/a>\u00a0\u2014 the basis for interaction with the Document Object Model of the browser in autotests;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/github.com\/allure-framework\/allure-java\">Allure<\/a>\u00a0for JVM \u2014 a very convenient reporting tool for autotests.<\/p>\n<\/li>\n<\/ul>\n<p>In addition, we also have the following tools to improve UI autotesting processes:<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/docs.qameta.io\/allure-testops\/\">Allure\u00a0TestOPS<\/a>\u00a0\u2014 a tool for storing autotest artefacts and test documentation;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/aerokube.com\/selenoid\/latest\/\">Selenoid<\/a>\u00a0\u2014 a tool for running tests remotely, cross-browser testing, and optional video recording of running autotests and their further storage<\/p>\n<\/li>\n<\/ul>\n<p>All of these tools almost always meet our requirements, each tool does its job, and does it well. But if everything was perfect, I wouldn\u2019t be writing this post.<\/p>\n<p>Very often the default use of these tools, when properly configured, caused several problems:<\/p>\n<ol>\n<li>\n<p>There used to be fewer tests, but their number has grown.<\/p>\n<\/li>\n<li>\n<p>There are more autotests now. Video files in Selenoid with the recorded autotest execution have become longer. Also, they are not really linked to Allure reports.<\/p>\n<\/li>\n<li>\n<p>Video files have become longer, making it increasingly difficult to understand which test is running at a given point in time.<\/p>\n<\/li>\n<li>\n<p>Increased difficulty in understanding resulted in the need for more transparency and flexibility in the generation of UI autotest execution reports.<\/p>\n<\/li>\n<\/ol>\n<p>If there is a problem, it has to be solved. In general, similar nontrivial tasks have been solved before, only in the API autotesting environment. The selected stack (JUnit5, Selenide) provides quite deep configurations and allows the delivery of seriously improved video artefacts of the autotest running with support of timestamps to an Allure report.<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/a73\/9de\/27b\/a739de27b4d9d47dc12d7892c2c2a43f.png\" alt=\"This video is over one hour long. Sad but true \" title=\"This video is over one hour long. Sad but true \" width=\"auto\" height=\"auto\" data-src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/a73\/9de\/27b\/a739de27b4d9d47dc12d7892c2c2a43f.png\"\/><figcaption>This video is over one hour long. Sad but true <\/figcaption><\/figure>\n<h2>Finding a Solution<\/h2>\n<p><strong>When solving a problem, I relied on the following supporting facts:<\/strong><\/p>\n<ul>\n<li>\n<p>Allure allows the usage of \u201c<a href=\"https:\/\/docs.qameta.io\/allure\/#_attachments_2\">attachments<\/a>\u201d in the resulting report, and this attachment can be applied to both a specific step in the test and the entire test;<\/p>\n<\/li>\n<li>\n<p>Selenide starts a browser session in the Selenoid container when you first call the open() method \u2014 you can also use it when implementing the solution.<\/p>\n<\/li>\n<li>\n<p>Selenide has a secure contract for accessing the webdriver using a\u00a0<a href=\"https:\/\/selenide.org\/javadoc\/current\/com\/codeborne\/selenide\/WebDriverRunner.html\">WebdriverRunner<\/a> wrapper.<\/p>\n<\/li>\n<li>\n<p>The Java implementation of Selenium (which Selenide is based on) has a contract to work with events implemented by\u00a0<a href=\"https:\/\/www.selenium.dev\/selenium\/docs\/api\/java\/org\/openqa\/selenium\/support\/events\/WebDriverListener.html\">WebdriverListener<\/a>;<\/p>\n<\/li>\n<li>\n<p>In the default mode, Selenide takes responsibility when its webdrivers close after all tests are completed.<\/p>\n<\/li>\n<li>\n<p>JUnit5 is packed with rich features to tap into the lifecycle test using\u00a0<a href=\"https:\/\/junit.org\/junit5\/docs\/current\/user-guide\/%23extensions-lifecycle-callbacks\">JUnit5 lifecycle extensions<\/a>.<\/p>\n<\/li>\n<\/ul>\n<p>The basic idea of implementing an improved UI autotest reporting process can be broken down into the following steps:<\/p>\n<ol>\n<li>\n<p>Rather than including the video recording of all tests in one file, split up the video. <\/p>\n<\/li>\n<li>\n<p>Form a time line for autotest execution from the start of video recording in Selenoid.<\/p>\n<\/li>\n<li>\n<p>Create timestamps for failed autotests relative to the time line.<\/p>\n<\/li>\n<li>\n<p>Upgrade the video according to the timestamps.<\/p>\n<\/li>\n<li>\n<p>Add an \u201cattachment\u201d report with the upgraded video to Allure.<\/p>\n<\/li>\n<\/ol>\n<p>I implemented some of the ideas as-is, but I had to work a little harder on others in order to implement an algorithm that was reasonable in terms of the time spent for its development. The details are below.<\/p>\n<p>How to Improve the Existing Video Recording Process in Selenoid<\/p>\n<p>The following conditions must be taken into account when working on a project:<\/p>\n<ul>\n<li>\n<p>Selenoid records actions in a container with a browser only when the following capabilities are set for the webdriver (I will represent this as json for convenience):<\/p>\n<\/li>\n<\/ul>\n<pre><code class=\"json\">{   \"selenoid:options\": {     \"enableVideo\": true   } }<\/code><\/pre>\n<ul>\n<li>\n<p>Selenoid begins video recording when starting a new session (opens session \u2192 starts browser \u2192 clicks the url link for UI tests)<\/p>\n<\/li>\n<li>\n<p>Selenoid stops video recording after closing the session (or the webdriver itself) in the autotest code<\/p>\n<\/li>\n<\/ul>\n<p>After discussing various ideas with the team and potential customers of this solution, we came up with the following approach \u2014 to record a separate video for each test suite or test class, to put it simply.<\/p>\n<p>This can be done by implementing a JUnit5 Extension.\u00a0This is a class that must implement one or more interfaces contained in the JUnit5 library. Here, we are interested in the following interfaces:<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/BeforeAllCallback.html\">BeforeAllCallback<\/a>\u00a0is an interface that adds some functionality before running all tests and fixtures in a test container (test class).\u00a0Here we shall add a Selenide pre-configuration to set up video recording options in Selenoid;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/AfterAllCallback.html\">AfterAllCallback<\/a>\u00a0is an interface that adds some functionality after executing all tests and fixtures in a test container (test class).\u00a0Here, we implement the process of stopping the video recording.<\/p>\n<\/li>\n<\/ul>\n<p>In addition, when using this extension, it is important to understand the following:<\/p>\n<ol>\n<li>\n<p>Selenide tests are run to set up the capabilities we want;<\/p>\n<\/li>\n<li>\n<p>the user wants to run tests on Selenoid;<\/p>\n<\/li>\n<li>\n<p>the user wants to use the video recording option on Selenoid.<\/p>\n<\/li>\n<\/ol>\n<p>I couldn\u2019t think up anything better than to<\/p>\n<ol>\n<li>\n<p>classify tests implemented with the use of the Selenide library and annotations.\u00a0An autotest developer must annotate a test class in which their Selenide tests are stored.<\/p>\n<\/li>\n<\/ol>\n<pre><code class=\"kotlin\">@Target(AnnotationTarget.CLASS) annotation class SelenideTest<\/code><\/pre>\n<p>I was thinking of a situation in which UI, API, and other tests could be stored in the same autotest repository, but this extension shouldn\u2019t affect anything other than Selenide tests.<\/p>\n<p>The 2nd and the 3rd points shall be solved using the flags. This can be easily done by using environment variables that hold a boolean value:<\/p>\n<ul>\n<li>\n<p>SELENOID_SUPPORT \u2014 for an option of running tests through Selenoid;<\/p>\n<\/li>\n<li>\n<p>SELENOID_VIDEO \u2014 for a video recording option.<\/p>\n<\/li>\n<\/ul>\n<p>The MVP of the solution can be represented in the code as follows (some additional methods necessary to run the extension are summarized and given without explanation, and may vary depending on your specific project):<\/p>\n<details class=\"spoiler\">\n<summary>Extension with a video recording function by test classes<\/summary>\n<div class=\"spoiler__content\">\n<pre><code class=\"kotlin\">class SelenideCoolExtension : BeforeAllCallback, AfterAllCallback {   override fun beforeAll(context: ExtensionContext) {          \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b          val matchAnnotation = parseSelenideTest(context)          \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c Selenide \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0437\u0430\u043f\u0443\u0441\u043a\u0430 Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {              \/\/ \u0420\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f \u043e\u0442 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432 Selenide \u2014 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0434\u043d\u043e\u043f\u043e\u0442\u043e\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0438\u0441\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u043e\u0432             checkSingleThreadExecution(context)              val capabilities = DesiredCapabilities()             \/\/ \u0417\u0434\u0435\u0441\u044c \u043c\u043e\u0433\u0443\u0442 \u0438\u0434\u0442\u0438 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u0430\u043c\u043e\u0433\u043e \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430              \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u043a \u0447\u0435\u0440\u0435\u0437 Selenoid if (checkSelenoidSupport()) { Configuration.remote = \"$selenoidUrl\/wd\/hub\"  Configuration.driverManagerEnabled = false \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e, \u0438 \u043e\u043f\u0446\u0438\u044e \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438 \u0441 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c \u0434\u043b\u044f \u0432\u0438\u0434\u0435\u043e  \/\/ \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0435 \u043c\u0435\u0442\u043a\u0438 \u0438\u043c\u0435\u043d\u0438 \u0442\u0435\u0441\u0442\u043e\u0432\u043e\u0433\u043e \u043a\u043b\u0430\u0441\u0441\u0430, \u0434\u0430\u043d\u043d\u044b\u0445 CI\/Local \u0440\u0435\u0436\u0438\u043c\u0430 \u0437\u0430\u043f\u0443\u0441\u043a\u0430, \/\/ \u0438 UTC \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0434\u043b\u044f \u0443\u0434\u043e\u0431\u0441\u0442\u0432\u0430 \u043f\u043e\u0438\u0441\u043a\u0430 \u0432 Selenoid capabilities.setCapability(\"selenoid:options\", prepareSelenoidOptions(context)) }              \/\/ \u041f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e \u0433\u043e\u0442\u043e\u0432\u044b\u0435 capabilities \u043f\u0440\u043e\u0431\u0440\u0430\u0441\u044b\u0432\u0430\u044e\u0442\u0441\u044f \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e             Configuration.browserCapabilities = capabilities         } }      override fun afterAll(context: ExtensionContext) {         \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u043c \u0441\u0435\u0441\u0441\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {             WebDriverRunner.closeWebDriver()  }  }  private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? {  return ctx .testClass       .orElseThrow { -> IllegalStateException(\"Something wrong with context. Test must contain ancestor (test class)\") } .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) }  .orElse(null) }  private fun checkSingleThreadExecution(context: ExtensionContext) {  context.getConfigurationParameter(\"junit.jupiter.execution.parallel.enabled\")?.ifPresent { if (it.toBoolean()) throw IllegalStateException(\"This extension works only with single thread mode\")  } }   private fun checkSelenoidSupport(): Boolean { return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) { \"1\" -> true else -> false  } }  private fun checkVideoRecorderSupport(): Boolean { return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) { \"1\" -> true else -> false  } }  private fun prepareSelenoidOptions(context: ExtensionContext): Map&lt;String, Any> {  val selenoidOptions = mutableMapOf&lt;String, Any>( \"enableVNC\" to true,  ) if (checkVideoRecorderSupport()) {  selenoidOptions[\"enableVideo\"] = true  selenoidOptions[\"videoName\"] = generateVideoName(context) } return selenoidOptions  }  private fun generateVideoName(context: ExtensionContext): String {          val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: \"Local\"         val currentTime = ZonedDateTime.now(Clock.systemUTC())             .format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))          val suiteName = context             .testClass .orElseThrow().let { AnnotationUtils.findAnnotation(it, DisplayName::class.java)             }.orElseThrow {                 IllegalStateException(\"Test suite must be annotated with @DisplayName\")             }.value             .splitToSequence(\" \")             .joinToString(\"-\")  val resultName = \"$ciJobId-$suiteName-$currentTime.mp4\"   return resultName }  private val selenoidUrl by lazy { System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL } }<\/code><\/pre>\n<\/p>\n<\/div>\n<\/details>\n<p>I deliberately try to write a simple and clear code for such solutions, so that the solution can be used by many people and teams.<\/p>\n<p>A synthetic test helped verify this solution:<\/p>\n<ol>\n<li>\n<p>Run two test classes:<\/p>\n<\/li>\n<\/ol>\n<pre><code class=\"kotlin\">@SelenideTest  @DisplayName(\"A\")  class ASuite {  @Test fun aTest() { open(\"https:\/\/www.google.com\") Selenide.sleep(5 * 1000) } }<\/code><\/pre>\n<pre><code class=\"kotlin\">@SelenideTest  @DisplayName(\"B\")  class BSuite {  @Test fun bTest() { Selenide.open(\"https:\/\/www.yandex.ru\") Selenide.sleep(5 * 1000) } }<\/code><\/pre>\n<ol start=\"2\">\n<li>\n<p>Make sure that exactly two videos with the right name are created in Selenoid, with each video containing execution of all tests of the test class.<\/p>\n<\/li>\n<\/ol>\n<p>I was more than happy with the result:<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/d28\/b0e\/40c\/d28b0e40c64bb93d1a715f5d5dcc0f53.png\" alt=\"Two videos of the test, just what the doctor ordered\" title=\"Two videos of the test, just what the doctor ordered\" width=\"auto\" height=\"auto\" data-src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/d28\/b0e\/40c\/d28b0e40c64bb93d1a715f5d5dcc0f53.png\"\/><figcaption>Two videos of the test, just what the doctor ordered<\/figcaption><\/figure>\n<h2>How to Create a Time Line and Timestamps for a Test Suite<\/h2>\n<p>The challenge here was that I wanted to make a simple and clear solution, while also creating all these timestamps as accurately as possible given the behaviour of the tests on Selenoid.<\/p>\n<p>Inspired,I used the JUni5 <a href=\"https:\/\/junit.org\/junit5\/docs\/current\/user-guide\/%23extensions-lifecycle-callbacks-timing-extension\">official documentation<\/a>\u00a0and the algorithm came to my mind right away:<\/p>\n<ol>\n<li>\n<p>When you start the extension, create a field inside it that contains a stopwatch starting at zero.<\/p>\n<\/li>\n<li>\n<p>Before running all the tests in the class, have a SELENOID_VIDEO flag checked, and register a custom <a href=\"https:\/\/www.selenium.dev\/selenium\/docs\/api\/java\/org\/openqa\/selenium\/support\/events\/WebDriverListener.html\">WebdriverListener<\/a> for a webdriver that has a link to this counter. This listener should start counting time only when the first interaction with the browser in the autotests happens. I chose to link it to triggering a webdriver <a href=\"https:\/\/www.selenium.dev\/selenium\/docs\/api\/java\/org\/openqa\/selenium\/WebDriver.html%23navigate()\">navigate()<\/a> method, as this method is called when the Selenide library open() method is called, which runs any autotest. This listener should also store the state of the first call to the navigate method inside itself to correctly handle and ignore subsequent navigate calls.<\/p>\n<\/li>\n<li>\n<p>Before running a particular autotest, read the state of the stopwatch and register it in some variable. Store it in the log as well.\u00a0We can do that by implementing a new interface inside our extension\u00a0\u2014 <a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/BeforeEachCallback.html\">BeforeEachCallback<\/a> that allows the addition of a function to be executed before running an individual autotest.<\/p>\n<\/li>\n<li>\n<p>If the test fails for any reason, you need to obtain the current state of the stopwatch and its state at the moment the test is run, and add it all to the log.\u00a0We can do that by implementing a new interface inside our extension\u00a0\u2014\u00a0<a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/TestWatcher.html\">TestWatcher<\/a> that allows the addition of a function after a test has been completed successfully, has failed, or has been skipped.<\/p>\n<\/li>\n<li>\n<p>After executing all of the autotests, reset the stopwatch and call back WebDriverListener.<\/p>\n<\/li>\n<\/ol>\n<p>Don\u2019t forget that all these functions should be executed only for Selenide tests.<\/p>\n<p>Let\u2019s take a look at a new stage of MVP implementation of our extension:<\/p>\n<details class=\"spoiler\">\n<summary>Extension with a timestamp support<\/summary>\n<div class=\"spoiler__content\">\n<pre><code class=\"kotlin\">private val logger = KotlinLogging.logger {}  class SelenideCoolExtension : BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeEachCallback {      \/\/ \u0418\u043d\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u0443\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0443\u044e \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u0435\u0442\u043e\u043a \u0432\u0440\u0435\u043c\u0435\u043d\u0438     private val timeSnapshot = AtomicLong(0)          \/\/ \u0418\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u0443\u0435\u043c \u0441\u0447\u0435\u0442\u0447\u0438\u043a \u0432\u0440\u0435\u043c\u0435\u043d\u0438     private val stopwatch: Stopwatch = Stopwatch.createUnstarted()      private lateinit var extensionListener: WebDriverListener           override fun beforeAll(context: ExtensionContext) {              \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c Selenide \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0437\u0430\u043f\u0443\u0441\u043a\u0430 Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {              \/\/ \u0420\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f \u043e\u0442 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432 Selenide - \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0434\u043d\u043e\u043f\u043e\u0442\u043e\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0438\u0441\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u043e\u0432             checkSingleThreadExecution(context)              val capabilities = DesiredCapabilities()             \/\/ \u0417\u0434\u0435\u0441\u044c \u043c\u043e\u0433\u0443\u0442 \u0438\u0434\u0442\u0438 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u0430\u043c\u043e\u0433\u043e \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430              \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u043a \u0447\u0435\u0440\u0435\u0437 Selenoid             if (checkSelenoidSupport()) {                 Configuration.remote = \"$selenoidUrl\/wd\/hub\"                  Configuration.driverManagerEnabled = false                 \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e, \u0438 \u043e\u043f\u0446\u0438\u044e \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438 \u0441 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c \u0434\u043b\u044f \u0432\u0438\u0434\u0435\u043e                  \/\/ \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0435 \u043c\u0435\u0442\u043a\u0438 \u0438\u043c\u0435\u043d\u0438 \u0442\u0435\u0441\u0442\u0432\u043e\u0433\u043e \u043a\u043b\u0430\u0441\u0441\u0430, \u0434\u0430\u043d\u043d\u044b\u0445 CI\/Local \u0440\u0435\u0436\u0438\u043c\u0430 \u0437\u0430\u043f\u0443\u0441\u043a\u0430,                 \/\/ \u0438 UTC \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0434\u043b\u044f \u0443\u0434\u043e\u0431\u0441\u0442\u0432\u0430 \u043f\u043e\u0438\u0441\u043a\u0430 \u0432 Selenoid                  capabilities.setCapability(\"selenoid:options\", prepareSelenoidOptions(context))             }             \/\/ \u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u043c listener \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0439 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e             if (checkVideoRecorderSupport()) {                  extensionListener = CounterListener(stopwatch)                  WebDriverRunner.addListener(extensionListener)             }             \/\/ \u041f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e \u0433\u043e\u0442\u043e\u0432\u044b\u0435 capabilities \u043f\u0440\u043e\u0431\u0440\u0430\u0441\u044b\u0432\u0430\u044e\u0442\u0441\u044f \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e             Configuration.browserCapabilities = capabilities         }     }      override fun beforeEach(context: ExtensionContext) {         val matchAnnotation = parseSelenideTest(context)          matchAnnotation?.let {             \/\/ \u0424\u0438\u043a\u0441\u0438\u0440\u0443\u0435\u043c \u0432\u0440\u0435\u043c\u044f \u0441\u0442\u0430\u0440\u0442\u0430 \u0442\u0435\u0441\u0442\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0439 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e             if (checkVideoRecorderSupport()) {                  stopwatch.elapsed(TimeUnit.SECONDS).let { time ->                     timeSnapshot.set(time)                 }             }         }     }      override fun testFailed(context: ExtensionContext, cause: Throwable?) {         val matchAnnotation = parseSelenideTest(context)                  \/\/ \u041b\u043e\u0433\u0438\u0440\u0443\u0435\u043c \u0432\u0440\u0435\u043c\u044f \u0441\u0442\u0430\u0440\u0442\u0430 \u0442\u0435\u0441\u0442\u0430 \u0438 \u0432\u0440\u0435\u043c\u044f \u043f\u0430\u0434\u0435\u043d\u0438\u044f \u043f\u043e \u043e\u0442\u043d\u043e\u0448\u0435\u043d\u0438\u044e \u043a \u0442\u0430\u0439\u043c\u043b\u0430\u0439\u043d\u0443 \u0432\u0438\u0434\u0435\u043e         \/\/ \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0439 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e         matchAnnotation?.let {             if (checkVideoRecorderSupport()) {                 logger.error { \"Test is failed. Test start time in video - ${timeSnapshot.get()} sec\" }                 stopwatch.elapsed(TimeUnit.SECONDS).let {                     logger.error { \"Test failure is on $it sec\" }                 }              }         }     }      override fun afterAll(context: ExtensionContext) {         \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u043c \u0441\u0435\u0441\u0441\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Selenide \u0442\u0435\u0441\u0442\u043e\u0432         \/\/ \u041e\u0442\u0437\u044b\u0432\u0430\u0435\u043c listener         \/\/ \u0410 \u0442\u0430\u043a\u0436\u0435 \u043b\u043e\u0433\u0438\u0440\u0443\u0435\u043c \u043e\u0431\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438         matchAnnotation?.let {             if (checkVideoRecorderSupport()) {                 logger.warn { \"Test run seconds: ${stopwatch.elapsed(TimeUnit.SECONDS)} seconds\" }                  stopwatch.reset()             }             WebDriverRunner.closeWebDriver()             if (this::extensionListener.isInitialized) {                 WebDriverRunner.removeListener(extensionListener)             }         }     }      private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? {          return ctx             .testClass             .orElseThrow { -> IllegalStateException(\"Something wrong with context. Test must contain     ancestor (test class)\") }             .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) }              .orElse(null)     }      private fun checkSingleThreadExecution(context: ExtensionContext) {         context.getConfigurationParameter(\"junit.jupiter.execution.parallel.enabled\")?.ifPresent {             if (it.toBoolean()) throw IllegalStateException(\"This extension works only with single thread mode\")           }     }      private fun checkSelenoidSupport(): Boolean {         return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) {             \"1\" -> true             else -> false          }     }      private fun checkVideoRecorderSupport(): Boolean {         return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) {             \"1\" -> true             else -> false          }     }      private fun prepareSelenoidOptions(context: ExtensionContext): Map&lt;String, Any> {          val selenoidOptions = mutableMapOf&lt;String, Any>(             \"enableVNC\" to true,          )         if (checkVideoRecorderSupport()) {              selenoidOptions[\"enableVideo\"] = true              selenoidOptions[\"videoName\"] = generateVideoName(context)         }         return selenoidOptions      }      private fun generateVideoName(context: ExtensionContext): String {              val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: \"Local\"             val currentTime = ZonedDateTime.now(Clock.systemUTC())                 .format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))              val suiteName = context                 .testClass                 .orElseThrow().let {                     AnnotationUtils.findAnnotation(it, DisplayName::class.java)                 }.orElseThrow {                     IllegalStateException(\"Test suite must be annotated with @DisplayName\")                 }.value                 .splitToSequence(\" \")                 .joinToString(\"-\")              val resultName = \"$ciJobId-$suiteName-$currentTime.mp4\"              return resultName     }     private val selenoidUrl by lazy {          System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL     }    }<\/code><\/pre>\n<\/p>\n<\/div>\n<\/details>\n<p>MVP implementation of our own WebDriverListener looks like this:<\/p>\n<pre><code class=\"kotlin\">private val logger = KotlinLogging.logger {}  class CounterListener(private val timer: Stopwatch) : WebDriverListener {      private val sessionCreated = AtomicBoolean(false)      override fun afterAnyCall(target: Any?, method: Method?, args: Array&lt;out Any>?, result: Any?) {          if (method?.name == LISTENER_TARGET_METHOD_FOR_TRACKING) {             if (!sessionCreated.get()) {                 logger.warn { \"Session start was intercepted, starting counting time\" }                      if (!timer.isRunning) {                         timer.start()                     }                 sessionCreated.set(true)              }         }     }          override fun afterQuit(driver: WebDriver?) {         logger.warn { \"Resetting time counter due to driver close\" }          sessionCreated.set(false)     }  }<\/code><\/pre>\n<p>I have chosen a\u00a0<a href=\"https:\/\/www.javadoc.io\/doc\/com.google.guava\/guava\/latest\/com\/google\/common\/base\/Stopwatch.html\">Guava<\/a> stopwatch with proven quality. We will count in milliseconds, though that is inexact, but for the first implementation that will do.<\/p>\n<p>To check the improvements, I wrote the following synthetic test:<\/p>\n<ol>\n<li>\n<p>Run a test class in which all test methods are implemented as failing.<\/p>\n<\/li>\n<\/ol>\n<pre><code class=\"kotlin\">@SelenideTest  @TestMethodOrder(MethodOrderer.OrderAnnotation::class)  @DisplayName(\"A\") class ASuite {      private val EXPECTED_TITLE = \"\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442\"          @Test     @Order(1)     fun aTest() {         open(\"https:\/\/www.google.com\")         element(By.xpath(\"\/\/input[@title='\u041f\u043e\u0438\u0441\u043a']\")).setValue(\"\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04301\").submit()          Assertions.assertEquals(title(), EXPECTED_TITLE)     }      @Test     @Order(2)     fun bSyntheticFailedTest() {         open(\"https:\/\/www.google.com\")         element(By.xpath(\"\/\/input[@title='\u041f\u043e\u0438\u0441\u043a']\")).setValue(\"\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04302\").submit()                      Assertions.assertEquals(title(), EXPECTED_TITLE)     }       @Test     @Order(3)     fun cTest() {         open(\"https:\/\/www.google.com\")         element(By.xpath(\"\/\/input[@title='\u041f\u043e\u0438\u0441\u043a']\")).setValue(\"\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04303\").submit()         Assertions.assertEquals(title(), EXPECTED_TITLE)     } }<\/code><\/pre>\n<ol start=\"2\">\n<li>\n<p>Use the logs to see the timestamps.<\/p>\n<\/li>\n<li>\n<p>In the generated video, go to the second of failure from log 3.<\/p>\n<\/li>\n<\/ol>\n<p>After running these tests, the following is displayed in the logs:<\/p>\n<pre><code>[Test worker] INFO com.codeborne.selenide.drivercommands.CreateDriverCommand - Created webdriver in thread 1: RemoteWebDriver -> RemoteWebDriver: chrome on LINUX (a7d95bf7873bfc86345b131ee7e8a3ae) [Test worker] INFO com.codeborne.selenide.drivercommands.CreateDriverCommand - Add listeners to webdriver: [tech.inno.qa.core.ui.extension.CounterListener@1cdd31a4] [Test worker] WARN tech.inno.qa.core.ui.extension.CounterListener - Session start was intercepted, starting counting time [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 0 ms [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 1748 ms  expected: &lt;\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442> but was: &lt;\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04301 - \u041f\u043e\u0438\u0441\u043a \u0432 Google> Expected :\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442 Actual :\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04301 - \u041f\u043e\u0438\u0441\u043a \u0432 Google  [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 2204 ms [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 5044 ms  expected: &lt;\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442> but was: &lt;\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04302 - \u041f\u043e\u0438\u0441\u043a \u0432 Google> Expected :\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442 Actual :\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04302 - \u041f\u043e\u0438\u0441\u043a \u0432 Google  [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 5411 ms [Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 7773 ms  expected: &lt;\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442> but was: &lt;\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0441\u0438\u043d\u0442\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u0442\u0435\u0441\u0442\u04303 - \u041f\u043e\u0438\u0441\u043a \u0432 Google> Expected :\u0441\u043e\u0432\u0441\u0435\u043c \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442  [Test worker] WARN tech.inno.qa.core.ui.extension.SelenideExtension - Test run: 7779 ms [Test worker] INFO com.codeborne.selenide.drivercommands.CloseDriverCommand - Close webdriver: 1 -> Decorated {RemoteWebDriver: chrome on LINUX (a7d95bf7873bfc86345b131ee7e8a3ae)}... [Test worker] WARN tech.inno.qa.core.ui.extension.CounterListener - Resetting counter state due to driver close [Test worker] INFO com.codeborne.selenide.drivercommands.CloseDriverCommand - Closed webdriver 1 in 117 ms<\/code><\/pre>\n<p>As an example, let\u2019s open the video at the moment of an error of test N3 \u2014 7,773 ms (at 7-8 seconds on a standard HTML5 video container) and verify that a failure happens there:<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/e47\/ef0\/01d\/e47ef001ddb034f55f4c1243976b1113.png\" width=\"auto\" height=\"auto\" data-src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/e47\/ef0\/01d\/e47ef001ddb034f55f4c1243976b1113.png\"\/><figcaption><\/figcaption><\/figure>\n<blockquote>\n<p><strong><em>Some notes<\/em><\/strong><\/p>\n<p>Unfortunately, I didn\u2019t manage to achieve precision in timestamps, because the entire process is built on wrappers around events.\u00a0I didn\u2019t manage to find a reasonable way to capture the event of the start of video recording itself, as this is handled by the Selenoid function.\u00a0The stopwatch can miss by a tiny fraction of a second, depending on the networking speed.<\/p>\n<p>In my version, it helped a lot that the CI runners which run autotests on have very fast access to Selenoid, since they are on the same infrastructure subnet.<\/p>\n<p>Overall, I\u2019m happy with the result. The timestamps are more or less accurate, so let\u2019s move on.<\/p>\n<\/blockquote>\n<h2>How to Link Video Timestamps with Allure Report<\/h2>\n<p>Again, I had to be somewhat creative. I couldn\u2019t find a predictable solution on the go, but I had the following ideas:<\/p>\n<ul>\n<li>\n<p>it might be easier to crop a piece of video from the main video of the test suite using timestamps, and attach it to the report as an \u201cattachment\u201d for a particular test.\u00a0As it turned out, this was not easier, and I did not really want to use JVM video processing libraries.<\/p>\n<\/li>\n<li>\n<p>as an alternative, don\u2019t bother at all, and just send a link to the video of a particular test to the Allure report, as well as instructing users of the solution to look in the logs and go to the required seconds manually.\u00a0As it turned out, end users wanted to see all the facts of test execution in one place, so this option also was discarded.<\/p>\n<\/li>\n<\/ul>\n<p>I soon realized that there was nothing wrong with using pieces of HTML markup as \u201cattachments\u201d. This means that it will be possible to:<\/p>\n<ol>\n<li>\n<p>\u00a0control the behaviour of a particular HTML5 element (in this case, &lt;video\/>) at the javascript level;<\/p>\n<\/li>\n<li>\n<p>writing whatever users want in this markup. <\/p>\n<\/li>\n<\/ol>\n<p>This is the algorithm that came to my mind:<\/p>\n<ol>\n<li>\n<p>Before running all the tests in the class, we save the generated name for a Selenoid video in the field of our extension.<\/p>\n<\/li>\n<li>\n<p>After any of the tests fails, we generate an HTML5 code with self-writing is that modifies a video player, and opens a video only for the duration of the failed test running. For example, it shows only 30 seconds from a 10-minute video.<\/p>\n<\/li>\n<li>\n<p>The generated code is saved as an \u201cattachment\u201d of the particular test using <a href=\"https:\/\/github.com\/allure-framework\/allure-java\/blob\/master\/allure-java-commons\/src\/main\/java\/io\/qameta\/allure\/AllureLifecycle.java\">AllureLifecycle<\/a>. This class lets you safely handle the data for the Allure report. We will develop a separate method for this.<\/p>\n<\/li>\n<\/ol>\n<p>So in the extension, you just need to add behaviour to the implementation of the TestWatcher interface, having previously implemented the function of generating an improved \u201cattachment\u201d for Allure.<\/p>\n<p>To improve videos, I decided to wrap a standard video container in the <a href=\"https:\/\/github.com\/sampotts\/plyr\">Plyr<\/a> player.\u00a0\u00a0It seemed to be a better wrapper over a standard video player, and it may come in handy if at some point users wish to have more features to interact with video reports.<\/p>\n<p>So how do you make the player open the video only at the desired range of seconds? The answer lies in an outdated\u00a0<a href=\"https:\/\/www.w3.org\/TR\/media-frags\/%23general-structure\">W3C specification<\/a>\u00a0for dealing with media fragments. This helps solve the problem of opening the same video on each Allure \u201cattachment\u201d, but at different timestamps.<\/p>\n<p>Let\u2019s look at the final code:<\/p>\n<details class=\"spoiler\">\n<summary>Final Extension version<\/summary>\n<div class=\"spoiler__content\">\n<pre><code class=\"kotlin\">class SelenideExtension1 : BeforeAllCallback, AfterAllCallback, BeforeEachCallback, TestWatcher {       \/\/ \u0418\u043d\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u0443\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0443\u044e \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043c\u0435\u0442\u043e\u043a \u0432\u0440\u0435\u043c\u0435\u043d\u0438     private val testStartTimeSnapshot = AtomicLong(0)      \/\/ \u0418\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u0443\u0435\u043c \u0441\u0447\u0435\u0442\u0447\u0438\u043a \u0432\u0440\u0435\u043c\u0435\u043d\u0438     private val stopwatch: Stopwatch = Stopwatch.createUnstarted()      private lateinit var extensionListener: WebDriverListener       \/\/ \u0418\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u0443\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0443\u044e \u0434\u043b\u044f \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0438\u0434\u0435\u043e-\u0444\u0430\u0439\u043b\u0430      \/\/ \u0422\u0430\u043a \u043a\u0430\u043a \u0432\u0441\u0435 \u0442\u0435\u0441\u0442\u044b \u0432 \u043a\u043b\u0430\u0441\u0441\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0442 \u043e\u0434\u043d\u043e\u043c\u0443 \u0432\u0438\u0434\u0435\u043e      private val currentVideoLink = AtomicReference(\"\")          override fun beforeAll(context: ExtensionContext) {              \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)              \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c Selenide \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0437\u0430\u043f\u0443\u0441\u043a\u0430 Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {                  \/\/ \u0420\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f \u043e\u0442 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432 Selenide - \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0434\u043d\u043e\u043f\u043e\u0442\u043e\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0438\u0441\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u043e\u0432             checkSingleThreadExecution(context)                  val capabilities = DesiredCapabilities()              \/\/ \u0417\u0434\u0435\u0441\u044c \u043c\u043e\u0433\u0443\u0442 \u0438\u0434\u0442\u0438 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u0430\u043c\u043e\u0433\u043e \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430              \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u043a \u0447\u0435\u0440\u0435\u0437 Selenoid             if (checkSelenoidSupport()) {             Configuration.remote = \"$selenoidUrl\/wd\/hub\"              Configuration.driverManagerEnabled = false             \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e, \u0438 \u043e\u043f\u0446\u0438\u044e \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438 \u0441 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c \u0434\u043b\u044f \u0432\u0438\u0434\u0435\u043e              \/\/ \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0435 \u043c\u0435\u0442\u043a\u0438 \u0438\u043c\u0435\u043d\u0438 \u0442\u0435\u0441\u0442\u0432\u043e\u0433\u043e \u043a\u043b\u0430\u0441\u0441\u0430, \u0434\u0430\u043d\u043d\u044b\u0445 CI\/Local \u0440\u0435\u0436\u0438\u043c\u0430 \u0437\u0430\u043f\u0443\u0441\u043a\u0430,             \/\/ \u0438 UTC \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0434\u043b\u044f \u0443\u0434\u043e\u0431\u0441\u0442\u0432\u0430 \u043f\u043e\u0438\u0441\u043a\u0430 \u0432 Selenoid              capabilities.setCapability(\"selenoid:options\", prepareSelenoidOptions(context))             }              \/\/ \u041f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e \u0433\u043e\u0442\u043e\u0432\u044b\u0435 capabilities \u043f\u0440\u043e\u0431\u0440\u0430\u0441\u044b\u0432\u0430\u044e\u0442\u0441\u044f \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e             Configuration.browserCapabilities = capabilities              \/\/ \u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u043c listener \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u043f\u043e\u0434\u043d\u044f\u0442\u043e\u043c \u0444\u043b\u0430\u0433\u0435 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e             if (checkSelenoidVideoSupport()) {                  extensionListener = CounterListener(stopwatch)                  WebDriverRunner.addListener(extensionListener)             }          }     }      override fun beforeEach(context: ExtensionContext) {          \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          matchAnnotation?.let {              \/\/ \u0417\u0434\u0435\u0441\u044c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e, \u0447\u0438\u0441\u0442\u0438\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430 (cookies, localstorage, etc..)              \/\/ \u0424\u0438\u043a\u0441\u0438\u0440\u0443\u0435\u043c \u0432\u0440\u0435\u043c\u044f \u0441\u0442\u0430\u0440\u0442\u0430 \u0442\u0435\u0441\u0442\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0439 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e             if (checkSelenoidVideoSupport()) {                  testStartTimeSnapshot.set(stopwatch.elapsed(TimeUnit.MILLISECONDS))             }          }     }      override fun testFailed(context: ExtensionContext, cause: Throwable?) {          \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u041b\u043e\u0433\u0438\u0440\u0443\u0435\u043c \u0432\u0440\u0435\u043c\u044f \u0441\u0442\u0430\u0440\u0442\u0430 \u0442\u0435\u0441\u0442\u0430 \u0438 \u0432\u0440\u0435\u043c\u044f \u043f\u0430\u0434\u0435\u043d\u0438\u044f \u043f\u043e \u043e\u0442\u043d\u043e\u0448\u0435\u043d\u0438\u044e \u043a \u0442\u0430\u0439\u043c\u043b\u0430\u0439\u043d\u0443 \u0432\u0438\u0434\u0435\u043e         \/\/ \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Selenide \u0442\u0435\u0441\u0442\u043e\u0432, \u0438 \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0439 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430\u043f\u0438\u0441\u0438 \u0432\u0438\u0434\u0435\u043e         \/\/ \u0413\u043e\u0442\u043e\u0432\u0438\u043c \u0432\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0441 \u043c\u0435\u0442\u043a\u0430\u043c\u0438 \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u043f\u0430\u0434\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u0430         matchAnnotation?.let {                          if (checkSelenoidVideoSupport()) {                 val testStartTime = testStartTimeSnapshot.get()                 logger.error { \"Test is failed. Test start time in video - ${testStartTime} ms\" }                  stopwatch.elapsed(TimeUnit.MILLISECONDS).let {                     logger.error { \"Test failure is on $it ms\" }                     prepareAllureVideoAttachment(testStartTime, it)                  }              }         }     }      override fun afterAll(context: ExtensionContext) {          \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u043c \u0441\u0435\u0441\u0441\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Selenide \u0442\u0435\u0441\u0442\u043e\u0432,         \/\/ \u043e\u0442\u0437\u044b\u0432\u0430\u0435\u043c listener, \u0438 \u0442\u0430\u043a\u0436\u0435 \u043b\u043e\u0433\u0438\u0440\u0443\u0435\u043c \u043e\u0431\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438         matchAnnotation?.let {             if (checkSelenoidVideoSupport()) {                 logger.warn { \"Test run: ${stopwatch.elapsed(TimeUnit.MILLISECONDS)} ms\" }                  stopwatch.reset()             }             if (WebDriverRunner.hasWebDriverStarted()) {                 WebDriverRunner.closeWebDriver()             }             if (this::extensionListener.isInitialized) {                  WebDriverRunner.removeListener(extensionListener)             }          }     }      private val selenoidUrl by lazy {         System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL     }          private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? {         return ctx.testClass.orElseThrow { -> IllegalStateException(\"Something wrong with context. Test must contain ancestor (test class)\") }             .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) }.orElse(null)     }      private fun checkSingleThreadExecution(context: ExtensionContext) {          context.getConfigurationParameter(\"junit.jupiter.execution.parallel.enabled\")?.ifPresent {             if (it.toBoolean()) throw IllegalStateException(\"This extension works only with single thread mode\")             }     }       private fun checkSelenoidSupport(): Boolean {         return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) {             \"1\" -> true             else -> false          }     }      private fun checkSelenoidVideoSupport(): Boolean {         return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) {             \"1\" -> true              else -> false             }      }      private fun checkScreenResolution(): String {             val resolution = System.getenv(SELENOID_DEFAULT_SCREEN_RESOLUTION_ENV_PROPERTY_NAME) ?: \"normal\"              return try {                 ScreenResolution.valueOf(resolution.uppercase()).value              } catch (e: IllegalArgumentException) {                 ScreenResolution.NORMAL.value             }     }      private fun prepareSelenoidOptions(context: ExtensionContext): Map&lt;String, Any> {          val selenoidOptions = mutableMapOf&lt;String, Any>(             \"enableVNC\" to true, \"enableLog\" to true          )         if (checkSelenoidVideoSupport()) {              selenoidOptions[\"enableVideo\"] = true              selenoidOptions[\"videoName\"] = generateVideoName(context)              selenoidOptions[\"videoFrameRate\"] to 24              selenoidOptions[\"screenResolution\"] = checkScreenResolution()         }     return selenoidOptions      }      private fun generateVideoName(context: ExtensionContext): String {          val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: \"Local\"          val currentTime = ZonedDateTime.now(Clock.systemUTC()).format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))                  val suiteName = context.testClass.orElseThrow().let {              AnnotationUtils.findAnnotation(it, DisplayName::class.java)         }.orElseThrow {             IllegalStateException(\"Test suite must be annotated with @DisplayName\")         }.value.splitToSequence(\" \").joinToString(\"-\")          val resultName = \"$ciJobId-$suiteName-$currentTime.mp4\"         \/\/ \u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0435\u043c \u0438\u043c\u044f \u0444\u0430\u0439\u043b\u0430         currentVideoLink.set(resultName)          return resultName      }      private fun generateVideoLink(): String {         return \"$selenoidUrl\/video\/${currentVideoLink.get()}\"     }      private fun prepareAllureVideoAttachment(startTime: Long, failTime: Long) {          val lifecycle = Allure.getLifecycle()         val startTimeFormatted = DurationFormatUtils.formatDurationHMS(startTime)          val failTimeFormatted = DurationFormatUtils.formatDurationHMS(failTime)          lifecycle.currentTestCase.ifPresent {             lifecycle.addAttachment(                 \"Recorded video\", \"text\/html\", \".html\", \"\"\"                     &lt;html>                         &lt;head>                             &lt;script src=\"https:\/\/cdn.plyr.io\/3.7.2\/plyr.js\">&lt;\/script>                             &lt;link rel=\"stylesheet\" href=\"https:\/\/cdn.plyr.io\/3.7.2\/plyr.css\" \/>                         &lt;\/head>                         &lt;body>                             &lt;p>\u0412\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u0430: $startTimeFormatted - $failTimeFormatted&lt;\/p>                             &lt;video controls playsinline id=\"player\" onpause=\"this.load()\">                                 &lt;source src='${generateVideoLink()}#t=${startTimeFormatted},${failTimeFormatted}' type='video\/mp4'>                             &lt;\/video>                             &lt;script>                                 const player = new Plyr(window.document.getElementById('player'), {                                      title: 'Selenoid Player',                                     invertTime: false                                  });                             &lt;\/script>                          &lt;\/body>                      &lt;\/html>                 \"\"\".trimIndent().toByteArray()             )         }     } }<\/code><\/pre>\n<\/p>\n<\/div>\n<\/details>\n<p>To test the functionality you will need to:<\/p>\n<ul>\n<li>\n<p>Run Selenide tests. What we said in the previous paragraph of the article also applies here;<\/p>\n<\/li>\n<li>\n<p>Take a look at the generated timestamps;<\/p>\n<\/li>\n<li>\n<p>Generate an Allure report, either locally or remotely in Allure TestOPS;<\/p>\n<\/li>\n<li>\n<p>Make sure that timestamps in the logs match a fragment of the video with a failed test in the Allure report.<\/p>\n<\/li>\n<\/ul>\n<p>After running the tests, all we need to do is to look at the log snippet of any failed test:<\/p>\n<figure class=\"full-width\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/21d\/358\/393\/21d358393f5e266c3cf90bb65e53aa3f.gif\" width=\"1792\" height=\"1120\" data-src=\"https:\/\/habrastorage.org\/getpro\/habr\/upload_files\/21d\/358\/393\/21d358393f5e266c3cf90bb65e53aa3f.gif\"\/><figcaption><\/figcaption><\/figure>\n<p>It works!<\/p>\n<p>The video opens on a particular timespan, and after it finishes, it returns to the start time of the failed test.<br \/> \u00a0Voila!<\/p>\n<h2>Beauty Will Save Autotests<\/h2>\n<p>A functional and user-friendly solution improves the UI autotest reporting process. Yes, it\u2019s made as \u201cad-hoc\u201d solution, but it lets you solve the task efficiently. It once again reminds us that with due perseverance and motivation, it is possible to automate and customize processes.<\/p>\n<p>I\u2019m currently working on MVP improvement. There are ideas to make video reports more pleasant by implementing text description of a test in the video of a test suite using subtitles, but more on that another time.<\/p>\n<p>Please leave your tips on optimization and questions on the implementation in the comments.<\/p>\n<\/p>\n<\/div>\n<\/div>\n<\/div>\n<p><!----><!----><\/div>\n<p><!----><!----><br \/> \u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/698022\/\"> https:\/\/habr.com\/ru\/articles\/698022\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<div><!--[--><!--]--><\/div>\n<div id=\"post-content-body\">\n<div>\n<div class=\"article-formatted-body article-formatted-body article-formatted-body_version-2\">\n<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>Initially, when my team at work was formed, we decided to use JVM stack to implement UI autotests, namely:<\/p>\n<ul>\n<li>\n<p>Kotlin \u2014 a programming language;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/\">JUnit5<\/a>\u00a0\u2014 a core of autotest design;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/ru.selenide.org\/\">Selenide<\/a>\u00a0\u2014 the basis for interaction with the Document Object Model of the browser in autotests;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/github.com\/allure-framework\/allure-java\">Allure<\/a>\u00a0for JVM \u2014 a very convenient reporting tool for autotests.<\/p>\n<\/li>\n<\/ul>\n<p>In addition, we also have the following tools to improve UI autotesting processes:<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/docs.qameta.io\/allure-testops\/\">Allure\u00a0TestOPS<\/a>\u00a0\u2014 a tool for storing autotest artefacts and test documentation;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/aerokube.com\/selenoid\/latest\/\">Selenoid<\/a>\u00a0\u2014 a tool for running tests remotely, cross-browser testing, and optional video recording of running autotests and their further storage<\/p>\n<\/li>\n<\/ul>\n<p>All of these tools almost always meet our requirements, each tool does its job, and does it well. But if everything was perfect, I wouldn\u2019t be writing this post.<\/p>\n<p>Very often the default use of these tools, when properly configured, caused several problems:<\/p>\n<ol>\n<li>\n<p>There used to be fewer tests, but their number has grown.<\/p>\n<\/li>\n<li>\n<p>There are more autotests now. Video files in Selenoid with the recorded autotest execution have become longer. Also, they are not really linked to Allure reports.<\/p>\n<\/li>\n<li>\n<p>Video files have become longer, making it increasingly difficult to understand which test is running at a given point in time.<\/p>\n<\/li>\n<li>\n<p>Increased difficulty in understanding resulted in the need for more transparency and flexibility in the generation of UI autotest execution reports.<\/p>\n<\/li>\n<\/ol>\n<p>If there is a problem, it has to be solved. In general, similar nontrivial tasks have been solved before, only in the API autotesting environment. The selected stack (JUnit5, Selenide) provides quite deep configurations and allows the delivery of seriously improved video artefacts of the autotest running with support of timestamps to an Allure report.<\/p>\n<figure class=\"\"><figcaption>This video is over one hour long. Sad but true <\/figcaption><\/figure>\n<h2>Finding a Solution<\/h2>\n<p><strong>When solving a problem, I relied on the following supporting facts:<\/strong><\/p>\n<ul>\n<li>\n<p>Allure allows the usage of \u201c<a href=\"https:\/\/docs.qameta.io\/allure\/#_attachments_2\">attachments<\/a>\u201d in the resulting report, and this attachment can be applied to both a specific step in the test and the entire test;<\/p>\n<\/li>\n<li>\n<p>Selenide starts a browser session in the Selenoid container when you first call the open() method \u2014 you can also use it when implementing the solution.<\/p>\n<\/li>\n<li>\n<p>Selenide has a secure contract for accessing the webdriver using a\u00a0<a href=\"https:\/\/selenide.org\/javadoc\/current\/com\/codeborne\/selenide\/WebDriverRunner.html\">WebdriverRunner<\/a> wrapper.<\/p>\n<\/li>\n<li>\n<p>The Java implementation of Selenium (which Selenide is based on) has a contract to work with events implemented by\u00a0<a href=\"https:\/\/www.selenium.dev\/selenium\/docs\/api\/java\/org\/openqa\/selenium\/support\/events\/WebDriverListener.html\">WebdriverListener<\/a>;<\/p>\n<\/li>\n<li>\n<p>In the default mode, Selenide takes responsibility when its webdrivers close after all tests are completed.<\/p>\n<\/li>\n<li>\n<p>JUnit5 is packed with rich features to tap into the lifecycle test using\u00a0<a href=\"https:\/\/junit.org\/junit5\/docs\/current\/user-guide\/%23extensions-lifecycle-callbacks\">JUnit5 lifecycle extensions<\/a>.<\/p>\n<\/li>\n<\/ul>\n<p>The basic idea of implementing an improved UI autotest reporting process can be broken down into the following steps:<\/p>\n<ol>\n<li>\n<p>Rather than including the video recording of all tests in one file, split up the video. <\/p>\n<\/li>\n<li>\n<p>Form a time line for autotest execution from the start of video recording in Selenoid.<\/p>\n<\/li>\n<li>\n<p>Create timestamps for failed autotests relative to the time line.<\/p>\n<\/li>\n<li>\n<p>Upgrade the video according to the timestamps.<\/p>\n<\/li>\n<li>\n<p>Add an \u201cattachment\u201d report with the upgraded video to Allure.<\/p>\n<\/li>\n<\/ol>\n<p>I implemented some of the ideas as-is, but I had to work a little harder on others in order to implement an algorithm that was reasonable in terms of the time spent for its development. The details are below.<\/p>\n<p>How to Improve the Existing Video Recording Process in Selenoid<\/p>\n<p>The following conditions must be taken into account when working on a project:<\/p>\n<ul>\n<li>\n<p>Selenoid records actions in a container with a browser only when the following capabilities are set for the webdriver (I will represent this as json for convenience):<\/p>\n<\/li>\n<\/ul>\n<pre><code class=\"json\">{   \"selenoid:options\": {     \"enableVideo\": true   } }<\/code><\/pre>\n<ul>\n<li>\n<p>Selenoid begins video recording when starting a new session (opens session \u2192 starts browser \u2192 clicks the url link for UI tests)<\/p>\n<\/li>\n<li>\n<p>Selenoid stops video recording after closing the session (or the webdriver itself) in the autotest code<\/p>\n<\/li>\n<\/ul>\n<p>After discussing various ideas with the team and potential customers of this solution, we came up with the following approach \u2014 to record a separate video for each test suite or test class, to put it simply.<\/p>\n<p>This can be done by implementing a JUnit5 Extension.\u00a0This is a class that must implement one or more interfaces contained in the JUnit5 library. Here, we are interested in the following interfaces:<\/p>\n<ul>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/BeforeAllCallback.html\">BeforeAllCallback<\/a>\u00a0is an interface that adds some functionality before running all tests and fixtures in a test container (test class).\u00a0Here we shall add a Selenide pre-configuration to set up video recording options in Selenoid;<\/p>\n<\/li>\n<li>\n<p><a href=\"https:\/\/junit.org\/junit5\/docs\/current\/api\/org.junit.jupiter.api\/org\/junit\/jupiter\/api\/extension\/AfterAllCallback.html\">AfterAllCallback<\/a>\u00a0is an interface that adds some functionality after executing all tests and fixtures in a test container (test class).\u00a0Here, we implement the process of stopping the video recording.<\/p>\n<\/li>\n<\/ul>\n<p>In addition, when using this extension, it is important to understand the following:<\/p>\n<ol>\n<li>\n<p>Selenide tests are run to set up the capabilities we want;<\/p>\n<\/li>\n<li>\n<p>the user wants to run tests on Selenoid;<\/p>\n<\/li>\n<li>\n<p>the user wants to use the video recording option on Selenoid.<\/p>\n<\/li>\n<\/ol>\n<p>I couldn\u2019t think up anything better than to<\/p>\n<ol>\n<li>\n<p>classify tests implemented with the use of the Selenide library and annotations.\u00a0An autotest developer must annotate a test class in which their Selenide tests are stored.<\/p>\n<\/li>\n<\/ol>\n<pre><code class=\"kotlin\">@Target(AnnotationTarget.CLASS) annotation class SelenideTest<\/code><\/pre>\n<p>I was thinking of a situation in which UI, API, and other tests could be stored in the same autotest repository, but this extension shouldn\u2019t affect anything other than Selenide tests.<\/p>\n<p>The 2nd and the 3rd points shall be solved using the flags. This can be easily done by using environment variables that hold a boolean value:<\/p>\n<ul>\n<li>\n<p>SELENOID_SUPPORT \u2014 for an option of running tests through Selenoid;<\/p>\n<\/li>\n<li>\n<p>SELENOID_VIDEO \u2014 for a video recording option.<\/p>\n<\/li>\n<\/ul>\n<p>The MVP of the solution can be represented in the code as follows (some additional methods necessary to run the extension are summarized and given without explanation, and may vary depending on your specific project):<\/p>\n<details class=\"spoiler\">\n<summary>Extension with a video recording function by test classes<\/summary>\n<div class=\"spoiler__content\">\n<pre><code class=\"kotlin\">class SelenideCoolExtension : BeforeAllCallback, AfterAllCallback {   override fun beforeAll(context: ExtensionContext) {          \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b          val matchAnnotation = parseSelenideTest(context)          \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c Selenide \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0437\u0430\u043f\u0443\u0441\u043a\u0430 Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {              \/\/ \u0420\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f \u043e\u0442 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432 Selenide \u2014 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0434\u043d\u043e\u043f\u043e\u0442\u043e\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c \u0438\u0441\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0442\u0435\u0441\u0442\u043e\u0432             checkSingleThreadExecution(context)              val capabilities = DesiredCapabilities()             \/\/ \u0417\u0434\u0435\u0441\u044c \u043c\u043e\u0433\u0443\u0442 \u0438\u0434\u0442\u0438 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0441\u0430\u043c\u043e\u0433\u043e \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0430              \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043f\u0443\u0441\u043a \u0447\u0435\u0440\u0435\u0437 Selenoid if (checkSelenoidSupport()) { Configuration.remote = \"$selenoidUrl\/wd\/hub\"  Configuration.driverManagerEnabled = false \/\/ \u041d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u043d\u043e, \u0438 \u043e\u043f\u0446\u0438\u044e \u0432\u0438\u0434\u0435\u043e\u0437\u0430\u043f\u0438\u0441\u0438 \u0441 \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c \u0434\u043b\u044f \u0432\u0438\u0434\u0435\u043e  \/\/ \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0435 \u043c\u0435\u0442\u043a\u0438 \u0438\u043c\u0435\u043d\u0438 \u0442\u0435\u0441\u0442\u043e\u0432\u043e\u0433\u043e \u043a\u043b\u0430\u0441\u0441\u0430, \u0434\u0430\u043d\u043d\u044b\u0445 CI\/Local \u0440\u0435\u0436\u0438\u043c\u0430 \u0437\u0430\u043f\u0443\u0441\u043a\u0430, \/\/ \u0438 UTC \u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0434\u043b\u044f \u0443\u0434\u043e\u0431\u0441\u0442\u0432\u0430 \u043f\u043e\u0438\u0441\u043a\u0430 \u0432 Selenoid capabilities.setCapability(\"selenoid:options\", prepareSelenoidOptions(context)) }              \/\/ \u041f\u043e\u043b\u043d\u043e\u0441\u0442\u044c\u044e \u0433\u043e\u0442\u043e\u0432\u044b\u0435 capabilities \u043f\u0440\u043e\u0431\u0440\u0430\u0441\u044b\u0432\u0430\u044e\u0442\u0441\u044f \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e             Configuration.browserCapabilities = capabilities         } }      override fun afterAll(context: ExtensionContext) {         \/\/ \u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u043c, \u0447\u0442\u043e \u0442\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u043a\u043b\u0430\u0441\u0441 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 Selenide \u0442\u0435\u0441\u0442\u044b         val matchAnnotation = parseSelenideTest(context)          \/\/ \u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u043c \u0441\u0435\u0441\u0441\u0438\u044e \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f Selenide \u0442\u0435\u0441\u0442\u043e\u0432         matchAnnotation?.let {             WebDriverRunner.closeWebDriver()  }  }  private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? {  return ctx .testClass       .orElseThrow { -> IllegalStateException(\"Something wrong with context. Test must contain ancestor (test class)\") } .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) }  .orElse(null) }  private fun checkSingleThreadExecution(context: ExtensionContext) {  context.getConfigurationParameter(\"junit.jupiter.execution.parallel.enabled\")?.ifPresent { if (it.toBoolean()) throw IllegalStateException(\"This extension works only with single thread mode\")  } }   private fun checkSelenoidSupport(): Boolean { return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) { \"1\" -> true else -> false  } }  private fun checkVideoRecorderSupport(): Boolean { return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) { \"1\" -> true else -> false  } }  private fun prepareSelenoidOptions(context: ExtensionContext): Map&lt;String, Any> {  val selenoidOptions = mutableMapOf&lt;String, Any>( \"enableVNC\" to true,  ) if (checkVideoRecorderSupport()) {  selenoidOptions[\"enableVideo\"] = true  selenoidOptions[\"videoName\"] = generateVideoName(context) } return selenoidOptions  }  private fun generateVideoName(context: ExtensionContext): String {          val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: \"Local\"         val currentTime = ZonedDateTime.now(Clock.systemUTC())             .format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))          val suiteName = context             .testClass .orElseThrow().let { AnnotationUtils.findAnnotation(it, DisplayName::class.java)             }.orElseThrow {                 IllegalStateException(\"Test suite must be annotated with @DisplayName\")             }.value             .splitToSequence(\" \")             .joinToString(\"-\")  val resultName = \"$ciJobId-$suiteName-$currentTime.mp4\"   return resultName }  private val selenoidUrl by lazy { System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL } }<\/code><\/pre>\n<\/p>\n<\/div>\n<\/details>\n<p>I deliberately try to write a simple and clear code for such solutions, so that the solution can be used by many people and teams.<\/p>\n<p>A synthetic test helped verify this solution:<\/p>\n<ol>\n<li>\n<p>Run two test classes:<\/p>\n<\/li>\n<\/ol>\n<pre><code class=\"kotlin\">@SelenideTest  @DisplayName(\"A\")  class ASuite {  @Test fun aTest() { open(\"https:\/\/www.google.com\") Selenide.sleep(5 * 1000) } }<\/code><\/pre>\n<pre><code class=\"kotlin\">@SelenideTest  @DisplayName(\"B\")  class BSuite {  @Test fun bTest() { Selenide.open(\"https:\/\/www.yandex.ru\") Selenide.sleep(5 * 1000) } }<\/code><\/pre>\n<\/div>\n<\/div>\n<\/div>\n<\/div>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-398818","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/398818","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=398818"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/398818\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=398818"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=398818"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=398818"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}