├── .gitignore
├── README.md
├── pom.xml
└── src
└── main
├── java
└── com
│ └── blogspot
│ └── notes
│ └── automation
│ └── qa
│ └── grid
│ ├── core
│ ├── CustomGridLauncher.java
│ ├── HubProxy.java
│ └── VideoRecordingServlet.java
│ ├── entities
│ └── VideoInfo.java
│ ├── enums
│ └── Command.java
│ └── utils
│ ├── IOUtils.java
│ └── VideoRecordingUtils.java
└── resources
└── SendSignalCtrlC.exe
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | target
3 | *.iml
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Selenium server with video recording feature
2 | This project is created to provide native video recording support for Selenium Grid and was initially designed to be used with [docker-selenium](https://github.com/sskorol/docker-selenium) project. See details in related [article](http://qa-automation-notes.blogspot.com/2016/04/docker-selenium-and-bit-of-allure-how.html).
3 |
4 | As Docker will be available on Windows soon, initial approach was extended for supporting corresponding OS. Technically, now you can use it on Windows even without Docker.
5 |
6 | [ffmpeg](https://ffmpeg.org) tool is used to produce mp4 output. The entire recording process is managed on selenium session level.
7 |
8 | `VideoInfo` entity is designed to supply required video options to be able to control output paths, quality and frame rate. As there's no easy way to extend official selenium sources, you should provide exactly the same entity on a client level and pass corresponding info in json format as a part of `DesiredCapabilities`.
9 |
10 | To be able to handle when actual recording is completed, all videos are published into `tmp` folder, which is relative to the provided output. Note that you should map corresponding volumes on Docker level to be able to correctly consume mp4 output. E.g. for `docker-compose.yml` it may look like the following:
11 |
12 | ```
13 | firefoxnode:
14 | image: sskorol/node-firefox-debug:2.53.0
15 | volumes:
16 | - ~/work:/e2e/uploads
17 | - ~/work/tmp:/e2e/uploads/tmp
18 | ```
19 |
20 | On Windows it's not required to map anything. Temporary folder will be created automatically, if it doesn't exist yet.
21 |
22 | To build this project use the following command:
23 |
24 | ```
25 | mvn clean install
26 | ```
27 |
28 | To use newly created jar with [docker-selenium](https://github.com/sskorol/docker-selenium), just copy it into corresponding [Base/lib](https://github.com/sskorol/docker-selenium/tree/master/Base/lib) folder and rebuild required chain of images.
29 |
30 | [](http://www.youtube.com/watch?v=f73ea4-RVHo)
31 |
--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 | 4.0.0
6 |
7 | com.blogspot.notes.automation.qa
8 | selenium-grid
9 | 2.53.1
10 | jar
11 |
12 |
13 | 1.8
14 | UTF-8
15 |
16 |
17 |
18 |
19 |
20 | maven-compiler-plugin
21 | 3.5.1
22 |
23 | ${jdk.version}
24 | ${jdk.version}
25 | ${jdk.version}
26 |
27 |
28 |
29 | maven-assembly-plugin
30 |
31 |
32 | jar-with-dependencies
33 |
34 |
35 |
36 | com.blogspot.notes.automation.qa.grid.core.CustomGridLauncher
37 |
38 |
39 |
40 |
41 |
42 | server
43 | package
44 |
45 | single
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | org.zeroturnaround
56 | zt-exec
57 | 1.9
58 |
59 |
60 | org.apache.httpcomponents
61 | httpclient
62 | 4.5
63 |
64 |
65 | org.seleniumhq.selenium
66 | selenium-server
67 | 2.53.1
68 |
69 |
70 | javax.servlet
71 | javax.servlet-api
72 | 3.1.0
73 |
74 |
75 | com.fasterxml.jackson.datatype
76 | jackson-datatype-jsr310
77 | 2.6.1
78 |
79 |
80 | org.apache.commons
81 | commons-lang3
82 | 3.4
83 |
84 |
85 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/core/CustomGridLauncher.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.core;
2 |
3 | import org.apache.commons.lang3.SystemUtils;
4 | import org.openqa.grid.selenium.GridLauncher;
5 |
6 | import java.util.logging.Logger;
7 |
8 | import static com.blogspot.notes.automation.qa.grid.utils.IOUtils.exportResourceToTmpDir;
9 | import static com.blogspot.notes.automation.qa.grid.utils.VideoRecordingUtils.SEND_CTRL_C_TOOL_NAME;
10 | import static com.blogspot.notes.automation.qa.grid.utils.VideoRecordingUtils.SEND_CTRL_C_TOOL_PATH;
11 |
12 | /**
13 | * Author: Serhii Korol.
14 | */
15 | public class CustomGridLauncher {
16 |
17 | private static final Logger LOGGER = Logger.getLogger(CustomGridLauncher.class.getName());
18 |
19 | public static void main(String[] args) throws Exception {
20 | if (SystemUtils.IS_OS_WINDOWS)
21 | exportResourceToTmpDir(SEND_CTRL_C_TOOL_NAME).ifPresent(path -> {
22 | SEND_CTRL_C_TOOL_PATH = path;
23 | LOGGER.info(SEND_CTRL_C_TOOL_PATH + " is set into global scope");
24 | });
25 | GridLauncher.main(args);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/core/HubProxy.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.core;
2 |
3 | import java.io.*;
4 | import java.util.Optional;
5 | import java.util.logging.Logger;
6 |
7 | import com.blogspot.notes.automation.qa.grid.enums.Command;
8 | import org.apache.http.client.methods.CloseableHttpResponse;
9 | import org.apache.http.client.methods.HttpPost;
10 | import org.apache.http.client.utils.HttpClientUtils;
11 | import org.apache.http.entity.ContentType;
12 | import org.apache.http.entity.StringEntity;
13 | import org.apache.http.impl.client.CloseableHttpClient;
14 | import org.apache.http.impl.client.HttpClientBuilder;
15 | import org.openqa.grid.common.RegistrationRequest;
16 | import org.openqa.grid.internal.Registry;
17 | import org.openqa.grid.internal.TestSession;
18 | import org.openqa.grid.selenium.proxy.DefaultRemoteProxy;
19 |
20 | public class HubProxy extends DefaultRemoteProxy {
21 |
22 | private static final Logger LOGGER = Logger.getLogger(HubProxy.class.getName());
23 |
24 | public HubProxy(final RegistrationRequest request, final Registry registry) throws IOException {
25 | super(request, registry);
26 | }
27 |
28 | @Override
29 | public void beforeSession(final TestSession session) {
30 | super.beforeSession(session);
31 | processRecording(session, Command.START_RECORDING);
32 | }
33 |
34 | @Override
35 | public void afterSession(final TestSession session) {
36 | super.afterSession(session);
37 | processRecording(session, Command.STOP_RECORDING);
38 | }
39 |
40 | private void processRecording(final TestSession session, final Command command) {
41 | final String videoInfo = getCapability(session, "videoInfo");
42 |
43 | if (!videoInfo.isEmpty()) {
44 | final String url = "http://" + this.getRemoteHost().getHost() + ":" + this.getRemoteHost().getPort() +
45 | "/extra/" + VideoRecordingServlet.class.getSimpleName() + "?command=" + command;
46 |
47 | switch (command) {
48 | case START_RECORDING:
49 | sendRecordingRequest(url, videoInfo);
50 | break;
51 | case STOP_RECORDING:
52 | sendRecordingRequest(url, "");
53 | break;
54 | }
55 | }
56 | }
57 |
58 | private void sendRecordingRequest(final String url, final String entity) {
59 | CloseableHttpResponse response = null;
60 |
61 | try (final CloseableHttpClient client = HttpClientBuilder.create().build()) {
62 | final HttpPost post = new HttpPost(url);
63 |
64 | if (!entity.isEmpty()) {
65 | post.setEntity(new StringEntity(entity, ContentType.APPLICATION_JSON));
66 | }
67 |
68 | response = client.execute(post);
69 | LOGGER.info("Node response: " + response);
70 | } catch (Exception ex) {
71 | LOGGER.severe("Unable to send recording request to node: " + ex);
72 | } finally {
73 | HttpClientUtils.closeQuietly(response);
74 | }
75 | }
76 |
77 | private String getCapability(final TestSession session, final String capability) {
78 | return Optional.ofNullable(session.getRequestedCapabilities().get(capability))
79 | .map(cap -> (String) cap)
80 | .orElse("");
81 | }
82 | }
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/core/VideoRecordingServlet.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.core;
2 |
3 | import com.blogspot.notes.automation.qa.grid.entities.VideoInfo;
4 | import com.blogspot.notes.automation.qa.grid.enums.Command;
5 | import com.fasterxml.jackson.databind.ObjectMapper;
6 | import org.apache.http.HttpStatus;
7 |
8 | import javax.servlet.ServletException;
9 | import javax.servlet.http.HttpServlet;
10 | import javax.servlet.http.HttpServletRequest;
11 | import javax.servlet.http.HttpServletResponse;
12 | import java.io.BufferedReader;
13 | import java.io.IOException;
14 | import java.util.Optional;
15 | import java.util.logging.Logger;
16 |
17 | import static com.blogspot.notes.automation.qa.grid.utils.VideoRecordingUtils.startVideoRecording;
18 | import static com.blogspot.notes.automation.qa.grid.utils.VideoRecordingUtils.stopVideoRecording;
19 |
20 | /**
21 | * Created by Serhii Korol
22 | */
23 | public class VideoRecordingServlet extends HttpServlet
24 | {
25 |
26 | private static final long serialVersionUID = -8308677302003045967L;
27 |
28 | private static final Logger LOGGER = Logger.getLogger(VideoRecordingServlet.class.getName());
29 |
30 | @Override
31 | protected void doGet(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException, IllegalArgumentException {
32 | doPost(request, response);
33 | }
34 |
35 | @Override
36 | protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws ServletException, IOException, IllegalArgumentException {
37 | process(request, response);
38 | }
39 |
40 | private void process(final HttpServletRequest request, final HttpServletResponse response) throws IOException, IllegalArgumentException {
41 | final Command command = Optional.ofNullable(request.getParameter("command"))
42 | .map(Command::valueOf)
43 | .orElseThrow(() -> new IllegalArgumentException("Unable to find command for video recording"));
44 |
45 | try {
46 | switch (command) {
47 | case START_RECORDING:
48 | stopVideoRecording();
49 | startVideoRecording(getVideoInfo(request));
50 | updateResponse(response, HttpStatus.SC_OK, "Started recording");
51 | break;
52 | case STOP_RECORDING:
53 | stopVideoRecording();
54 | updateResponse(response, HttpStatus.SC_OK, "Stopped recording");
55 | break;
56 | }
57 | } catch (Exception ex) {
58 | LOGGER.severe("Unable to process recording: " + ex);
59 | updateResponse(response, HttpStatus.SC_INTERNAL_SERVER_ERROR,
60 | "Internal server error occurred while trying to start / stop recording: " + ex);
61 | }
62 | }
63 |
64 | private VideoInfo getVideoInfo(final HttpServletRequest request) throws IOException {
65 | final StringBuilder jsonBuilder = new StringBuilder();
66 | String line;
67 |
68 | try (BufferedReader reader = request.getReader()) {
69 | while ((line = reader.readLine()) != null) {
70 | jsonBuilder.append(line);
71 | }
72 | }
73 |
74 | return new ObjectMapper().findAndRegisterModules().readValue(jsonBuilder.toString(), VideoInfo.class);
75 | }
76 |
77 | private void updateResponse(final HttpServletResponse response, final int status, final String message) throws IOException {
78 | response.setStatus(status);
79 | response.getWriter().write(message);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/entities/VideoInfo.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.entities;
2 |
3 | import com.fasterxml.jackson.annotation.JsonIgnore;
4 |
5 | import java.awt.*;
6 | import java.time.Duration;
7 |
8 | /**
9 | * Author: Serhii Korol.
10 | */
11 | public class VideoInfo {
12 |
13 | private String storagePath;
14 | private String fileName;
15 | private int quality;
16 | private int frameRate;
17 | private Duration timeout;
18 |
19 | private final Dimension screenSize;
20 |
21 | public VideoInfo() {
22 | this.screenSize = Toolkit.getDefaultToolkit().getScreenSize();
23 | }
24 |
25 | public VideoInfo(final String storagePath, final String fileName, final int quality, final int frameRate,
26 | final Duration timeout) {
27 | this();
28 | this.storagePath = storagePath;
29 | this.fileName = fileName;
30 | this.quality = quality;
31 | this.frameRate = frameRate;
32 | this.timeout = timeout;
33 | }
34 |
35 | public String getStoragePath() {
36 | return storagePath;
37 | }
38 |
39 | public void setStoragePath(final String storagePath) {
40 | this.storagePath = storagePath;
41 | }
42 |
43 | public String getFileName() {
44 | return fileName;
45 | }
46 |
47 | public void setFileName(final String fileName) {
48 | this.fileName = fileName;
49 | }
50 |
51 | public int getQuality() {
52 | return quality;
53 | }
54 |
55 | public void setQuality(final int quality) {
56 | this.quality = quality;
57 | }
58 |
59 | public int getFrameRate() {
60 | return frameRate;
61 | }
62 |
63 | public void setFrameRate(final int frameRate) {
64 | this.frameRate = frameRate;
65 | }
66 |
67 | @JsonIgnore
68 | public String getResolution() {
69 | return (int) screenSize.getWidth() + "x" + (int) screenSize.getHeight();
70 | }
71 |
72 | public Duration getTimeout() {
73 | return timeout;
74 | }
75 |
76 | public void setTimeout(final Duration timeout) {
77 | this.timeout = timeout;
78 | }
79 |
80 | @Override
81 | public String toString() {
82 | return "VideoInfo{" +
83 | "storagePath='" + getStoragePath() + '\'' +
84 | ", fileName='" + getFileName() + '\'' +
85 | ", quality=" + getQuality() +
86 | ", frameRate=" + getFrameRate() +
87 | ", timeout=" + getTimeout().getSeconds() +
88 | ", screenSize=" + getResolution() +
89 | '}';
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/enums/Command.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.enums;
2 |
3 | /**
4 | * Author: Serhii Korol.
5 | */
6 | public enum Command {
7 | START_RECORDING,
8 | STOP_RECORDING
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/utils/IOUtils.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.utils;
2 |
3 | import java.io.File;
4 | import java.io.InputStream;
5 | import java.nio.file.Files;
6 | import java.util.Optional;
7 | import java.util.logging.Logger;
8 |
9 | /**
10 | * Author: Serhii Korol.
11 | */
12 | public final class IOUtils {
13 |
14 | private static final Logger LOGGER = Logger.getLogger(IOUtils.class.getName());
15 |
16 | private IOUtils() {
17 | throw new UnsupportedOperationException("Illegal access to private constructor");
18 | }
19 |
20 | public static Optional exportResourceToTmpDir(final String name) {
21 | final File tmpDir = new File(System.getProperty("java.io.tmpdir"));
22 | final File resourceCopy = new File(tmpDir.getAbsolutePath() + File.separator + name);
23 | Optional outputPath;
24 |
25 | if (!tmpDir.exists())
26 | tmpDir.mkdirs();
27 |
28 | if (!resourceCopy.exists()) {
29 | try (final InputStream resourceStream = ClassLoader.getSystemResourceAsStream(name)) {
30 | Files.copy(resourceStream, resourceCopy.getAbsoluteFile().toPath());
31 | outputPath = Optional.of(resourceCopy.getAbsolutePath());
32 | } catch (Exception ex) {
33 | LOGGER.severe("Unable to copy " + name + " into " + tmpDir + ": " + ex);
34 | outputPath = Optional.empty();
35 | }
36 | } else {
37 | outputPath = Optional.of(resourceCopy.getAbsolutePath());
38 | }
39 |
40 | return outputPath;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/main/java/com/blogspot/notes/automation/qa/grid/utils/VideoRecordingUtils.java:
--------------------------------------------------------------------------------
1 | package com.blogspot.notes.automation.qa.grid.utils;
2 |
3 | import com.blogspot.notes.automation.qa.grid.entities.VideoInfo;
4 | import org.apache.commons.lang3.SystemUtils;
5 | import org.zeroturnaround.exec.ProcessExecutor;
6 |
7 | import java.io.File;
8 | import java.io.IOException;
9 | import java.util.Arrays;
10 | import java.util.Optional;
11 | import java.util.concurrent.*;
12 | import java.util.logging.Logger;
13 |
14 | import static org.apache.commons.io.FilenameUtils.separatorsToSystem;
15 |
16 | /**
17 | * Created by Serhii Korol
18 | */
19 | public final class VideoRecordingUtils {
20 |
21 | public static String SEND_CTRL_C_TOOL_PATH;
22 | public static final String SEND_CTRL_C_TOOL_NAME = "SendSignalCtrlC.exe";
23 |
24 | private static final Logger LOGGER = Logger.getLogger(VideoRecordingUtils.class.getName());
25 | private static final String RECORDING_TOOL = "ffmpeg";
26 | private static final String TMP_DIR_NAME = "tmp";
27 |
28 | private VideoRecordingUtils() {
29 | throw new UnsupportedOperationException("Illegal access to private constructor");
30 | }
31 |
32 | public static void startVideoRecording(final VideoInfo info) {
33 | final String tmpPath = info.getStoragePath() + File.separator + TMP_DIR_NAME;
34 | createTmpDirectory(tmpPath);
35 | final String outputPath = parseFileName(tmpPath, info.getFileName(), "mp4");
36 | final String display = SystemUtils.IS_OS_LINUX ? System.getenv("DISPLAY") : "desktop";
37 | final String recorder = SystemUtils.IS_OS_LINUX ? "x11grab" : "gdigrab";
38 | final String[] commandsSequence = new String[]{
39 | RECORDING_TOOL, "-y",
40 | "-video_size", info.getResolution(),
41 | "-f", recorder,
42 | "-i", display,
43 | "-an",
44 | "-vcodec", "libx264",
45 | "-crf", String.valueOf(info.getQuality()),
46 | "-r", String.valueOf(info.getFrameRate()),
47 | "-t", String.valueOf(info.getTimeout().getSeconds()),
48 | outputPath
49 | };
50 |
51 | CompletableFuture.supplyAsync(() -> runCommand(commandsSequence))
52 | .whenCompleteAsync((output, errors) -> {
53 | LOGGER.info("Start recording output log: " + output + (errors != null ? "; ex: " + errors : ""));
54 | LOGGER.info("Trying to copy " + outputPath + " to the main folder.");
55 | copyFile(info.getStoragePath(), info.getFileName());
56 | });
57 | }
58 |
59 | public static void stopVideoRecording() {
60 | final String output = SystemUtils.IS_OS_LINUX ?
61 | runCommand("pkill", "-INT", RECORDING_TOOL) :
62 | runCommand(Optional.ofNullable(SEND_CTRL_C_TOOL_PATH).orElse(SEND_CTRL_C_TOOL_NAME), getPidOf(RECORDING_TOOL));
63 | LOGGER.info("Stop recording output log: " + output);
64 | }
65 |
66 | private static void copyFile(final String storagePath, final String fileName) {
67 | final String inputFileName = parseFileName(storagePath + File.separator + TMP_DIR_NAME, fileName, "mp4");
68 | final String outputFileName = parseFileName(storagePath, fileName, "mp4");
69 | final String output = SystemUtils.IS_OS_LINUX ? runCommand("cp", inputFileName, outputFileName) :
70 | runCommand("cmd", "/c", "copy", inputFileName, outputFileName);
71 | LOGGER.info("File copy output log: " + output);
72 | }
73 |
74 | private static String runCommand(final String... args) {
75 | LOGGER.info("Trying to execute the following command: " + Arrays.asList(args));
76 | try {
77 | return new ProcessExecutor()
78 | .command(args)
79 | .readOutput(true)
80 | .execute()
81 | .outputUTF8();
82 | } catch (IOException | InterruptedException | TimeoutException e) {
83 | LOGGER.severe("Unable to execute command: " + e);
84 | return "PROCESS_EXECUTION_ERROR";
85 | }
86 | }
87 |
88 | private static String parseFileName(final String storagePath, final String fileName, final String extension) {
89 | if (!fileName.isEmpty() && !storagePath.isEmpty()) {
90 | return separatorsToSystem(storagePath + File.separator + fileName + "." + extension);
91 | }
92 |
93 | throw new IllegalArgumentException("Unable to determine output file path");
94 | }
95 |
96 | private static void createTmpDirectory(final String directoryName) {
97 | final String formattedDirectoryName = separatorsToSystem(directoryName);
98 | final File tmpDir = new File(formattedDirectoryName);
99 |
100 | if (!tmpDir.exists()) {
101 | LOGGER.info("Creating " + formattedDirectoryName);
102 | tmpDir.mkdirs();
103 | }
104 | }
105 |
106 | private static String getPidOf(final String processName) {
107 | return runCommand("cmd", "/c", "for /f \"tokens=2\" %i in ('tasklist ^| findstr \"" + processName +
108 | "\"') do @echo %i").trim();
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/main/resources/SendSignalCtrlC.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sskorol/docker-selenium-grid/a0994224765afcf21a114dc8e37e52cc28fd7ffa/src/main/resources/SendSignalCtrlC.exe
--------------------------------------------------------------------------------