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 87d59774ce initial stub to do UI testing, fixes  #7196 (#7197)
87d59774ce is described below

commit 87d59774ce8977bfe0090d97a918b1585d2ac0f6
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat May 30 17:01:28 2026 +0200

    initial stub to do UI testing, fixes  #7196 (#7197)
    
    test failing flow
    
    remove failing test
---
 .github/workflows/pr_build_code.yml                |  34 +++
 .gitignore                                         |   3 +
 Jenkinsfile                                        |  15 +
 Jenkinsfile.daily                                  |   2 +-
 {rcp => lib-p2}/pom.xml                            |   8 +-
 {rcp/tm4e => lib-p2/swtbot}/pom.xml                |  91 +++---
 {rcp => lib-p2}/tm4e/pom.xml                       |   2 +-
 plugins/pom.xml                                    |  36 +++
 plugins/transforms/abort/pom.xml                   |   2 +
 .../pipeline/transforms/abort/AbortDialogTest.java | 104 +++++++
 pom.xml                                            |  21 +-
 rcp/app/pom.xml                                    |  73 -----
 rcp/pom.xml                                        |  71 ++++-
 rcp/{app => }/src/assembly/assembly.xml            |   0
 .../java/org/apache/hop/ui/core/PrintSpool.java    |   0
 .../apache/hop/ui/core/SafeCTabFolderRenderer.java |   0
 .../apache/hop/ui/core/gui/GuiResourceImpl.java    |   0
 .../ui/core/widget/svg/SvgLabelListenerImpl.java   |   0
 .../org/apache/hop/ui/hopgui/CanvasFacadeImpl.java |   0
 .../apache/hop/ui/hopgui/CanvasListenerImpl.java   |   0
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     |   0
 .../hop/ui/hopgui/ContentEditorTm4eSupport.java    |   0
 .../java/org/apache/hop/ui/hopgui/HopGuiImpl.java  |   0
 .../hopgui/RuleBasedSourceViewerConfiguration.java |   0
 .../hop/ui/hopgui/ServerPushSessionFacadeImpl.java |   0
 .../hop/ui/hopgui/TextSizeUtilFacadeImpl.java      |   0
 .../hop/ui/hopgui/ToolBarToolbarContainer.java     |   0
 .../apache/hop/ui/hopgui/ToolbarFacadeImpl.java    |   0
 .../hop/ui/hopgui/context/GuiContextUtilImpl.java  |   0
 .../org/apache/hop/ui/hopgui/grammars/json.json    |   0
 .../org/apache/hop/ui/hopgui/grammars/sql.json     |   0
 .../org/apache/hop/ui/hopgui/grammars/xml.json     |   0
 .../apache/hop/ui/core/widget/HopUiWidgetTest.java |  61 +++++
 ui/pom.xml                                         |  29 ++
 .../org/apache/hop/ui/testing/SwtBotTestBase.java  | 305 +++++++++++++++++++++
 35 files changed, 722 insertions(+), 135 deletions(-)

diff --git a/.github/workflows/pr_build_code.yml 
b/.github/workflows/pr_build_code.yml
index 136082247b..4089925278 100644
--- a/.github/workflows/pr_build_code.yml
+++ b/.github/workflows/pr_build_code.yml
@@ -55,3 +55,37 @@ jobs:
         run: mvn spotless:check
       - name: Build with Maven
         run: MAVEN_OPTS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"; mvn 
clean install -T 1C -B -C -e -fae -V -Dmaven.compiler.fork=true 
-Dsurefire.rerunFailingTestsCount=2 -Dassemblies=false -Djacoco.skip=true 
--file pom.xml
+
+  # Add a UI and run the UI tests only
+  ui-tests:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up JDK 21
+        uses: actions/setup-java@v1
+        with:
+          java-version: 21
+      - name: Cache Maven packages
+        uses: actions/cache@v4
+        with:
+          path: ~/.m2/repository
+          key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
+          restore-keys: |
+            ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
+            ${{ runner.os }}-m2-
+            ${{ runner.os }}-
+      # SWT uses the GTK3 native bindings on Linux; Xvfb provides the virtual 
display.
+      - name: Install Xvfb and SWT GTK libraries
+        run: sudo apt-get update && sudo apt-get install -y xvfb libgtk-3-0
+      - name: Run SWTBot UI tests under Xvfb
+        run: >
+          xvfb-run -a --server-args="-screen 0 1280x1024x24"
+          mvn -B -V -Puitest -Dassemblies=false -Djacoco.skip=true package
+      - name: Upload SWTBot failure screenshots
+        if: failure()
+        uses: actions/upload-artifact@v4
+        with:
+          name: swtbot-screenshots
+          path: '**/screenshots/**'
+          if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 1ae79bd406..968ed7b2b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ assemblies/debug/audit
 # Monaco Editor files are downloaded at build time (see rap/pom.xml)
 rap/src/main/resources/org/apache/hop/ui/hopgui/monaco/vs/
 rap/src/main/resources/org/apache/hop/ui/hopgui/monaco/monaco-files.list
+
+# Screenshots captured by SWTbot
+screenshots/
diff --git a/Jenkinsfile b/Jenkinsfile
index 0d8284709e..d6c8df3d5b 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -113,6 +113,21 @@ pipeline {
                 }
             }
         }
+        stage('UI Tests (SWTBot)') {
+            when {
+                 anyOf { changeset pattern: 
"^(?!docs).*^(?!integration-tests).*" , comparator: "REGEXP" ; equals expected: 
true, actual: params.FORCE_BUILD }
+                }
+            steps {
+                echo 'Running SWTBot UI tests under Xvfb'
+                sh "xvfb-run -a --server-args='-screen 0 1280x1024x24' mvn 
$MAVEN_PARAMS -Puitest -Dassemblies=false -Djacoco.skip=true test"
+            }
+            post {
+                always {
+                    junit(testResults: '**/surefire-reports/*.xml', 
allowEmptyResults: true)
+                    archiveArtifacts(artifacts: '**/screenshots/**', 
allowEmptyArchive: true)
+                }
+            }
+        }
         stage('Unzip Apache Hop'){
             when {
                   anyOf { changeset pattern: 
"^(?!docs).*^(?!integration-tests).*" , comparator: "REGEXP" ; equals expected: 
true, actual: params.FORCE_BUILD }
diff --git a/Jenkinsfile.daily b/Jenkinsfile.daily
index 271cbda353..712fafa031 100644
--- a/Jenkinsfile.daily
+++ b/Jenkinsfile.daily
@@ -82,7 +82,7 @@ pipeline {
         stage('Build & Test') {
             steps {
                 echo 'Build & Test'
-                sh "mvn $MAVEN_PARAMS clean install"
+                sh "xvfb-run -a --server-args='-screen 0 1280x1024x24' mvn 
$MAVEN_PARAMS clean install"
             }
         }
         stage('Code Quality') {
diff --git a/rcp/pom.xml b/lib-p2/pom.xml
similarity index 88%
copy from rcp/pom.xml
copy to lib-p2/pom.xml
index 5bfd18ed60..1aca6fc44e 100644
--- a/rcp/pom.xml
+++ b/lib-p2/pom.xml
@@ -24,13 +24,13 @@
         <version>2.19.0-SNAPSHOT</version>
     </parent>
 
-    <artifactId>hop-ui-rcp-parent</artifactId>
+    <artifactId>hop-libs-p2</artifactId>
     <packaging>pom</packaging>
-    <name>Hop GUI (RCP)</name>
+    <name>Hop p2 Libraries</name>
+    <description>Fetch Eclipse artifacts from P2</description>
 
     <modules>
-        <module>app</module>
+        <module>swtbot</module>
         <module>tm4e</module>
     </modules>
-
 </project>
diff --git a/rcp/tm4e/pom.xml b/lib-p2/swtbot/pom.xml
similarity index 56%
copy from rcp/tm4e/pom.xml
copy to lib-p2/swtbot/pom.xml
index a7aa3e35d9..40e909e18f 100644
--- a/rcp/tm4e/pom.xml
+++ b/lib-p2/swtbot/pom.xml
@@ -20,58 +20,43 @@
 
     <parent>
         <groupId>org.apache.hop</groupId>
-        <artifactId>hop-ui-rcp-parent</artifactId>
+        <artifactId>hop-libs-p2</artifactId>
         <version>2.19.0-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
 
     <groupId>org.eclipse</groupId>
-    <artifactId>org.eclipse.tm4e.core</artifactId>
-    <version>0.17.2</version>
+    <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+    <version>4.3.0</version>
     <packaging>pom</packaging>
-    <name>Eclipse TM4E Core Wrapper</name>
+    <name>Eclipse SWTBot Wrapper</name>
 
     <properties>
-        
<tm4e.core.download.dir>${project.build.directory}/download</tm4e.core.download.dir>
-        
<tm4e.core.download.file>org.eclipse.tm4e.core_0.17.2.202511281144.jar</tm4e.core.download.file>
-        
<tm4e.core.download.url>https://download.eclipse.org/tm4e/releases/0.17.2/plugins/${tm4e.core.download.file}</tm4e.core.download.url>
-        
<tm4e.core.jar>${project.build.directory}/${project.artifactId}-${project.version}.jar</tm4e.core.jar>
+        
<swtbot.download.dir>${project.build.directory}/download</swtbot.download.dir>
+        
<swtbot.finder.file>org.eclipse.swtbot.swt.finder_4.3.0.202506021445.jar</swtbot.finder.file>
+        
<swtbot.finder.jar>${project.build.directory}/${project.artifactId}-${project.version}.jar</swtbot.finder.jar>
+        
<swtbot.junit5.file>org.eclipse.swtbot.junit5_x_4.3.0.202506021445.jar</swtbot.junit5.file>
+        
<swtbot.junit5.jar>${project.build.directory}/${project.artifactId}-${project.version}-junit5.jar</swtbot.junit5.jar>
+        
<swtbot.plugins.url>https://download.eclipse.org/technology/swtbot/releases/${project.version}/plugins</swtbot.plugins.url>
     </properties>
 
-    <dependencyManagement>
-        <dependencies>
-            <dependency>
-                <groupId>org.apache.hop</groupId>
-                <artifactId>hop-libs</artifactId>
-                <version>${parent.version}</version>
-                <type>pom</type>
-                <scope>import</scope>
-            </dependency>
-        </dependencies>
-    </dependencyManagement>
-
+    <!-- SWTBot's bundles Import-Package hamcrest, slf4j and (in SWTBotAssert) 
org.junit, so the
+         mirror declares them: anything depending on this wrapper gets the 
runtime stack transitively. -->
     <dependencies>
         <dependency>
-            <groupId>com.google.code.gson</groupId>
-            <artifactId>gson</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>commons-io</groupId>
-            <artifactId>commons-io</artifactId>
-        </dependency>
-        <dependency>
-            <groupId>commons-logging</groupId>
-            <artifactId>commons-logging</artifactId>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.13.2</version>
         </dependency>
         <dependency>
-            <groupId>org.jruby.jcodings</groupId>
-            <artifactId>jcodings</artifactId>
-            <version>1.0.63</version>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest</artifactId>
+            <version>2.2</version>
         </dependency>
         <dependency>
-            <groupId>org.jruby.joni</groupId>
-            <artifactId>joni</artifactId>
-            <version>2.2.6</version>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <version>2.0.17</version>
         </dependency>
     </dependencies>
 
@@ -83,15 +68,27 @@
                 <version>1.9.0</version>
                 <executions>
                     <execution>
-                        <id>download-tm4e-core</id>
+                        <id>download-swtbot-finder</id>
+                        <goals>
+                            <goal>wget</goal>
+                        </goals>
+                        <phase>generate-resources</phase>
+                        <configuration>
+                            
<url>${swtbot.plugins.url}/${swtbot.finder.file}</url>
+                            
<outputDirectory>${swtbot.download.dir}</outputDirectory>
+                            
<outputFileName>${swtbot.finder.file}</outputFileName>
+                        </configuration>
+                    </execution>
+                    <execution>
+                        <id>download-swtbot-junit5</id>
                         <goals>
                             <goal>wget</goal>
                         </goals>
                         <phase>generate-resources</phase>
                         <configuration>
-                            <url>${tm4e.core.download.url}</url>
-                            
<outputDirectory>${tm4e.core.download.dir}</outputDirectory>
-                            
<outputFileName>${tm4e.core.download.file}</outputFileName>
+                            
<url>${swtbot.plugins.url}/${swtbot.junit5.file}</url>
+                            
<outputDirectory>${swtbot.download.dir}</outputDirectory>
+                            
<outputFileName>${swtbot.junit5.file}</outputFileName>
                         </configuration>
                     </execution>
                 </executions>
@@ -103,14 +100,15 @@
                 <version>3.1.0</version>
                 <executions>
                     <execution>
-                        <id>prepare-tm4e-core-jar</id>
+                        <id>prepare-swtbot-jars</id>
                         <goals>
                             <goal>run</goal>
                         </goals>
                         <phase>generate-resources</phase>
                         <configuration>
                             <target>
-                                <copy 
file="${tm4e.core.download.dir}/${tm4e.core.download.file}" overwrite="true" 
tofile="${tm4e.core.jar}"></copy>
+                                <copy 
file="${swtbot.download.dir}/${swtbot.finder.file}" overwrite="true" 
tofile="${swtbot.finder.jar}"></copy>
+                                <copy 
file="${swtbot.download.dir}/${swtbot.junit5.file}" overwrite="true" 
tofile="${swtbot.junit5.jar}"></copy>
                             </target>
                         </configuration>
                     </execution>
@@ -122,7 +120,7 @@
                 <artifactId>build-helper-maven-plugin</artifactId>
                 <executions>
                     <execution>
-                        <id>attach-tm4e-core-jar</id>
+                        <id>attach-swtbot-jars</id>
                         <goals>
                             <goal>attach-artifact</goal>
                         </goals>
@@ -130,8 +128,13 @@
                         <configuration>
                             <artifacts>
                                 <artifact>
-                                    <file>${tm4e.core.jar}</file>
+                                    <file>${swtbot.finder.jar}</file>
+                                    <type>jar</type>
+                                </artifact>
+                                <artifact>
+                                    <file>${swtbot.junit5.jar}</file>
                                     <type>jar</type>
+                                    <classifier>junit5</classifier>
                                 </artifact>
                             </artifacts>
                         </configuration>
diff --git a/rcp/tm4e/pom.xml b/lib-p2/tm4e/pom.xml
similarity index 99%
rename from rcp/tm4e/pom.xml
rename to lib-p2/tm4e/pom.xml
index a7aa3e35d9..e1d5dbfc67 100644
--- a/rcp/tm4e/pom.xml
+++ b/lib-p2/tm4e/pom.xml
@@ -20,7 +20,7 @@
 
     <parent>
         <groupId>org.apache.hop</groupId>
-        <artifactId>hop-ui-rcp-parent</artifactId>
+        <artifactId>hop-libs-p2</artifactId>
         <version>2.19.0-SNAPSHOT</version>
         <relativePath>../pom.xml</relativePath>
     </parent>
diff --git a/plugins/pom.xml b/plugins/pom.xml
index 76e1c11b33..283b46024a 100644
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -85,5 +85,41 @@
             <type>test-jar</type>
             <scope>test</scope>
         </dependency>
+
+        <!-- SWTBot UI-test stack, available to any plugin that adds a 
@Tag("uitest") test extending
+             org.apache.hop.ui.testing.SwtBotTestBase. hop-ui/core/engine/SWT 
are already provided
+             above; these add the harness, the standalone look-and-feel impls, 
and the SWTBot jars
+             (hamcrest/slf4j/junit4 come transitively from the swtbot 
wrapper). All test-scope, so
+             non-UI plugins carry them on the test classpath only and are 
otherwise unaffected. -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-ui</artifactId>
+            <version>${project.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-ui-rcp</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <classifier>junit5</classifier>
+            <scope>test</scope>
+        </dependency>
     </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. -->
 </project>
diff --git a/plugins/transforms/abort/pom.xml b/plugins/transforms/abort/pom.xml
index 76d64638d0..c7f58e55b7 100644
--- a/plugins/transforms/abort/pom.xml
+++ b/plugins/transforms/abort/pom.xml
@@ -29,4 +29,6 @@
     <packaging>jar</packaging>
     <name>Hop Plugins Transforms Abort</name>
 
+    <!-- The SWTBot UI-test stack (harness, swtbot jars, rcp) and the 
@Tag("uitest") gating are all
+         inherited from the hop-plugins parent, so AbortDialogTest needs no 
extra config here. -->
 </project>
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
new file mode 100644
index 0000000000..a6a5145c23
--- /dev/null
+++ 
b/plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+package org.apache.hop.pipeline.transforms.abort;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end SWTBot coverage for the Abort transform's {@link AbortDialog}, 
kept next to the
+ * transform it exercises. The dialog 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 excluded from the normal build (it needs 
a display); run with
+ * {@code mvn -pl plugins/transforms/abort -Puitest test}.
+ */
+@Tag("uitest")
+class AbortDialogTest extends SwtBotTestBase {
+
+  private static final String TRANSFORM_NAME = "abort";
+  private static final String SAFE_STOP_LABEL = "Stop input processing";
+  private static final String ALWAYS_LOG_LABEL = "Always log rows";
+
+  @Test
+  void okWritesEditedOptionsBackToMeta() {
+    AbortMeta meta = new AbortMeta(); // defaults to AbortOption.ABORT, 
alwaysLogRows = false
+    PipelineMeta pipelineMeta = pipelineWith(meta);
+
+    withDialog(
+        parent -> new AbortDialog(parent, new Variables(), meta, 
pipelineMeta).open(),
+        bot -> {
+          SWTBot dialog = bot.shell("Abort").activate().bot();
+
+          // text(0)=transform name, text(1)=abort threshold, text(2)=abort 
message
+          // (creation order in the dialog). Guard the indexing assumption up 
front.
+          assertEquals(TRANSFORM_NAME, dialog.text(0).getText(), "transform 
name field");
+
+          dialog.radio(SAFE_STOP_LABEL).click();
+          dialog.text(1).setText("500");
+          dialog.text(2).setText("Too many rows");
+          dialog.checkBox(ALWAYS_LOG_LABEL).click(); // false -> true
+
+          dialog.button(buttonLabel("System.Button.OK")).click();
+        });
+
+    assertTrue(meta.isSafeStop(), "Safe-stop radio should map to 
AbortOption.SAFE_STOP");
+    assertEquals("500", meta.getRowThreshold());
+    assertEquals("Too many rows", meta.getMessage());
+    assertTrue(meta.isAlwaysLogRows(), "checking the box should enable 
always-log-rows");
+  }
+
+  @Test
+  void cancelLeavesMetaUntouched() {
+    AbortMeta meta = new AbortMeta();
+    PipelineMeta pipelineMeta = pipelineWith(meta);
+
+    withDialog(
+        parent -> new AbortDialog(parent, new Variables(), meta, 
pipelineMeta).open(),
+        bot -> {
+          SWTBot dialog = bot.shell("Abort").activate().bot();
+          dialog.text(1).setText("999"); // edit then cancel - must not be 
persisted
+          dialog.radio(SAFE_STOP_LABEL).click();
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertNull(meta.getRowThreshold(), "Cancel must not write the edited 
threshold");
+    assertTrue(meta.isAbort(), "Cancel must keep the original 
AbortOption.ABORT");
+  }
+
+  private static PipelineMeta pipelineWith(AbortMeta meta) {
+    String pluginId = 
PluginRegistry.getInstance().getPluginId(TransformPluginType.class, meta);
+    assertNotNull(pluginId, "Abort transform plugin must be registered via 
HopEnvironment.init()");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.addTransform(new TransformMeta(pluginId, TRANSFORM_NAME, 
meta));
+    return pipelineMeta;
+  }
+}
diff --git a/pom.xml b/pom.xml
index dd551c5e19..f0b624d9ef 100644
--- a/pom.xml
+++ b/pom.xml
@@ -164,14 +164,15 @@
         
<site-repo-url>scpexe://people.apache.org/www/hop.apache.org/maven/</site-repo-url>
         <sonar-maven-plugin.version>5.5.0.6356</sonar-maven-plugin.version>
         <spotless.skip>false</spotless.skip>
+        <swtbot.version>4.3.0</swtbot.version>
         <target.jdk.version>21</target.jdk.version>
+        <ui.test.argLine></ui.test.argLine>
+        <ui.test.excludedGroups></ui.test.excludedGroups>
+        <ui.test.includedGroups></ui.test.includedGroups>
     </properties>
 
     <dependencyManagement>
         <dependencies>
-            <!-- Jetty: transitive deps (e.g. jersey-jetty-connector) can pull 
older patch lines (12.1.2).
-                 hop-libs already pins Jetty for modules that import it; root 
entries cover the rest of
-                 the reactor without importing hop-libs (would cycle: hop -> 
hop-libs -> parent hop). -->
             <dependency>
                 <groupId>org.eclipse.jetty</groupId>
                 <artifactId>jetty-alpn-client</artifactId>
@@ -448,11 +449,12 @@
                         </goals>
                         <configuration>
                             <argLine>@{argLine} 
-Dfile.encoding=${project.build.sourceEncoding}
-                                ${maven-surefire-plugin.argLine}</argLine>
-                            <!-- this is set by jacoco's prepare-agent 
execution -->
+                                ${maven-surefire-plugin.argLine} 
${ui.test.argLine}</argLine>
                             
<forkCount>${maven-surefire-plugin.forkCount}</forkCount>
                             
<reuseForks>${maven-surefire-plugin.reuseForks}</reuseForks>
                             
<testFailureIgnore>${maven-surefire-plugin.testFailureIgnore}</testFailureIgnore>
+                            <groups>${ui.test.includedGroups}</groups>
+                            
<excludedGroups>${ui.test.excludedGroups}</excludedGroups>
                             <properties>
                                 <includeTags>junit5</includeTags>
                             </properties>
@@ -672,6 +674,7 @@
                 <module>engine-beam</module>
                 <module>lib</module>
                 <module>lib-jdbc</module>
+                <module>lib-p2</module>
                 <module>plugins</module>
                 <module>rap</module>
                 <module>rcp</module>
@@ -793,6 +796,7 @@
             <properties>
                 <env>mac</env>
                 
<swt.artifactId>org.eclipse.swt.cocoa.macosx.aarch64</swt.artifactId>
+                <ui.test.argLine>-XstartOnFirstThread</ui.test.argLine>
             </properties>
             <dependencyManagement>
                 <dependencies>
@@ -810,6 +814,13 @@
                 </dependencies>
             </dependencyManagement>
         </profile>
+        <profile>
+            <id>uitest</id>
+            <properties>
+                <ui.test.excludedGroups></ui.test.excludedGroups>
+                <ui.test.includedGroups>uitest</ui.test.includedGroups>
+            </properties>
+        </profile>
         <profile>
             <id>apache-release</id>
             <build>
diff --git a/rcp/app/pom.xml b/rcp/app/pom.xml
deleted file mode 100644
index 70b52e6419..0000000000
--- a/rcp/app/pom.xml
+++ /dev/null
@@ -1,73 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-  ~ 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.
-  -->
-<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
-    <modelVersion>4.0.0</modelVersion>
-
-    <parent>
-        <groupId>org.apache.hop</groupId>
-        <artifactId>hop-ui-rcp-parent</artifactId>
-        <version>2.19.0-SNAPSHOT</version>
-        <relativePath>../pom.xml</relativePath>
-    </parent>
-
-    <artifactId>hop-ui-rcp</artifactId>
-    <packaging>jar</packaging>
-    <name>Hop GUI (RCP fragment)</name>
-
-    <properties>
-        <jface.text.version>3.29.0</jface.text.version>
-        <tm4e.core.version>0.17.2</tm4e.core.version>
-    </properties>
-
-    <dependencies>
-        <dependency>
-            <groupId>org.eclipse</groupId>
-            <artifactId>org.eclipse.tm4e.core</artifactId>
-            <version>${tm4e.core.version}</version>
-        </dependency>
-        <dependency>
-            <groupId>org.eclipse.platform</groupId>
-            <artifactId>org.eclipse.jface.text</artifactId>
-            <version>${jface.text.version}</version>
-            <exclusions>
-                <exclusion>
-                    <groupId>org.eclipse.platform</groupId>
-                    <artifactId>org.eclipse.swt</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.hop</groupId>
-            <artifactId>hop-core</artifactId>
-            <version>${project.version}</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.hop</groupId>
-            <artifactId>hop-engine</artifactId>
-            <version>${project.version}</version>
-            <scope>provided</scope>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.hop</groupId>
-            <artifactId>hop-ui</artifactId>
-            <version>${project.version}</version>
-            <scope>provided</scope>
-        </dependency>
-    </dependencies>
-</project>
diff --git a/rcp/pom.xml b/rcp/pom.xml
index 5bfd18ed60..eb8c0d029f 100644
--- a/rcp/pom.xml
+++ b/rcp/pom.xml
@@ -22,15 +22,72 @@
         <groupId>org.apache.hop</groupId>
         <artifactId>hop</artifactId>
         <version>2.19.0-SNAPSHOT</version>
+        <relativePath>../pom.xml</relativePath>
     </parent>
 
-    <artifactId>hop-ui-rcp-parent</artifactId>
-    <packaging>pom</packaging>
-    <name>Hop GUI (RCP)</name>
+    <artifactId>hop-ui-rcp</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop GUI (RCP fragment)</name>
 
-    <modules>
-        <module>app</module>
-        <module>tm4e</module>
-    </modules>
+    <properties>
+        <jface.text.version>3.29.0</jface.text.version>
+        <tm4e.core.version>0.17.2</tm4e.core.version>
+    </properties>
 
+    <dependencies>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.tm4e.core</artifactId>
+            <version>${tm4e.core.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.platform</groupId>
+            <artifactId>org.eclipse.jface.text</artifactId>
+            <version>${jface.text.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.eclipse.platform</groupId>
+                    <artifactId>org.eclipse.swt</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-core</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-engine</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-ui</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-ui</artifactId>
+            <version>${project.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <classifier>junit5</classifier>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
 </project>
diff --git a/rcp/app/src/assembly/assembly.xml b/rcp/src/assembly/assembly.xml
similarity index 100%
rename from rcp/app/src/assembly/assembly.xml
rename to rcp/src/assembly/assembly.xml
diff --git a/rcp/app/src/main/java/org/apache/hop/ui/core/PrintSpool.java 
b/rcp/src/main/java/org/apache/hop/ui/core/PrintSpool.java
similarity index 100%
rename from rcp/app/src/main/java/org/apache/hop/ui/core/PrintSpool.java
rename to rcp/src/main/java/org/apache/hop/ui/core/PrintSpool.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/core/SafeCTabFolderRenderer.java 
b/rcp/src/main/java/org/apache/hop/ui/core/SafeCTabFolderRenderer.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/core/SafeCTabFolderRenderer.java
rename to rcp/src/main/java/org/apache/hop/ui/core/SafeCTabFolderRenderer.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/core/gui/GuiResourceImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/core/gui/GuiResourceImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/core/gui/GuiResourceImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/core/gui/GuiResourceImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelListenerImpl.java
 b/rcp/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelListenerImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelListenerImpl.java
rename to 
rcp/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelListenerImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
similarity index 100%
rename from rcp/app/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java
diff --git a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
similarity index 100%
rename from rcp/app/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/ServerPushSessionFacadeImpl.java
 b/rcp/src/main/java/org/apache/hop/ui/hopgui/ServerPushSessionFacadeImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/ServerPushSessionFacadeImpl.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/ServerPushSessionFacadeImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/TextSizeUtilFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/TextSizeUtilFacadeImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/TextSizeUtilFacadeImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/hopgui/TextSizeUtilFacadeImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/ToolBarToolbarContainer.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ToolBarToolbarContainer.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/ToolBarToolbarContainer.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/ToolBarToolbarContainer.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/ToolbarFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ToolbarFacadeImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/ToolbarFacadeImpl.java
rename to rcp/src/main/java/org/apache/hop/ui/hopgui/ToolbarFacadeImpl.java
diff --git 
a/rcp/app/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtilImpl.java
 b/rcp/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtilImpl.java
similarity index 100%
rename from 
rcp/app/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtilImpl.java
rename to 
rcp/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtilImpl.java
diff --git 
a/rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/json.json 
b/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/json.json
similarity index 100%
rename from 
rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/json.json
rename to rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/json.json
diff --git 
a/rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/sql.json 
b/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/sql.json
similarity index 100%
rename from 
rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/sql.json
rename to rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/sql.json
diff --git 
a/rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/xml.json 
b/rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/xml.json
similarity index 100%
rename from 
rcp/app/src/main/resources/org/apache/hop/ui/hopgui/grammars/xml.json
rename to rcp/src/main/resources/org/apache/hop/ui/hopgui/grammars/xml.json
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/widget/HopUiWidgetTest.java 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/HopUiWidgetTest.java
new file mode 100644
index 0000000000..13ea2d4c60
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/core/widget/HopUiWidgetTest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FillLayout;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("uitest")
+class HopUiWidgetTest extends SwtBotTestBase {
+
+  @Test
+  void textVarRoundTripsTypedValue() {
+    withScene(
+        shell -> {
+          shell.setLayout(new FillLayout());
+          new TextVar(new Variables(), shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+        },
+        bot -> {
+          // The variable-insert image label is part of the TextVar composite 
(proves it built).
+          assertNotNull(bot.label(), "TextVar should contribute its variable 
image label");
+          bot.text().setText("Hello ${USER}");
+          assertEquals("Hello ${USER}", bot.text().getText());
+        });
+  }
+
+  @Test
+  void labelTextShowsLabelAndCapturesInput() {
+    withScene(
+        shell -> {
+          shell.setLayout(new FillLayout());
+          new LabelText(shell, "Name:", "Enter a name");
+        },
+        bot -> {
+          assertNotNull(bot.label("Name:"), "LabelText should render its 
label");
+          bot.text().setText("Apache Hop");
+          assertEquals("Apache Hop", bot.text().getText());
+        });
+  }
+}
diff --git a/ui/pom.xml b/ui/pom.xml
index a714039a53..5ad623ad33 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -118,6 +118,19 @@
             <classifier>tests</classifier>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse</groupId>
+            <artifactId>org.eclipse.swtbot.swt.finder</artifactId>
+            <version>${swtbot.version}</version>
+            <classifier>junit5</classifier>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <repositories>
@@ -127,4 +140,20 @@
             
<url>https://packages.jetbrains.team/maven/p/ij/intellij-dependencies</url>
         </repository>
     </repositories>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>test-jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
 </project>
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
new file mode 100644
index 0000000000..8a8b837aaf
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java
@@ -0,0 +1,305 @@
+/*
+ * 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.
+ */
+
+package org.apache.hop.ui.testing;
+
+import java.awt.GraphicsEnvironment;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.junit5.SWTBotJunit5Extension;
+import org.eclipse.swtbot.swt.finder.utils.SWTUtils;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(SWTBotJunit5Extension.class)
+public abstract class SwtBotTestBase {
+
+  /**
+   * Optional hold (milliseconds) applied after the interactions of each 
scene/dialog so the window
+   * stays on screen long enough to screenshot. Defaults to 0 so normal/CI 
runs are not slowed, e.g.
+   * {@code -Dswtbot.test.holdMillis=5000}.
+   */
+  private static final String HOLD_MILLIS_PROPERTY = "swtbot.test.holdMillis";
+
+  protected static Display display;
+
+  @BeforeAll
+  static void initHopUiEnvironment() throws Exception {
+    Assumptions.assumeFalse(
+        GraphicsEnvironment.isHeadless(),
+        "No display available (headless); skipping SWTBot UI tests. Run on a 
desktop or under Xvfb.");
+    // Registers the transform/plugin metadata (e.g. the Abort transform) the 
dialogs look up.
+    HopEnvironment.init();
+    ensureDisplay();
+    // Warm up the Hop look-and-feel (fonts, zoom factor) against this display.
+    PropsUi.getInstance();
+    GuiResource.getInstance();
+    primeEventLoop();
+  }
+
+  protected static synchronized void ensureDisplay() {
+    if (display == null || display.isDisposed()) {
+      display = Display.getDefault();
+    }
+  }
+
+  /** Opens and briefly pumps a throwaway shell so the platform event loop is 
live and warm. */
+  private static void primeEventLoop() {
+    Shell shell = new Shell(display, SWT.NO_TRIM);
+    try {
+      shell.setSize(1, 1);
+      shell.open();
+      long deadline = System.currentTimeMillis() + 300;
+      while (System.currentTimeMillis() < deadline) {
+        if (!display.readAndDispatch()) {
+          try {
+            Thread.sleep(10);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return;
+          }
+        }
+      }
+    } finally {
+      shell.dispose();
+      while (display.readAndDispatch()) {
+        // flush the dispose
+      }
+    }
+  }
+
+  /**
+   * Builds a transient shell, lets {@code build} populate it on the UI 
thread, opens it, then runs
+   * {@code interactions} on a worker thread while this (UI) thread pumps the 
SWT event loop until
+   * the worker finishes. Use this for widgets that do not run their own event 
loop.
+   */
+  protected void withScene(Consumer<Shell> build, Consumer<SWTBot> 
interactions) {
+    ensureDisplay();
+    Shell shell = new Shell(display, SWT.SHELL_TRIM);
+    AtomicReference<Throwable> error = new AtomicReference<>();
+    AtomicBoolean done = new AtomicBoolean(false);
+    try {
+      shell.setText("Hop SWTBot test");
+      build.accept(shell);
+      if (shell.getSize().x == 0 || shell.getSize().y == 0) {
+        shell.setSize(520, 260);
+      }
+      shell.open();
+
+      Thread worker =
+          new Thread(
+              () -> {
+                try {
+                  interactions.accept(new SWTBot(shell));
+                  hold();
+                } catch (InterruptedException ie) {
+                  Thread.currentThread().interrupt();
+                  error.set(ie);
+                } catch (Throwable t) {
+                  // Take the failure screenshot HERE, while the UI is still 
on screen. By the time
+                  // SWTBotJunit5Extension.testFailed fires, the finally below 
has already torn the
+                  // dialog/shell down and the auto-screenshot would just show 
an empty display.
+                  captureLiveScreenshot(t);
+                  error.set(t);
+                } finally {
+                  done.set(true);
+                  display.wake();
+                }
+              },
+              "swtbot-worker");
+      worker.start();
+
+      pumpUntil(done);
+      join(worker);
+      rethrow(error.get());
+    } finally {
+      if (!shell.isDisposed()) {
+        shell.dispose();
+      }
+      drain();
+    }
+  }
+
+  /**
+   * Drives a dialog that runs its own (blocking) event loop, such as a Hop 
transform dialog whose
+   * {@code open()} pumps until the dialog is disposed.
+   *
+   * <p>{@code blockingOpener} receives a parent shell and is expected to 
construct and open the
+   * dialog (the call blocks on the UI thread). {@code interactions} run on a 
worker thread: they
+   * locate the dialog with SWTBot, exercise it, and must close it (e.g. click 
OK/Cancel) so the
+   * opener returns. Should the interactions fail first, every open shell is 
closed so the opener
+   * still returns and the failure is reported.
+   */
+  protected void withDialog(Consumer<Shell> blockingOpener, Consumer<SWTBot> 
interactions) {
+    ensureDisplay();
+    Shell parent = new Shell(display, SWT.SHELL_TRIM);
+    AtomicReference<Throwable> error = new AtomicReference<>();
+    try {
+      Thread worker =
+          new Thread(
+              () -> {
+                try {
+                  interactions.accept(new SWTBot());
+                  hold();
+                } catch (InterruptedException ie) {
+                  Thread.currentThread().interrupt();
+                  error.set(ie);
+                } catch (Throwable t) {
+                  // Take the failure screenshot HERE, while the UI is still 
on screen. By the time
+                  // SWTBotJunit5Extension.testFailed fires, the finally below 
has already torn the
+                  // dialog/shell down and the auto-screenshot would just show 
an empty display.
+                  captureLiveScreenshot(t);
+                  error.set(t);
+                } finally {
+                  // Guarantee the blocking opener returns even if 
interactions failed early.
+                  display.asyncExec(
+                      () -> {
+                        for (Shell openShell : display.getShells()) {
+                          if (!openShell.isDisposed()) {
+                            openShell.close();
+                          }
+                        }
+                      });
+                  display.wake();
+                }
+              },
+              "swtbot-worker");
+      worker.start();
+
+      Throwable openError = null;
+      try {
+        // Runs the dialog's own event loop on the UI thread until the dialog 
closes.
+        blockingOpener.accept(parent);
+      } catch (Throwable t) {
+        openError = t;
+      }
+      // Keep pumping so the worker's SWTBot calls resolve (or time out) and 
its cleanup runs.
+      pumpUntilThreadDone(worker);
+
+      rethrow(error.get() != null ? error.get() : openError);
+    } finally {
+      if (!parent.isDisposed()) {
+        parent.dispose();
+      }
+      drain();
+    }
+  }
+
+  /** Resolves a {@code System.Button.*} label the way the dialogs do, minus 
the SWT mnemonic. */
+  protected static String buttonLabel(String key) {
+    // SWTBot's mnemonic matcher strips '&' from the widget text but does not 
trim, so we mirror
+    // exactly what the button shows (leading/trailing spaces kept, '&' 
removed).
+    return BaseMessages.getString(ITransform.class, key).replace("&", "");
+  }
+
+  private static void hold() throws InterruptedException {
+    long holdMillis = Long.getLong(HOLD_MILLIS_PROPERTY, 0L);
+    if (holdMillis > 0) {
+      Thread.sleep(holdMillis);
+    }
+  }
+
+  private void pumpUntil(AtomicBoolean done) {
+    while (!done.get()) {
+      if (!display.readAndDispatch()) {
+        display.sleep();
+      }
+    }
+  }
+
+  private void pumpUntilThreadDone(Thread worker) {
+    while (worker.isAlive()) {
+      if (!display.readAndDispatch()) {
+        display.sleep();
+      }
+    }
+    drain();
+  }
+
+  private void drain() {
+    while (display.readAndDispatch()) {
+      // flush anything the worker posted right before exiting (e.g. closing 
the parent shell)
+    }
+  }
+
+  private static void join(Thread worker) {
+    try {
+      worker.join();
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+    }
+  }
+
+  private static final AtomicInteger SCREENSHOT_COUNTER = new AtomicInteger();
+
+  /**
+   * Captures the SWT display to {@code 
screenshots/<TestClass>.<method>-N.png} the moment a UI
+   * test's worker thread sees an assertion failure or unexpected exception. 
We do this here, before
+   * the harness's finally tears the dialog/shell down - by the time the 
SWTBot extension's
+   * testFailed runs the UI is gone and its auto-screenshot would just be the 
empty Xvfb desktop.
+   * Best effort: any failure while capturing is swallowed so the original 
test failure still
+   * propagates with its full stack trace.
+   */
+  private static void captureLiveScreenshot(Throwable failure) {
+    String name = "harness-failure";
+    for (StackTraceElement frame : failure.getStackTrace()) {
+      String cn = frame.getClassName();
+      // Skip the harness, JUnit/opentest4j, and JDK frames; the first frame 
left is the test code
+      // (likely a synthetic lambda$<testMethod>$N, which is still a useful 
filename).
+      if (!cn.startsWith("org.apache.hop.ui.testing.")
+          && !cn.startsWith("org.junit.")
+          && !cn.startsWith("org.opentest4j.")
+          && !cn.startsWith("java.")
+          && !cn.startsWith("jdk.")) {
+        name = cn.substring(cn.lastIndexOf('.') + 1) + "." + 
frame.getMethodName();
+        break;
+      }
+    }
+    String path =
+        String.format("screenshots/%s-%d.png", name, 
SCREENSHOT_COUNTER.incrementAndGet());
+    try {
+      SWTUtils.captureScreenshot(path);
+    } catch (Throwable ignored) {
+      // best effort - the original failure must propagate
+    }
+  }
+
+  private static void rethrow(Throwable t) {
+    if (t == null) {
+      return;
+    }
+    if (t instanceof RuntimeException re) {
+      throw re;
+    }
+    if (t instanceof Error err) {
+      throw err;
+    }
+    throw new RuntimeException(t);
+  }
+}

Reply via email to