Icon for gfsd IntelliJ IDEA

A better X server setup for IntelliJ plugin tests locally and in your CI

Symbolic header image that depicts a component "plugging into" IntelliJ with a plug

Check out all the posts in our IntelliJ plugin development series:

All you need to know for IntelliJ plugin development

When you test an IntelliJ plugin using UI automation, you need to have an instance of IntelliJ running that hosts the plugin under test. That instance needs a graphical environment to run in, such as an X server on Linux. On your workstation, IntelliJ will use the X server that also powers your desktop. That’s good enough to execute tests but overall not ideal: your desktop is unusable while tests are running, since they need to simulate keyboard and mouse input without you interfering.

We have built a more elaborate setup that solves these problems into the Gradle task that starts the testing instance of IntelliJ and below, we’re going to explain it step by step. We’re going to start with a high-level overview of the code and describe each step in detail. Feel free to read sections out of order or skip them entirely.

If you just want the code, you can skip straight to the “Full Code” section below.

The style that is used below is similar to a technique called “Literate Programming”.
// Constants

// Global variables

tasks {
    runIdeForUiTests {
        doFirst {
            // Start an X server.

            // Wait until we can query the X server.

            // Start a window manager.

            // Start a script that syncs clipboards between the host and nested X servers.
        }
    }

    finalizedBy("runIdeForUiTestsCleanup")

    register("runIdeForUiTestsCleanup", Task::class) {
        doFirst {
            // Clean up processes started by "runIdeForUiTests".
        }
    }
}

Start an X server

Mentally replace the // Start an X server. comment with the following code:

val xServer = ProcessBuilder()
xServer.command(
    "Xephyr",
    "-dpi", "96",
    "-resizeable",
    "-s", "0",
    "-screen", "1280x960x24",
    "-sw-cursor",
    TEST_DISPLAY
)
xServerProcess = xServer.start()

Here we’re starting a new process for a program called Xephyr. Xephyr is a nested X server, that is, it acts both as a server and as a client to another X server. It can be thought of as a nested desktop but without a task bar or window borders to simplify a bit.

The command-line arguments configure the virtual screen, enable a fake mouse cursor, and disable the virtual screen saver. You can read up on what each of them means in the Xephyr(1) man page (via man Xephyr).

Finally we’re starting the process and assigning a reference to a global variable which we haven’t defined yet. This is later going to be used when cleaning up the process. Append the following after the // Global variables comment:

var xServerProcess: Process? = null

That’s not enough to make the test instance of IntelliJ use that new X server, though. We also have to set a special environment variable used by X programs to find the server they need to talk to. Append the following right after the doFirst line for that task:

val originalDisplay = environment[DISPLAY]
environment[DISPLAY] = TEST_DISPLAY

This simply assigns the display name of our X server (which we haven’t defined yet) to the DISPLAY environment variable (using a constant which we also haven’t defined yet) and keeps the old value, since we’re going to need that later for clipboard synchronization.

You’re clearly interested in plugin development.
That’s great news: we’re hiring!

The used constants go after the // Constants comment:

val DISPLAY = "DISPLAY"
val TEST_DISPLAY = ":99.0"

Wait until we can query the X server

Replace the // Wait until we can query the X server. comment with:

var xServerIsRunning = false
for (i in 0 until 100) {
    println("Waiting for X server...")
    val xTest = ProcessBuilder().command("xset", "q")
    xTest.environment()[DISPLAY] = TEST_DISPLAY
    if (xTest.start().waitFor() == 0) {
        xServerIsRunning = true
        break
    }
    Thread.sleep(500)
}
if (!xServerIsRunning) {
    throw StopExecutionException("X Server could not be queried, please check if it is running.")
}

This segment tries to run a simple request against the newly started X server multiple times for up to 50 seconds, with a half second delay between each request. If a request succeeds, execution continues. If no request succeeds, an exception is thrown.

The request is sent by the “xset” program which should be part of X server packages. The request simply queries the server for some information.

In a CI setting, you could change this block conditionally to run an X server without any graphical output instead, such as “Xvfb”. The “Full Code” section includes that as well but we have omitted it here for clarity.

Start a window manager

Next we start a tiling window manager in our setup that runs on the Xephyr instance started above. There are two reasons for that:

  1. A tiling window manager automatically resizes the main window of the IntelliJ instance used in tests to fill the virtual screen provided by Xephyr. So when you resize the Xephyr window, IntelliJ is also resized and there are no awkward black borders around the window.
  2. A window manager provides some functionality required by obscure Java AWT methods we use in our tests to check if a window is focused. This probably doesn’t apply to you though.

Replace the // Start a window manager. comment with:

println("Starting i3...")
val i3 = ProcessBuilder()
    .command(
        "i3",
        "-c",
        "i3config"
    )
i3.environment()[DISPLAY] = TEST_DISPLAY
windowManagerProcess = i3.start()

… and the following to the // Global variables block:

var windowManagerProcess: Process? = null

i3 is a popular tiling window manager that’s easy to set up and very flexible. The configuration file passed to it (here called i3config) can be found in the “Full Code” section below.

Start a script that syncs clipboards between the host and nested X servers

This lets you copy something on the host and paste it in the test instance of IntelliJ and vice-versa. Replace the // Start a script that syncs clipboards between the host and nested X servers. comment with:

println("Starting clipboard synchronization...")
val syncClipboards = ProcessBuilder()
    .command(
        "./sync-clipboards",
        originalDisplay as String,
        TEST_DISPLAY
    )
syncClipboards.environment()[DISPLAY] = TEST_DISPLAY
clipboardSyncProcess = syncClipboards.start()

This simply starts the script defined below that synchronizes the nested X server’s clipboard with that of the host X server, passing the original display name as a parameter. In CI, you would conditionally skip that block. Clean up processes started by runIdeForUiTests

Finally, we need to stop the processes started by the runIdeForUiTests task. Gradle tasks don’t have an equivalent to doFirst that runs after the main functionality of the task but you can designate another task as a “finalizer”. Replace the // Clean up processes started by "runIdeForUiTests". comment with:

// Stop the clipboard syncing script.
clipboardSyncProcess?.let {
    it.destroy()
    it.waitFor()
}
clipboardSyncProcess = null

// Stop the window manager.
windowManagerProcess?.let {
    it.destroy()
    it.waitFor()
}
windowManagerProcess = null

// Stop the X server.
xServerProcess?.let {
    it.destroy()
    it.waitFor()
}
xServerProcess = null

This block simply goes through all of the process variables we’ve defined in our setup task, checks if they hold a reference to a process, and stops that process. Then, it removes the process reference.

It is at the very least unusual to use global variables in Gradle scripts and the handling of these processes could be made more robust. For example, when the Gradle task is killed, the cleanup never happens and the processes continue running. One way to solve this could be storing the process IDs in files instead of variables that hold references to processes.

Plugin development seems to be right up your alley!
That’s great news: we’re hiring!

Full Code

build.gradle.kts snippet

/** DISPLAY is the environment variable used by X programs to find the X server to talk to. */
val DISPLAY = "DISPLAY"

/** TEST_DISPLAY is the display name (identifier) of the X server that hosts windows of IntelliJ instances in tests. */
val TEST_DISPLAY = ":99.0"

// These variables hold references to processes started in "runIdeForUiTests".
var clipboardSyncProcess: Process? = null
var windowManagerProcess: Process? = null
var xServerProcess: Process? = null

tasks {
    runIdeForUiTests {
        doFirst {
            val isInCI = false // TODO: Check if running in CI.

            val originalDisplay = environment[DISPLAY]
            environment[DISPLAY] = TEST_DISPLAY

            // Start an X server.
            val xServer = ProcessBuilder()
            if (isInCI) {
                // Xvfb runs a headless X server.
                // CI jobs have no direct graphical output (like a monitor) so we run a headless server and record its screen in tests.
                // Recording is done by the "video-recorder-junit5" library.
                println("Using Xvfb as we're running in CI.")
                xServer.command(
                    "Xvfb",
                    "-ac",
                    "-screen", "0", "1920x1080x24",
                    TEST_DISPLAY
                )
            } else {
                // Xephyr acts both as an X server and as a client to another server (the one that runs our desktops in this case).
                // It lets us inject keyboard and mouse inputs without affecting other applications on the host.
                println("Using Xephyr as we're running in a development environment.")
                xServer.command(
                    "Xephyr",
                    "-dpi", "96",
                    "-resizeable",
                    "-s", "0",
                    "-screen", "1280x960x24",
                    "-sw-cursor",
                    TEST_DISPLAY
                )
            }
            xServerProcess = xServer.start()

            // Wait until we can query the X server.
            var xServerIsRunning = false
            for (i in 0 until 100) {
                println("Waiting for X server...")
                val xTest = ProcessBuilder().command("xset", "q")
                xTest.environment()[DISPLAY] = TEST_DISPLAY
                if (xTest.start().waitFor() == 0) {
                    xServerIsRunning = true
                    break
                }
                Thread.sleep(500)
            }
            if (!xServerIsRunning) {
                throw StopExecutionException("X Server could not be queried, please check if it is running.")
            }

            // Start a window manager.
            // The window manager takes care of automatic resizing of windows running in the test X server to fill the virtual screen size.
            println("Starting i3...")
            val i3 = ProcessBuilder()
                .command(
                    "i3",
                    "-c",
                    "i3config"
                )
            i3.environment()[DISPLAY] = TEST_DISPLAY
            windowManagerProcess = i3.start()

            if (!isInCI) {
                // Start a script that syncs clipboards between the host and nested X servers.
                // This lets you copy something on the host and paste it in the test instance of IntelliJ and vice-versa.
                println("Starting clipboard synchronization...")
                val syncClipboards = ProcessBuilder()
                    .command(
                        "./sync-clipboards",
                        originalDisplay as String,
                        TEST_DISPLAY
                    )
                syncClipboards.environment()[DISPLAY] = TEST_DISPLAY
                clipboardSyncProcess = syncClipboards.start()
            }
        }
    }

    finalizedBy("runIdeForUiTestsCleanup")

    register("runIdeForUiTestsCleanup", Task::class) {
        doFirst {
            // Clean up processes started by "runIdeForUiTests".

            // Stop the clipboard syncing script.
            clipboardSyncProcess?.let {
                it.destroy()
                it.waitFor()
            }
            clipboardSyncProcess = null

            // Stop the window manager.
            windowManagerProcess?.let {
                it.destroy()
                it.waitFor()
            }
            windowManagerProcess = null

            // Stop the X server.
            xServerProcess?.let {
                it.destroy()
                it.waitFor()
            }
            xServerProcess = null
        }
    }
}

./i3config

# i3 config file (v4)
#
# Please see https://i3wm.org/docs/userguide.html for a complete reference!

set $mod Mod5
font pango:monospace 8
default_border none

# Start a terminal.
bindsym $mod+Return exec i3-sensible-terminal

# Kill focused window.
bindsym $mod+Shift+q kill

# Reload the configuration file.
bindsym $mod+Shift+c reload

# Restart i3 inplace (preserves your layout/session).
bindsym $mod+Shift+r restart

# Exit i3 (logs you out of your X session).
bindsym $mod+Shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"

./sync-clipboards

#!/bin/bash

# sync-clipboards synchronizes the clipboard buffers between two X servers, continuously waiting for changes to one server's clipboard and distributing it to the other server.
#
# Initially, the first server's clipboard is copied to the second server.

set -euo pipefail

# REMARK: X servers are addressed by their display names, hence why these variables are called "display".
readonly display1="$1"
readonly display2="$2"

function read_clipboard {
	xclip -out -selection clipboard -display "$1"
}

function write_clipboard {
	echo -n "$2" | xclip -in -selection clipboard -display "$1"
}

# Some X servers such as Xephyr don't start with a clipboard and reading it fails unless we first write to it. So we initialize the second server's clipboard with the contents of the first server's clipboard.
d1="$(read_clipboard "$display1")"
d2="$d1"
last="$d1"
write_clipboard "$display2" "$d2"

# Continuously check and update clipboards.
# The first server's clipboard takes precedence in case both change roughly at the same time.
# REMARK: Unfortunately, there is no way to wait for a clipboard to update, so we just wait in a timeout loop.
while :; do
	d1="$(read_clipboard "$display1")"
	d2="$(read_clipboard "$display2")"

	if [ "$d1" != "$last" ]; then
		# If the first clipboard diverged, update the second.
		d2="$d1"
		write_clipboard "$display2" "$d1"
		last="$d1"
	elif [ "$d2" != "$last" ]; then
		# If the second clipboard diverged, update the first.
		d1="$d2"
		write_clipboard "$display1" "$d2"
		last="$d2"
	fi

	sleep 1
done
| 2023-02-07