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 {
 

Reply via email to