From 383f97923136c161e782d96fb25266276cf2db54 Mon Sep 17 00:00:00 2001 From: Hans Van Akelyen Date: Fri, 29 May 2026 16:44:24 +0200 Subject: [PATCH] initial stub to do UI testing, fixes #7196 test failing flow remove failing test --- .github/workflows/pr_build_code.yml | 34 ++ .gitignore | 3 + Jenkinsfile | 15 + Jenkinsfile.daily | 2 +- lib-p2/pom.xml | 36 +++ lib-p2/swtbot/pom.xml | 162 ++++++++++ {rcp => lib-p2}/tm4e/pom.xml | 2 +- plugins/pom.xml | 36 +++ plugins/transforms/abort/pom.xml | 2 + .../transforms/abort/AbortDialogTest.java | 104 ++++++ pom.xml | 21 +- rcp/app/pom.xml | 73 ----- rcp/pom.xml | 71 +++- rcp/{app => }/src/assembly/assembly.xml | 0 .../org/apache/hop/ui/core/PrintSpool.java | 0 .../hop/ui/core/SafeCTabFolderRenderer.java | 0 .../hop/ui/core/gui/GuiResourceImpl.java | 0 .../core/widget/svg/SvgLabelListenerImpl.java | 0 .../hop/ui/hopgui/CanvasFacadeImpl.java | 0 .../hop/ui/hopgui/CanvasListenerImpl.java | 0 .../ui/hopgui/ContentEditorFacadeImpl.java | 0 .../ui/hopgui/ContentEditorTm4eSupport.java | 0 .../org/apache/hop/ui/hopgui/HopGuiImpl.java | 0 .../RuleBasedSourceViewerConfiguration.java | 0 .../hopgui/ServerPushSessionFacadeImpl.java | 0 .../hop/ui/hopgui/TextSizeUtilFacadeImpl.java | 0 .../ui/hopgui/ToolBarToolbarContainer.java | 0 .../hop/ui/hopgui/ToolbarFacadeImpl.java | 0 .../ui/hopgui/context/GuiContextUtilImpl.java | 0 .../apache/hop/ui/hopgui/grammars/json.json | 0 .../apache/hop/ui/hopgui/grammars/sql.json | 0 .../apache/hop/ui/hopgui/grammars/xml.json | 0 .../hop/ui/core/widget/HopUiWidgetTest.java | 61 ++++ ui/pom.xml | 29 ++ .../apache/hop/ui/testing/SwtBotTestBase.java | 305 ++++++++++++++++++ 35 files changed, 869 insertions(+), 87 deletions(-) create mode 100644 lib-p2/pom.xml create mode 100644 lib-p2/swtbot/pom.xml rename {rcp => lib-p2}/tm4e/pom.xml (99%) create mode 100644 plugins/transforms/abort/src/test/java/org/apache/hop/pipeline/transforms/abort/AbortDialogTest.java delete mode 100644 rcp/app/pom.xml rename rcp/{app => }/src/assembly/assembly.xml (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/core/PrintSpool.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/core/SafeCTabFolderRenderer.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/core/gui/GuiResourceImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelListenerImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/CanvasFacadeImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/CanvasListenerImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/ContentEditorTm4eSupport.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/HopGuiImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/RuleBasedSourceViewerConfiguration.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/ServerPushSessionFacadeImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/TextSizeUtilFacadeImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/ToolBarToolbarContainer.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/ToolbarFacadeImpl.java (100%) rename rcp/{app => }/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtilImpl.java (100%) rename rcp/{app => }/src/main/resources/org/apache/hop/ui/hopgui/grammars/json.json (100%) rename rcp/{app => }/src/main/resources/org/apache/hop/ui/hopgui/grammars/sql.json (100%) rename rcp/{app => }/src/main/resources/org/apache/hop/ui/hopgui/grammars/xml.json (100%) create mode 100644 rcp/src/test/java/org/apache/hop/ui/core/widget/HopUiWidgetTest.java create mode 100644 ui/src/test/java/org/apache/hop/ui/testing/SwtBotTestBase.java diff --git a/.github/workflows/pr_build_code.yml b/.github/workflows/pr_build_code.yml index 136082247bc..4089925278c 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 1ae79bd4066..968ed7b2b7a 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 0d8284709ef..d6c8df3d5b2 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 271cbda353d..712fafa0312 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/lib-p2/pom.xml b/lib-p2/pom.xml new file mode 100644 index 00000000000..1aca6fc44ef --- /dev/null +++ b/lib-p2/pom.xml @@ -0,0 +1,36 @@ + + + + 4.0.0 + + + org.apache.hop + hop + 2.19.0-SNAPSHOT + + + hop-libs-p2 + pom + Hop p2 Libraries + Fetch Eclipse artifacts from P2 + + + swtbot + tm4e + + diff --git a/lib-p2/swtbot/pom.xml b/lib-p2/swtbot/pom.xml new file mode 100644 index 00000000000..40e909e18f0 --- /dev/null +++ b/lib-p2/swtbot/pom.xml @@ -0,0 +1,162 @@ + + + + 4.0.0 + + + org.apache.hop + hop-libs-p2 + 2.19.0-SNAPSHOT + ../pom.xml + + + org.eclipse + org.eclipse.swtbot.swt.finder + 4.3.0 + pom + Eclipse SWTBot Wrapper + + + ${project.build.directory}/download + org.eclipse.swtbot.swt.finder_4.3.0.202506021445.jar + ${project.build.directory}/${project.artifactId}-${project.version}.jar + org.eclipse.swtbot.junit5_x_4.3.0.202506021445.jar + ${project.build.directory}/${project.artifactId}-${project.version}-junit5.jar + https://download.eclipse.org/technology/swtbot/releases/${project.version}/plugins + + + + + + junit + junit + 4.13.2 + + + org.hamcrest + hamcrest + 2.2 + + + org.slf4j + slf4j-api + 2.0.17 + + + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + 1.9.0 + + + download-swtbot-finder + + wget + + generate-resources + + ${swtbot.plugins.url}/${swtbot.finder.file} + ${swtbot.download.dir} + ${swtbot.finder.file} + + + + download-swtbot-junit5 + + wget + + generate-resources + + ${swtbot.plugins.url}/${swtbot.junit5.file} + ${swtbot.download.dir} + ${swtbot.junit5.file} + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + prepare-swtbot-jars + + run + + generate-resources + + + + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + attach-swtbot-jars + + attach-artifact + + generate-resources + + + + ${swtbot.finder.jar} + jar + + + ${swtbot.junit5.jar} + jar + junit5 + + + + + + + + + + + + apache-release + + + + maven-deploy-plugin + + true + + + + + + + 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 a7aa3e35d98..e1d5dbfc676 100644 --- a/rcp/tm4e/pom.xml +++ b/lib-p2/tm4e/pom.xml @@ -20,7 +20,7 @@ org.apache.hop - hop-ui-rcp-parent + hop-libs-p2 2.19.0-SNAPSHOT ../pom.xml diff --git a/plugins/pom.xml b/plugins/pom.xml index 76e1c11b33b..283b46024a8 100644 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -85,5 +85,41 @@ test-jar test + + + + org.apache.hop + hop-ui + ${project.version} + test-jar + test + + + org.apache.hop + hop-ui-rcp + ${project.version} + test + + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + test + + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + junit5 + test + + + diff --git a/plugins/transforms/abort/pom.xml b/plugins/transforms/abort/pom.xml index 76d64638d04..c7f58e55b79 100644 --- a/plugins/transforms/abort/pom.xml +++ b/plugins/transforms/abort/pom.xml @@ -29,4 +29,6 @@ jar Hop Plugins Transforms Abort + 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 00000000000..a6a5145c237 --- /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. + * + *

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 dd551c5e19d..f0b624d9eff 100644 --- a/pom.xml +++ b/pom.xml @@ -164,14 +164,15 @@ scpexe://people.apache.org/www/hop.apache.org/maven/ 5.5.0.6356 false + 4.3.0 21 + + + - org.eclipse.jetty jetty-alpn-client @@ -448,11 +449,12 @@ @{argLine} -Dfile.encoding=${project.build.sourceEncoding} - ${maven-surefire-plugin.argLine} - + ${maven-surefire-plugin.argLine} ${ui.test.argLine} ${maven-surefire-plugin.forkCount} ${maven-surefire-plugin.reuseForks} ${maven-surefire-plugin.testFailureIgnore} + ${ui.test.includedGroups} + ${ui.test.excludedGroups} junit5 @@ -672,6 +674,7 @@ engine-beam lib lib-jdbc + lib-p2 plugins rap rcp @@ -793,6 +796,7 @@ mac org.eclipse.swt.cocoa.macosx.aarch64 + -XstartOnFirstThread @@ -810,6 +814,13 @@ + + uitest + + + uitest + + apache-release diff --git a/rcp/app/pom.xml b/rcp/app/pom.xml deleted file mode 100644 index 70b52e64198..00000000000 --- a/rcp/app/pom.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - 4.0.0 - - - org.apache.hop - hop-ui-rcp-parent - 2.19.0-SNAPSHOT - ../pom.xml - - - hop-ui-rcp - jar - Hop GUI (RCP fragment) - - - 3.29.0 - 0.17.2 - - - - - org.eclipse - org.eclipse.tm4e.core - ${tm4e.core.version} - - - org.eclipse.platform - org.eclipse.jface.text - ${jface.text.version} - - - org.eclipse.platform - org.eclipse.swt - - - - - org.apache.hop - hop-core - ${project.version} - provided - - - org.apache.hop - hop-engine - ${project.version} - provided - - - org.apache.hop - hop-ui - ${project.version} - provided - - - diff --git a/rcp/pom.xml b/rcp/pom.xml index 5bfd18ed60f..eb8c0d029f6 100644 --- a/rcp/pom.xml +++ b/rcp/pom.xml @@ -22,15 +22,72 @@ org.apache.hop hop 2.19.0-SNAPSHOT + ../pom.xml - hop-ui-rcp-parent - pom - Hop GUI (RCP) + hop-ui-rcp + jar + Hop GUI (RCP fragment) - - app - tm4e - + + 3.29.0 + 0.17.2 + + + + org.eclipse + org.eclipse.tm4e.core + ${tm4e.core.version} + + + org.eclipse.platform + org.eclipse.jface.text + ${jface.text.version} + + + org.eclipse.platform + org.eclipse.swt + + + + + org.apache.hop + hop-core + ${project.version} + provided + + + org.apache.hop + hop-engine + ${project.version} + provided + + + org.apache.hop + hop-ui + ${project.version} + provided + + + org.apache.hop + hop-ui + ${project.version} + test-jar + test + + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + test + + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + junit5 + test + + 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 00000000000..13ea2d4c60c --- /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 a714039a535..5ad623ad339 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -118,6 +118,19 @@ tests test + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + test + + + org.eclipse + org.eclipse.swtbot.swt.finder + ${swtbot.version} + junit5 + test + @@ -127,4 +140,20 @@ https://packages.jetbrains.team/maven/p/ij/intellij-dependencies + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + 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 00000000000..8a8b837aaf4 --- /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 build, Consumer interactions) { + ensureDisplay(); + Shell shell = new Shell(display, SWT.SHELL_TRIM); + AtomicReference 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. + * + *

{@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 blockingOpener, Consumer interactions) { + ensureDisplay(); + Shell parent = new Shell(display, SWT.SHELL_TRIM); + AtomicReference 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/.-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$$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); + } +}