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);
+ }
+}