This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 36fd8e853d Issue #7968 : Isolate SWT UI tests from the interactive
display (#7970)
36fd8e853d is described below
commit 36fd8e853daefa4d7563a8a7df898ec5a2fbaf44
Author: Matt Casters <[email protected]>
AuthorDate: Wed Aug 19 15:50:26 2026 +0200
Issue #7968 : Isolate SWT UI tests from the interactive display (#7970)
Add tools/with-isolated-display.sh so a desktop mvn clean install can run
the SWTBot suite against Xvfb (host xvfb-run, or a Docker sidecar if xvfb
is missing) instead of DISPLAY=:0. Document the Linux-only limit for macOS
Cocoa, add -Pskip-uitest, and correct javadocs that claimed uitest was
already excluded from the default build.
Fixes #7968
---
AGENTS.md | 5 +-
docker/ui-tests/Dockerfile | 34 ++++
docker/ui-tests/compose.yaml | 30 ++++
docker/ui-tests/docker-entrypoint.sh | 24 +++
.../modules/ROOT/pages/setup-dev-environment.adoc | 11 ++
.../beam/transforms/bq/BeamBQOutputDialogTest.java | 5 +-
plugins/pom.xml | 5 +-
.../pipeline/transforms/abort/AbortDialogTest.java | 5 +-
.../transforms/constant/ConstantDialogTest.java | 5 +-
.../pipeline/transforms/fake/FakeDialogTest.java | 5 +-
.../transforms/fake/FakerBrowserDialogTest.java | 5 +-
.../transforms/httppost/HttpPostDialogTest.java | 5 +-
.../SelectValuesDialogMetaTabTest.java | 5 +-
.../transforms/update/UpdateDialogTest.java | 5 +-
pom.xml | 11 ++
.../database/JdbcDriverDownloadDialogTest.java | 5 +-
.../ui/core/widget/TableViewLeaveEditorTest.java | 5 +-
tools/with-isolated-display.sh | 195 +++++++++++++++++++++
.../org/apache/hop/ui/testing/SwtBotTestBase.java | 6 +
19 files changed, 348 insertions(+), 23 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 0ea7ac48dc..ece27f79eb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,10 @@ concise — we will expand the sections below over time.
- **Requirements:** Java 21 (JDK) and Maven 3.6.3+ — or use the bundled wrapper
`./mvnw` (no local Maven needed).
- **Build + unit tests:** `./mvnw clean install` (build with Java 21, otherwise
- tests are skipped).
+ tests are skipped). On Linux wrap a desktop run with
+ `./tools/with-isolated-display.sh` so SWT UI tests do not steal the
+ interactive session. `-Pskip-uitest` excludes them; `-Puitest` runs *only*
+ those tests.
## Conventions
diff --git a/docker/ui-tests/Dockerfile b/docker/ui-tests/Dockerfile
new file mode 100644
index 0000000000..f19d61ffd7
--- /dev/null
+++ b/docker/ui-tests/Dockerfile
@@ -0,0 +1,34 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Sidecar X server for Hop SWT UI tests. Maven, JDK and GTK stay on the host;
+# this image only provides a virtual display the host Surefire JVMs can use.
+FROM debian:bookworm-slim
+
+LABEL maintainer="Apache Hop Team"
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ DISPLAY_NUM=99
+
+RUN apt-get update \
+ && apt-get install --no-install-recommends --assume-yes xvfb \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod 755 /docker-entrypoint.sh
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
diff --git a/docker/ui-tests/compose.yaml b/docker/ui-tests/compose.yaml
new file mode 100644
index 0000000000..8638023325
--- /dev/null
+++ b/docker/ui-tests/compose.yaml
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Virtual X server for host-side Maven/SWT UI tests.
+# Prefer tools/with-isolated-display.sh, which starts this when xvfb-run is not
+# installed. Do not point DISPLAY at the host desktop and do not run xhost.
+services:
+ xvfb:
+ image: hop-ui-test-xvfb:local
+ build: .
+ init: true
+ network_mode: none
+ environment:
+ DISPLAY_NUM: ${DISPLAY_NUM:-99}
+ volumes:
+ - /tmp/.X11-unix:/tmp/.X11-unix
diff --git a/docker/ui-tests/docker-entrypoint.sh
b/docker/ui-tests/docker-entrypoint.sh
new file mode 100755
index 0000000000..d63e0a3d46
--- /dev/null
+++ b/docker/ui-tests/docker-entrypoint.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+# Virtual framebuffer only: unix socket, no TCP, no MIT-SHM (host JVM and this
+# process do not share an IPC namespace). -ac is local-only because we do not
+# listen on the network; it is not xhost on the developer's interactive seat.
+DISPLAY_NUM="${DISPLAY_NUM:-99}"
+mkdir -p /tmp/.X11-unix
+exec Xvfb ":${DISPLAY_NUM}" -ac -screen 0 1280x1024x24 -nolisten tcp
-extension MIT-SHM
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/setup-dev-environment.adoc
b/docs/hop-dev-manual/modules/ROOT/pages/setup-dev-environment.adoc
index 81a699933f..9c6ada8784 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/setup-dev-environment.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/setup-dev-environment.adoc
@@ -81,6 +81,17 @@ Run the following command to build Hop and run all unit
tests:
[source]
mvn clean install
+On Linux a full reactor run also executes the SWT UI tests (`@Tag("uitest")`).
Those tests open real dialogs; pointed at your desktop they steal keyboard
focus for many minutes. Jenkins already wraps Maven in `xvfb-run`. Locally do
the same with the helper, which uses host `xvfb-run` when installed and
otherwise starts a tiny Xvfb Docker sidecar (Maven and GTK stay on the host):
+
+[source]
+----
+./tools/with-isolated-display.sh ./mvnw clean install
+----
+
+`-Puitest` means *only* UI tests, not "also enable them". `-Pskip-uitest` is
the explicit escape hatch that excludes them. Failed UI tests already write a
PNG under `screenshots/` while the shell is still up; do not add success-path
screenshot capture.
+
+On macOS the SWT tests use Cocoa (`-XstartOnFirstThread` from the `swt-mac`
profile). The helper cannot isolate those windows: a host Cocoa JVM has no X11
socket, and the Linux Xvfb sidecar is not visible to macOS SWT. The script
prints a warning and runs the command on the interactive session. Use
`-Pskip-uitest` for a focus-free local Mac build. Reviewing the sidecar itself
is a Docker image/compose check; running the Linux GTK suite from a Mac host
JVM is out of scope.
+
Please make sure all the files you added or changed have the proper license
header.
You can run the following command to verify this:
diff --git
a/plugins/engines/beam/src/test/java/org/apache/hop/beam/transforms/bq/BeamBQOutputDialogTest.java
b/plugins/engines/beam/src/test/java/org/apache/hop/beam/transforms/bq/BeamBQOutputDialogTest.java
index f1b7895e4d..b023aca6bc 100644
---
a/plugins/engines/beam/src/test/java/org/apache/hop/beam/transforms/bq/BeamBQOutputDialogTest.java
+++
b/plugins/engines/beam/src/test/java/org/apache/hop/beam/transforms/bq/BeamBQOutputDialogTest.java
@@ -35,8 +35,9 @@ import org.junit.jupiter.api.Test;
/**
* SWTBot coverage for {@link BeamBQOutputDialog}. The dialog runs its own
blocking event loop in
* {@code open()}, so {@link SwtBotTestBase#withDialog} pumps it on the UI
thread while assertions
- * drive it from a worker. Tagged {@code uitest} so it is excluded from the
normal build (needs a
- * display); run with {@code mvn -pl plugins/engines/beam -Puitest test}.
+ * drive it from a worker. Tagged {@code uitest} so it is skipped when there
is no display. The
+ * default reactor run still includes it on a desktop; wrap Maven with {@code
+ * tools/with-isolated-display.sh} so the dialog does not steal focus.
*/
@Tag("uitest")
class BeamBQOutputDialogTest extends SwtBotTestBase {
diff --git a/plugins/pom.xml b/plugins/pom.xml
index 84d6aeaa8b..b50f163888 100644
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -138,8 +138,9 @@
</dependencies>
<!-- The SWTBot UI-test stack above is on every plugin's test classpath.
The @Tag("uitest")
- gating and the uitest / swtbot-mac profiles are inherited from the
root pom, so a plugin
- needs no extra config to add a UI test extending
org.apache.hop.ui.testing.SwtBotTestBase. -->
+ gating and the uitest / skip-uitest / swt-mac profiles are inherited
from the root pom, so
+ a plugin needs no extra config to add a UI test extending
+ org.apache.hop.ui.testing.SwtBotTestBase. -->
<build>
<plugins>
diff --git
a/plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java
b/plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java
index a6a5145c23..d6511eca83 100644
---
a/plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java
+++
b/plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java
@@ -38,8 +38,9 @@ import org.junit.jupiter.api.Test;
* SwtBotTestBase#withDialog} pumps it on the UI thread while the assertions
drive it from a worker
* thread.
*
- * <p>Tagged {@code uitest} so it is excluded from the normal build (it needs
a display); run with
- * {@code mvn -pl plugins/transforms/abort -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class AbortDialogTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
index fdf3a32b68..9285fffb50 100644
---
a/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
+++
b/plugins/transforms/constant/src/test/java/org/apache/hop/pipeline/transforms/constant/ConstantDialogTest.java
@@ -51,8 +51,9 @@ import org.junit.jupiter.api.Test;
* real OK/Cancel buttons are clicked so the dialog's own {@code ok()}/{@code
cancel()} logic is
* what gets exercised.
*
- * <p>Tagged {@code uitest} so it is skipped on headless machines; run with
{@code mvn -pl
- * plugins/transforms/constant -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class ConstantDialogTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakeDialogTest.java
b/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakeDialogTest.java
index 404330b89b..5209497ea6 100644
---
a/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakeDialogTest.java
+++
b/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakeDialogTest.java
@@ -37,8 +37,9 @@ import org.junit.jupiter.api.Test;
* {@link SwtBotTestBase#withDialog} pumps it on the UI thread while the
assertions drive it from a
* worker thread.
*
- * <p>Tagged {@code uitest} so it is only run with a display; run with {@code
mvn -pl
- * plugins/transforms/fake -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class FakeDialogTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakerBrowserDialogTest.java
b/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakerBrowserDialogTest.java
index f200087095..c4586f0863 100644
---
a/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakerBrowserDialogTest.java
+++
b/plugins/transforms/fake/src/test/java/org/apache/hop/pipeline/transforms/fake/FakerBrowserDialogTest.java
@@ -41,8 +41,9 @@ import org.junit.jupiter.api.Test;
* runs its own blocking event loop in {@code open()}, so {@link
SwtBotTestBase#withDialog} pumps it
* on the UI thread while the assertions drive it from a worker thread.
*
- * <p>Tagged {@code uitest} so it is only run with a display; run with {@code
mvn -pl
- * plugins/transforms/fake -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class FakerBrowserDialogTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostDialogTest.java
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostDialogTest.java
index af45db710b..68c8fefa27 100644
---
a/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostDialogTest.java
+++
b/plugins/transforms/httppost/src/test/java/org/apache/hop/pipeline/transforms/httppost/HttpPostDialogTest.java
@@ -40,8 +40,9 @@ import org.junit.jupiter.api.Test;
* loop in {@code open()}, so {@link SwtBotTestBase#withDialog} pumps it on
the UI thread while the
* assertions drive it from a worker thread.
*
- * <p>Tagged {@code uitest} so it is excluded from the normal build (it needs
a display); run with
- * {@code mvn -pl plugins/transforms/httppost -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class HttpPostDialogTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
b/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
index 7d4f931f32..2594f15c75 100644
---
a/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
+++
b/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
@@ -62,8 +62,9 @@ import org.junit.jupiter.api.Test;
* #editingTheTypeCellStaysInTheTypeColumn()} is the same scene on a plain
CCOMBO column, which is
* committed correctly today.
*
- * <p>Tagged {@code uitest} so it is skipped on headless machines; run with
{@code mvn -pl
- * plugins/transforms/selectvalues -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class SelectValuesDialogMetaTabTest extends SwtBotTestBase {
diff --git
a/plugins/transforms/update/src/test/java/org/apache/hop/pipeline/transforms/update/UpdateDialogTest.java
b/plugins/transforms/update/src/test/java/org/apache/hop/pipeline/transforms/update/UpdateDialogTest.java
index 03c2b4d854..fe2211c8a0 100644
---
a/plugins/transforms/update/src/test/java/org/apache/hop/pipeline/transforms/update/UpdateDialogTest.java
+++
b/plugins/transforms/update/src/test/java/org/apache/hop/pipeline/transforms/update/UpdateDialogTest.java
@@ -54,8 +54,9 @@ import org.junit.jupiter.api.Test;
* the UI thread) the way {@code ConstantDialogTest} does it. The real
OK/Cancel buttons are then
* clicked so the dialog's own {@code ok()}/{@code cancel()} logic is what
gets exercised.
*
- * <p>Tagged {@code uitest} so it is skipped on headless machines; run with
{@code mvn -pl
- * plugins/transforms/update -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class UpdateDialogTest extends SwtBotTestBase {
diff --git a/pom.xml b/pom.xml
index e3d106f3c6..8df7abb8bf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -769,6 +769,11 @@
</dependencies>
</dependencyManagement>
</profile>
+ <!-- uitest: run ONLY @Tag("uitest") classes (needs a display).
+ skip-uitest: exclude them from the reactor run.
+ Default (neither): include them whenever a display is available.
+ On a desktop wrap Maven with tools/with-isolated-display.sh so the
+ shells do not steal the interactive session. -->
<profile>
<id>uitest</id>
<properties>
@@ -776,6 +781,12 @@
<ui.test.includedGroups>uitest</ui.test.includedGroups>
</properties>
</profile>
+ <profile>
+ <id>skip-uitest</id>
+ <properties>
+ <ui.test.excludedGroups>uitest</ui.test.excludedGroups>
+ </properties>
+ </profile>
<profile>
<id>apache-release</id>
<build>
diff --git
a/rcp/src/test/java/org/apache/hop/ui/core/database/JdbcDriverDownloadDialogTest.java
b/rcp/src/test/java/org/apache/hop/ui/core/database/JdbcDriverDownloadDialogTest.java
index 3b75ef6cc9..5c0c5905fb 100644
---
a/rcp/src/test/java/org/apache/hop/ui/core/database/JdbcDriverDownloadDialogTest.java
+++
b/rcp/src/test/java/org/apache/hop/ui/core/database/JdbcDriverDownloadDialogTest.java
@@ -41,8 +41,9 @@ import org.junit.jupiter.api.Test;
* SwtBotTestBase#withDialog} pumps it on the UI thread while the assertions
drive it from a worker
* thread. Both tests Cancel before any download, so nothing is fetched over
the network.
*
- * <p>Tagged {@code uitest} so it is excluded from the normal headless build
(it needs a display);
- * run with {@code mvn -pl rcp -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class JdbcDriverDownloadDialogTest extends SwtBotTestBase {
diff --git
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
index 5cba616fb9..abd6c0e7bf 100644
---
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
+++
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
@@ -49,8 +49,9 @@ import org.junit.jupiter.api.Test;
* the {@code COLUMN_TYPE_TEXT_BUTTON} one, which is a {@link TextVarButton} -
a {@link TextVar},
* not the plain {@link Text} that the type mismatch makes {@code
getTextWidgetValue} cast to.
*
- * <p>Tagged {@code uitest} so it is skipped on headless machines; run with
{@code mvn -pl rcp
- * -Puitest test}.
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. The
default reactor run still
+ * includes it on a desktop; wrap Maven with {@code
tools/with-isolated-display.sh} so the dialog
+ * does not steal focus.
*/
@Tag("uitest")
class TableViewLeaveEditorTest extends SwtBotTestBase {
diff --git a/tools/with-isolated-display.sh b/tools/with-isolated-display.sh
new file mode 100755
index 0000000000..1b09667f6c
--- /dev/null
+++ b/tools/with-isolated-display.sh
@@ -0,0 +1,195 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Run a command (typically ./mvnw) against a virtual X display so SWT UI tests
+# do not steal the interactive session. Prefers host xvfb-run (what Jenkins
+# uses). Falls back to the docker/ui-tests Xvfb sidecar when xvfb is not
+# installed. Never points Maven at the current seat unless
+# HOP_ALLOW_INTERACTIVE_DISPLAY=1 is set as a last resort.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+COMPOSE_FILE="${ROOT}/docker/ui-tests/compose.yaml"
+SCREEN_ARGS="${HOP_XVFB_SCREEN:-1280x1024x24}"
+
+usage() {
+ cat <<'EOF'
+Usage: tools/with-isolated-display.sh COMMAND [ARGS...]
+
+Run COMMAND with SWT/GTK talking to a virtual framebuffer instead of the
+interactive X/Wayland session. Example:
+
+ tools/with-isolated-display.sh ./mvnw clean install
+
+Host xvfb-run is used when installed (same as Jenkins). Otherwise a tiny
+Xvfb Docker sidecar is started and DISPLAY is pointed at its unix socket.
+
+Environment:
+ HOP_ALLOW_INTERACTIVE_DISPLAY=1 run on the current DISPLAY if no Xvfb
+ or Docker sidecar can be started
+ HOP_XVFB_SCREEN=1280x1024x24 Xvfb screen geometry
+EOF
+}
+
+die() {
+ echo "with-isolated-display: $*" >&2
+ exit 1
+}
+
+warn() {
+ echo "with-isolated-display: $*" >&2
+}
+
+if [[ $# -eq 0 ]]; then
+ usage
+ exit 2
+fi
+
+# Force GTK/SWT onto X11 so a Wayland session does not keep sending windows
+# to the seat we are trying to leave alone.
+isolate_env() {
+ export GDK_BACKEND=x11
+ unset WAYLAND_DISPLAY || true
+}
+
+run_on_interactive_display() {
+ warn "running on the interactive display; SWT shells will steal focus"
+ exec "$@"
+}
+
+if command -v xvfb-run >/dev/null 2>&1; then
+ isolate_env
+ exec env -u WAYLAND_DISPLAY GDK_BACKEND=x11 \
+ xvfb-run -a --server-args="-screen 0 ${SCREEN_ARGS}" "$@"
+fi
+
+# xvfb-run is a Linux/X11 tool. A Cocoa or Win32 SWT JVM cannot use this
+# sidecar; say so and run the command as-is rather than pretending.
+os="$(uname -s || echo unknown)"
+if [[ "${os}" != "Linux" ]]; then
+ warn "isolated X11 display is Linux-only (${os}); SWT will use the
interactive session"
+ exec "$@"
+fi
+
+have_docker=0
+if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
+ have_docker=1
+fi
+
+if [[ "${have_docker}" -ne 1 ]]; then
+ if [[ "${HOP_ALLOW_INTERACTIVE_DISPLAY:-}" == "1" ]]; then
+ run_on_interactive_display "$@"
+ fi
+ die "neither xvfb-run nor Docker is available. Install xvfb (preferred) or
Docker, or set HOP_ALLOW_INTERACTIVE_DISPLAY=1"
+fi
+
+display_busy() {
+ local n="$1"
+ [[ -e "/tmp/.X11-unix/X${n}" || -e "/tmp/.X${n}-lock" ]]
+}
+
+pick_display() {
+ local n
+ for n in $(seq 99 199); do
+ if ! display_busy "${n}"; then
+ echo "${n}"
+ return 0
+ fi
+ done
+ return 1
+}
+
+DISPLAY_NUM="$(pick_display)" || die "no free X display in :99-:199"
+
+SIDECAR_COMPOSE=0
+SIDECAR_CID=""
+COMPOSE_PROJECT="hop-xvfb-$$-${DISPLAY_NUM}"
+
+compose_bin() {
+ if docker compose version >/dev/null 2>&1; then
+ echo "docker compose"
+ return 0
+ fi
+ if command -v docker-compose >/dev/null 2>&1; then
+ echo "docker-compose"
+ return 0
+ fi
+ return 1
+}
+
+stop_sidecar() {
+ if [[ "${SIDECAR_COMPOSE}" -eq 1 ]]; then
+ # shellcheck disable=SC2086
+ DISPLAY_NUM="${DISPLAY_NUM}" ${COMPOSE} -f "${COMPOSE_FILE}" -p
"${COMPOSE_PROJECT}" \
+ down --remove-orphans >/dev/null 2>&1 || true
+ elif [[ -n "${SIDECAR_CID}" ]]; then
+ docker stop "${SIDECAR_CID}" >/dev/null 2>&1 || true
+ fi
+}
+
+start_sidecar() {
+ local compose_cmd
+ if compose_cmd="$(compose_bin)"; then
+ COMPOSE="${compose_cmd}"
+ # shellcheck disable=SC2086
+ DISPLAY_NUM="${DISPLAY_NUM}" ${COMPOSE} -f "${COMPOSE_FILE}" -p
"${COMPOSE_PROJECT}" \
+ up -d --build
+ SIDECAR_COMPOSE=1
+ return 0
+ fi
+
+ docker build -t hop-ui-test-xvfb:local "${ROOT}/docker/ui-tests"
+ SIDECAR_CID="$(
+ docker run -d --rm --network none --init \
+ -e "DISPLAY_NUM=${DISPLAY_NUM}" \
+ -v /tmp/.X11-unix:/tmp/.X11-unix \
+ --name "${COMPOSE_PROJECT}" \
+ hop-ui-test-xvfb:local
+ )"
+}
+
+sidecar_logs() {
+ if [[ "${SIDECAR_COMPOSE}" -eq 1 ]]; then
+ # shellcheck disable=SC2086
+ DISPLAY_NUM="${DISPLAY_NUM}" ${COMPOSE} -f "${COMPOSE_FILE}" -p
"${COMPOSE_PROJECT}" \
+ logs >&2 || true
+ elif [[ -n "${SIDECAR_CID}" ]]; then
+ docker logs "${SIDECAR_CID}" >&2 || true
+ fi
+}
+
+wait_for_display() {
+ local n="$1"
+ local i
+ for i in $(seq 1 100); do
+ if [[ -S "/tmp/.X11-unix/X${n}" ]]; then
+ return 0
+ fi
+ sleep 0.1
+ done
+ sidecar_logs
+ return 1
+}
+
+trap stop_sidecar EXIT INT TERM
+
+warn "xvfb-run not found; starting Docker Xvfb sidecar on :${DISPLAY_NUM}"
+start_sidecar
+wait_for_display "${DISPLAY_NUM}" || die "Xvfb sidecar did not create
/tmp/.X11-unix/X${DISPLAY_NUM}"
+
+isolate_env
+export DISPLAY=":${DISPLAY_NUM}"
+"$@"
diff --git a/ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java
b/ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java
index 5fdc60d357..a4c6f108fe 100644
--- a/ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java
+++ b/ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java
@@ -42,6 +42,12 @@ import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
+/**
+ * Shared SWTBot harness for {@code @Tag("uitest")} tests. The default reactor
run includes those
+ * tests whenever a display is available; they are skipped only when the JVM
is headless. On a
+ * desktop wrap Maven with {@code tools/with-isolated-display.sh} so the
shells do not steal the
+ * interactive session. {@code -Puitest} runs only UI tests; {@code
-Pskip-uitest} excludes them.
+ */
@ExtendWith(SWTBotJunit5Extension.class)
public abstract class SwtBotTestBase {