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 8a691e0229 revive selenium tests, fixes #8142 (#8149)
8a691e0229 is described below
commit 8a691e0229175bbb3045131b916b62ff02128dc6
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Aug 28 15:28:56 2026 +0200
revive selenium tests, fixes #8142 (#8149)
---
Jenkinsfile.hop-web-selenium | 24 +-
integration-tests/hopweb/Dockerfile | 60 ---
integration-tests/hopweb/docker-compose.yml | 25 --
integration-tests/hopweb/pom.xml | 133 ------
.../java/org/apache/hop/ui/hopgui/HopWebTest.java | 423 ------------------
.../org/apache/hop/ui/hopgui/ScreenshotUtil.java | 43 --
.../src/test/resources/docker/Dockerfile.web | 25 --
.../test/resources/hopwebtest-docker.properties | 20 -
.../src/test/resources/hopwebtest.properties | 19 -
.../hopweb/src/test/resources/run-tests.sh | 51 ---
integration-tests/scripts/run-tests-docker.sh | 4 +-
integration-tests/scripts/run-tests.sh | 2 +-
pom.xml | 14 +
.../apache/hop/ui/core/dialog/ContextDialog.java | 39 ++
.../hop/ui/core/dialog/ContextDialogTest.java | 68 +++
web-tests/pom.xml | 123 +++++
.../org/apache/hop/web/it/FailureArtifacts.java | 82 ++++
.../org/apache/hop/web/it/HopWebEnvironment.java | 341 ++++++++++++++
.../org/apache/hop/web/it/HopWebSmokeTest.java | 99 +++++
.../java/org/apache/hop/web/it/HopWebTestBase.java | 94 ++++
.../test/java/org/apache/hop/web/it/ServerLog.java | 79 ++++
.../java/org/apache/hop/web/it/ServerLogTest.java | 83 ++++
.../hop/web/it/TransformDialogSweepTest.java | 99 +++++
.../org/apache/hop/web/it/pages/ContextDialog.java | 136 ++++++
.../org/apache/hop/web/it/pages/HopGuiPage.java | 279 ++++++++++++
.../apache/hop/web/it/pages/PipelineGraphPage.java | 175 ++++++++
.../src/test}/resources/hop-config.json | 0
.../src/test/resources/transforms.csv | 495 ++++++++++++---------
28 files changed, 2009 insertions(+), 1026 deletions(-)
diff --git a/Jenkinsfile.hop-web-selenium b/Jenkinsfile.hop-web-selenium
index a6519ba167..ffb22336b8 100644
--- a/Jenkinsfile.hop-web-selenium
+++ b/Jenkinsfile.hop-web-selenium
@@ -21,7 +21,8 @@ def AGENT_LABEL = env.AGENT_LABEL ?: 'ubuntu'
def JDK_NAME = env.JDK_NAME ?: 'jdk_21_latest'
def MAVEN_NAME = env.MAVEN_NAME ?: 'maven_3_latest'
-def MAVEN_PARAMS = "-T 2 -U -B -e -fae -V -Dmaven.compiler.fork=true
-Dsurefire.rerunFailingTestsCount=2"
+// The image the tests drive. Overridable so a release candidate can be
checked the same way.
+def HOP_WEB_IMAGE = env.HOP_WEB_IMAGE ?: 'apache/hop-web:Development'
pipeline {
@@ -53,6 +54,7 @@ pipeline {
logRotator(artifactNumToKeepStr: '5', numToKeepStr: '10')
)
disableConcurrentBuilds()
+ timeout(time: 1, unit: 'HOURS')
}
stages {
@@ -77,14 +79,24 @@ pipeline {
checkout scm
}
}
+ stage('Record image under test') {
+ steps {
+ // These tests drive a published image rather than this
checkout, so record which
+ // one actually ran. Without this a broken main build leaves
the job quietly
+ // testing yesterday's image and reporting green.
+ sh "docker pull ${HOP_WEB_IMAGE}"
+ sh "docker image inspect ${HOP_WEB_IMAGE} --format 'Testing
{{.RepoTags}} digest {{.Id}} created {{.Created}}'"
+ }
+ }
stage('Run Selenium tests'){
steps {
- sh "mvn -f integration-tests/hopweb/pom.xml clean test"
+ sh "mvn -f web-tests/pom.xml -B -e clean test
-Dhopweb.image=${HOP_WEB_IMAGE}"
}
post {
always {
- archiveArtifacts(artifacts:
'integration-tests/hopweb/**/*.png', allowEmptyArchive: true)
- junit(testResults:
'integration-tests/**/surefire-reports/*.xml', allowEmptyResults: true)
+ // Screenshot and rendered DOM of every failed test.
+ archiveArtifacts(artifacts:
'web-tests/target/hopweb-artifacts/**', allowEmptyArchive: true)
+ junit(testResults:
'web-tests/target/surefire-reports/*.xml', allowEmptyResults: true)
}
}
}
@@ -93,6 +105,10 @@ pipeline {
post {
always {
cleanWs()
+ }
+ // Only mail when there is something to act on: a nightly green build
does not need
+ // to reach every developer's inbox.
+ unsuccessful {
emailext(
subject: '${DEFAULT_SUBJECT}',
body: '${DEFAULT_CONTENT}',
diff --git a/integration-tests/hopweb/Dockerfile
b/integration-tests/hopweb/Dockerfile
deleted file mode 100644
index 713a948f6d..0000000000
--- a/integration-tests/hopweb/Dockerfile
+++ /dev/null
@@ -1,60 +0,0 @@
-# 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.
-
-FROM selenium/standalone-chrome
-
-COPY integration-tests/hopweb/src/ /home/hop/src/
-COPY integration-tests/hopweb/pom.xml /home/hop/
-
-RUN sudo useradd -s /bin/bash -m hop \
- && sudo chown -R hop:hop /home/hop/ \
- && sudo mkdir /surefire-reports/ && sudo chown -R hop:hop /surefire-reports
-
-
-RUN sudo apt-get -qqy update \
- && sudo apt-get -qqy remove docker docker.io containerd runc \
- && sudo apt-get -qqy --no-install-recommends install \
- ca-certificates \
- curl \
- gnupg \
- lsb-release \
- uidmap \
- apt-utils \
- && sudo mkdir -p /etc/apt/keyrings \
- && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg
--dearmor -o /etc/apt/keyrings/docker.gpg \
- && echo \
- "deb [arch=$(dpkg --print-architecture)
signed-by=/etc/apt/keyrings/docker.gpg]
https://download.docker.com/linux/ubuntu \
- $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list
> /dev/null \
- && sudo apt-get -qqy update \
- && sudo apt-get -qqy install docker-ce docker-ce-cli containerd.io
docker-compose-plugin \
- && sudo rm -rf /var/lib/apt/lists/* /var/cache/apt/*
-
-
-USER hop
-
-RUN wget
https://dlcdn.apache.org/maven/maven-3/3.8.6/binaries/apache-maven-3.8.6-bin.tar.gz
-P /tmp \
- && cd /tmp/ && tar -xvzf apache-maven-3.8.6-bin.tar.gz \
- && cp /home/hop/src/test/resources/hopwebtest-docker.properties
/home/hop/src/test/resources/hopwebtest.properties \
- && cp /home/hop/src/test/resources/run-tests.sh /home/hop/ \
- && chmod +x /home/hop/run-tests.sh
-
-
-ENV PATH=$PATH:/tmp/apache-maven-3.8.6/bin/
-
-WORKDIR /home/hop
-ENTRYPOINT ["/bin/bash", "/home/hop/run-tests.sh"]
-
diff --git a/integration-tests/hopweb/docker-compose.yml
b/integration-tests/hopweb/docker-compose.yml
deleted file mode 100644
index d9366df4ca..0000000000
--- a/integration-tests/hopweb/docker-compose.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-# 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.
-
-version: '2.4'
-services:
- hop-web-test:
- build:
- context: ../../.
- dockerfile: integration-tests/hopweb/Dockerfile
- volumes:
- -
../../integration-tests/hopweb/target/surefire-reports:/surefire-reports
diff --git a/integration-tests/hopweb/pom.xml b/integration-tests/hopweb/pom.xml
deleted file mode 100644
index b764322d7b..0000000000
--- a/integration-tests/hopweb/pom.xml
+++ /dev/null
@@ -1,133 +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>
-
- <groupId>org.apache.hop</groupId>
- <artifactId>hop-web-integration-tests</artifactId>
- <version>2.18.0-SNAPSHOT</version>
-
- <name>Hop Web Integration Tests</name>
-
- <properties>
- <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
- <java.version>21</java.version>
- <selenium.version>4.8.1</selenium.version>
- <junit.version>5.8.2</junit.version>
- <surefire.version>2.22.2</surefire.version>
- </properties>
-
- <dependencies>
- <dependency>
- <groupId>org.seleniumhq.selenium</groupId>
- <artifactId>selenium-java</artifactId>
- <version>${selenium.version}</version>
- </dependency>
- <dependency>
- <groupId>org.junit.jupiter</groupId>
- <artifactId>junit-jupiter-engine</artifactId>
- <version>${junit.version}</version>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.junit.jupiter</groupId>
- <artifactId>junit-jupiter-params</artifactId>
- <version>${junit.version}</version>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>commons-io</groupId>
- <artifactId>commons-io</artifactId>
- <version>2.11.0</version>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.testcontainers</groupId>
- <artifactId>testcontainers</artifactId>
- <version>1.21.4</version>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>io.github.bonigarcia</groupId>
- <artifactId>webdrivermanager</artifactId>
- <version>6.3.2</version>
- </dependency>
- </dependencies>
-
- <reporting>
- <plugins>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-surefire-report-plugin</artifactId>
- <version>${surefire.version}</version>
- </plugin>
- </plugins>
- </reporting>
-
- <build>
- <plugins>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-site-plugin</artifactId>
- <version>3.8.2</version>
- </plugin>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-surefire-plugin</artifactId>
- <version>3.0.0-M7</version>
- <dependencies>
- <dependency>
- <groupId>me.fabriciorby</groupId>
-
<artifactId>maven-surefire-junit5-tree-reporter</artifactId>
- <version>0.1.0</version>
- </dependency>
- </dependencies>
- <configuration>
- <!-- disable system classloader as a workaround for
https://github.com/carlossg/docker-maven/issues/90 -->
- <useSystemClassLoader>false</useSystemClassLoader>
- <reportFormat>plain</reportFormat>
- <consoleOutputReporter>
- <disable>true</disable>
- </consoleOutputReporter>
- <statelessTestsetInfoReporter
implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5StatelessTestsetInfoTreeReporter"
/>
- <statelessTestsetReporter
implementation="org.apache.maven.plugin.surefire.extensions.junit5.JUnit5Xml30StatelessReporter">
- <disable>false</disable>
- <version>3.0</version>
- <usePhrasedFileName>false</usePhrasedFileName>
-
<usePhrasedTestSuiteClassName>true</usePhrasedTestSuiteClassName>
-
<usePhrasedTestCaseClassName>true</usePhrasedTestCaseClassName>
-
<usePhrasedTestCaseMethodName>true</usePhrasedTestCaseMethodName>
- </statelessTestsetReporter>
- </configuration>
- </plugin>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-compiler-plugin</artifactId>
- <version>3.8.1</version>
- <configuration>
- <source>${java.version}</source>
- <target>${java.version}</target>
- </configuration>
- </plugin>
- </plugins>
- </build>
-</project>
diff --git
a/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/HopWebTest.java
b/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/HopWebTest.java
deleted file mode 100644
index 1756f0f3fc..0000000000
---
a/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/HopWebTest.java
+++ /dev/null
@@ -1,423 +0,0 @@
-/*
- * 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.hopgui;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.fail;
-
-import io.github.bonigarcia.wdm.WebDriverManager;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.time.Duration;
-import java.util.List;
-import java.util.Properties;
-import org.junit.jupiter.api.AfterAll;
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Order;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvFileSource;
-import org.openqa.selenium.By;
-import org.openqa.selenium.Dimension;
-import org.openqa.selenium.ElementNotInteractableException;
-import org.openqa.selenium.Keys;
-import org.openqa.selenium.NoSuchElementException;
-import org.openqa.selenium.WebDriver;
-import org.openqa.selenium.WebElement;
-import org.openqa.selenium.chrome.ChromeDriver;
-import org.openqa.selenium.chrome.ChromeOptions;
-import org.openqa.selenium.interactions.Actions;
-import org.openqa.selenium.support.ui.ExpectedConditions;
-import org.openqa.selenium.support.ui.WebDriverWait;
-import org.opentest4j.AssertionFailedError;
-import org.testcontainers.containers.GenericContainer;
-import org.testcontainers.containers.wait.strategy.Wait;
-import org.testcontainers.images.builder.ImageFromDockerfile;
-
-public class HopWebTest {
-
- private static int MARGIN_TOP = 35;
- private static int MARGIN_LEFT = 10;
- private static int WAIT_SHORT = 2000;
- private static int WAIT_MEDIUM = 3000;
- private static int WAIT_LONG = 5000;
- private static int WAIT_VERY_LONG = 10000;
- private static WebDriver driver;
- private static Actions actions;
- private static WebDriverWait wait;
- private WebElement contextCanvas, pipelineCanvas;
- private Dimension canvasDimension;
- private static boolean isHeadless;
- private static String baseUrl, transformName;
- private static String transformsFile;
- private static ScreenshotUtil screenshotUtil;
- private static GenericContainer<?> hopWebContainer;
-
- @BeforeAll
- public static void setUp() throws Exception {
- hopWebContainer =
- new GenericContainer<>(
- new ImageFromDockerfile()
- .withFileFromClasspath("Dockerfile",
"docker/Dockerfile.web")
- .withFileFromClasspath(
- "resources/hop-config.json",
"docker/resources/hop-config.json"))
- .withExposedPorts(8080)
- .waitingFor(Wait.forHttp("/ui"));
- hopWebContainer.start();
-
- screenshotUtil = new ScreenshotUtil();
-
- InputStream input =
- new FileInputStream(
-
HopWebTest.class.getClassLoader().getResource("hopwebtest.properties").getFile());
- Properties properties = new Properties();
- properties.load(input);
- baseUrl =
- "http://" + hopWebContainer.getHost() + ":" +
hopWebContainer.getFirstMappedPort() + "/ui";
- System.out.println("Connection URL used: " + baseUrl);
- isHeadless = Boolean.valueOf(properties.getProperty("headless"));
- transformsFile = properties.getProperty("transformsFile");
-
- System.setProperty("webdriver.chrome.whitelistedIps", "");
-
- // Use webDriverManager to fetch correct chromedriver
- WebDriverManager.chromedriver().setup();
-
- ChromeOptions options = new ChromeOptions();
- if (isHeadless) {
- options.addArguments("headless");
- }
- options.addArguments("--no-sandbox");
- options.addArguments("--disable-dev-shm-usage");
- options.addArguments("--window-size=1280,800");
- options.addArguments("--remote-allow-origins=*");
-
- driver = new ChromeDriver(options);
- actions = new Actions(driver);
- wait = new WebDriverWait(driver, Duration.ofSeconds(3));
- driver.manage().timeouts().implicitlyWait(Duration.ofMillis(WAIT_SHORT));
- driver.get(baseUrl);
-
- // sleep for 2 minutes to give Hop Web plenty of time to fully start.
- Thread.sleep(Integer.valueOf(properties.getProperty("sleepStart")));
-
- // check if the help dialog is shown. If it is, close it.
- checkWelcomeDialog();
- }
-
- @AfterAll
- public static void cleanUp() throws Exception {
- hopWebContainer.stop();
- }
-
- @Test
- @Order(1)
- public void testNewPipeline() throws InterruptedException, IOException {
- // Create a new pipeline
- WebElement newImage = driver.findElement(By.id("toolbar-10010-new"));
- WebElement element = newImage.findElement(By.xpath("./../.."));
- new Actions(driver).moveToElement(element).click().perform();
-
- Thread.sleep(WAIT_SHORT);
-
- contextCanvas = getLastCanvas();
-
- assertNotNull(contextCanvas);
-
- clickFirstCanvasElement(contextCanvas);
-
- WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(5));
- wait.until(
-
ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[text()='New
pipeline']")));
- assertEquals(1, driver.findElements(By.xpath("//div[text()='New
pipeline']")).size());
- Thread.sleep(WAIT_LONG);
- }
-
- /**
- * Walk over the list of transforms and run tests for each of those.
Implicit/explicit waits don't
- * really help here, so still resorting to Thread.sleep().
- *
- * @param transformName
- * @throws InterruptedException
- * @throws IOException
- */
- @ParameterizedTest(name = "{index} => transform=''{0}''")
- @CsvFileSource(files = "src/test/resources/transforms.csv")
- @Order(2)
- public void testAddTransform(String transformName) throws
InterruptedException, IOException {
-
System.out.println("#############################################################");
- System.out.println(transformName + " - Starting test");
-
System.out.println("#############################################################");
- createNewTransform(transformName);
- }
-
- /**
- * Check if the Hop welcome dialog is shown. If it is, check if it is
benign, then close it.
- *
- * @throws InterruptedException
- */
- private static void checkWelcomeDialog() throws InterruptedException,
IOException {
-
- try {
- WebElement welcomeCloseElement =
- driver.findElement(By.xpath("//div[text()='" + "Apache Hop" +
"']/../div[5]"));
-
- if (welcomeCloseElement != null) {
- new
Actions(driver).moveToElement(welcomeCloseElement).click().perform();
- }
- } catch (NoSuchElementException e) {
- screenshotUtil.takeScreenshot(driver, "welcome-dialog-close-failed.png");
- }
- }
-
- private void closeAndcreateNewPipeline() throws InterruptedException,
IOException {
- driver.navigate().refresh();
-
- checkWelcomeDialog();
-
- testNewPipeline();
- }
-
- public void createNewTransform(String transformName) throws
InterruptedException, IOException {
-
- this.transformName = transformName;
- pipelineCanvas = getGraphCanvas();
-
- clickCanvasAtPos(pipelineCanvas, 0, 0);
-
- contextCanvas = getLastCanvas();
-
- sendInput(transformName);
-
- clickFirstCanvasElement(contextCanvas);
-
- Thread.sleep(WAIT_SHORT);
-
- clickCanvasAtPos(pipelineCanvas, 10, 10);
-
- Thread.sleep(WAIT_SHORT);
- sendInput("edit");
-
- contextCanvas = getLastCanvas();
-
- clickFirstCanvasElement(contextCanvas);
-
- // can we pick up the transform's dialog?
- try {
- System.out.println(transformName + " - Check if Dialog is open");
- assertTrue(isElementPresent(transformName));
- } catch (AssertionFailedError e) {
- restartOnFailure(e);
- }
-
- try {
-
- // close the dialog
- System.out.println(transformName + " - Closing dialog (Esc)");
- driver.findElement(By.xpath("//body")).sendKeys(Keys.ESCAPE);
- Thread.sleep(WAIT_SHORT);
-
- // for some transforms (e.g. Append Streams), the dialog is closed
correctly, but the dialog
- // element keeps floating around, causing this assertion to fail.
- // check that we can't get the transform's dialog anymore
- System.out.println(transformName + " - Check if Dialog is gone");
- assertFalse(isElementPresent(transformName));
- } catch (AssertionFailedError e) {
- screenshotUtil.takeScreenshot(driver, transformName +
"-could-not-get-dialog.png");
- restartOnFailure(e);
- }
-
- // open the context dialog again to delete this transform.
- try {
-
- // pick up the pipeline canvas again before we remove the transform
- pipelineCanvas = getCanvas(0);
- assertNotNull(pipelineCanvas);
-
- // now open the context dialog to remove the transform
- new Actions(driver)
- .moveToElement(pipelineCanvas, 10, 10)
- .click()
- .pause(Duration.ofMillis(WAIT_SHORT))
- .perform();
-
- } catch (AssertionFailedError e) {
- screenshotUtil.takeScreenshot(driver, transformName +
"-could-not-get-delete-dialog.png");
- restartOnFailure(e);
- }
-
- try {
-
- System.out.println(transformName + " - Delete Transform");
- contextCanvas = getLastCanvas();
-
- sendInput("delete");
-
- clickFirstCanvasElement(contextCanvas);
-
- // check if the context dialog was closed correctly.
- // TODO: check if element is gone
-
- } catch (AssertionFailedError e) {
- screenshotUtil.takeScreenshot(driver, transformName +
"-delete-dialog-not-closed.png");
- restartOnFailure(e);
- }
- }
-
- private String getElementHtml(String transformName) {
- try {
- WebElement transformNameElement =
- driver.findElement(By.xpath("//div[text()='" + transformName +
"']"));
- WebElement transformElement =
transformNameElement.findElement(By.xpath("./../.."));
-
- String transformHtml = transformElement.getAttribute("innerHTML");
-
- return transformHtml;
-
- } catch (Exception e) {
- e.printStackTrace();
- screenshotUtil.takeScreenshot(driver, transformName +
"-get-element-html.png");
- }
- return null;
- }
-
- private boolean isElementPresent(String transformName) {
- try {
- String checkTransformNameTest =
- driver.findElement(By.xpath("//body/div[5]/div[1]")).getText();
-
- if (!checkTransformNameTest.equals(transformName)) {
- System.out.println(
- transformName + " - Dialog name does not match is : " +
checkTransformNameTest);
- return false;
- }
-
- WebElement transformNameElement =
- driver.findElement(By.xpath("//div[text()='" + transformName +
"']"));
- String transformHtml = transformNameElement.getAttribute("innerHTML");
-
- if (transformNameElement != null) {
- System.out.println(transformName + " - Element found");
- return true;
- } else {
- System.out.println(transformName + " - Element not found");
- return false;
- }
- } catch (NoSuchElementException e) {
- System.out.println(transformName + " - Element not found");
- return false;
- }
- }
-
- private void clickElementByXpath(String xpath) {
- WebElement element = driver.findElement(By.xpath(xpath));
- new Actions(driver).moveToElement(element).click().perform();
- }
-
- private void clickElementById(String id) {
- WebElement element = driver.findElement(By.id(id));
- new Actions(driver).moveToElement(element).click().perform();
- }
-
- private void clickFirstCanvasElement(WebElement canvas) throws
InterruptedException {
-
- canvasDimension = canvas.getSize();
-
- int posX = 0 - (canvasDimension.getWidth() / 2) + MARGIN_LEFT;
- int posY = 0 - (canvasDimension.getHeight() / 2) + MARGIN_TOP;
-
- clickCanvasAtPos(canvas, posX, posY);
- }
-
- private void clickCanvasAtPos(WebElement canvas, int posX, int posY) {
- try {
- new Actions(driver)
- .moveToElement(canvas, posX, posY)
- .click()
- .pause(Duration.ofMillis(WAIT_SHORT))
- .perform();
- } catch (Exception e) {
- screenshotUtil.takeScreenshot(
- driver, transformName + "-canvas-click" + posX + "-" + posY +
".png");
- }
- }
-
- private WebElement getCanvas(int canvasNb) throws IOException,
InterruptedException {
- try {
- WebElement canvas =
driver.findElements(By.xpath("//canvas")).get(canvasNb);
- assertNotNull(canvas);
- return canvas;
- } catch (AssertionFailedError e) {
- screenshotUtil.takeScreenshot(driver, transformName + "-get-canvas.png");
- restartOnFailure(e);
- }
- return null;
- }
-
- private WebElement getGraphCanvas() throws IOException, InterruptedException
{
- // pipeline and workflow canvas is the first one in the dom.
- return getCanvas(0);
- }
-
- private WebElement getContextCanvas() throws InterruptedException,
IOException {
- // context canvas is the second one in the dom.
- return getCanvas(1);
- }
-
- private WebElement getLastCanvas() throws InterruptedException, IOException {
- List<WebElement> canvasList = driver.findElements(By.xpath("//canvas"));
- WebElement canvas = canvasList.get(canvasList.size() - 1);
- assertNotNull(canvas);
- return canvas;
- }
-
- @AfterAll
- public static void tearDown() {
- driver.quit();
- }
-
- public void sendInput(String inputString) throws InterruptedException,
IOException {
- // safe to assume the input we need is the last one added.
- try {
- List<WebElement> inputElements =
driver.findElements(By.tagName("input"));
- WebElement inputElement = inputElements.get(inputElements.size() - 1);
- assertTrue(inputElement.isDisplayed());
- Thread.sleep(WAIT_SHORT);
- inputElement.sendKeys(inputString);
- Thread.sleep(WAIT_SHORT);
- } catch (ElementNotInteractableException e) {
- e.printStackTrace();
- closeAndcreateNewPipeline();
- Thread.sleep(WAIT_LONG);
- } catch (AssertionFailedError e) {
- restartOnFailure(e);
- Thread.sleep(WAIT_LONG);
- }
- }
-
- private void restartOnFailure(AssertionFailedError e) throws
InterruptedException, IOException {
- e.printStackTrace();
- closeAndcreateNewPipeline();
- Thread.sleep(WAIT_LONG);
- fail();
- }
-}
diff --git
a/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/ScreenshotUtil.java
b/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/ScreenshotUtil.java
deleted file mode 100644
index 36ce9c319f..0000000000
---
a/integration-tests/hopweb/src/test/java/org/apache/hop/ui/hopgui/ScreenshotUtil.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * 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.hopgui;
-
-import org.apache.commons.io.FileUtils;
-import org.openqa.selenium.OutputType;
-import org.openqa.selenium.TakesScreenshot;
-import org.openqa.selenium.WebDriver;
-
-import java.io.File;
-import java.io.IOException;
-
-public class ScreenshotUtil {
-
- public ScreenshotUtil(){
- }
-
- public void takeScreenshot(WebDriver driver, String screenshotPath) {
- TakesScreenshot screenshot = (TakesScreenshot) driver;
- File imgAtError = screenshot.getScreenshotAs(OutputType.FILE);
- File errorImageFile = new File("target/images/"
+screenshotPath.replaceAll(" ", "-").toLowerCase());
- try{
- FileUtils.copyFile(imgAtError, errorImageFile);
- }catch(IOException e){
- e.printStackTrace();
- }
- }
-}
diff --git a/integration-tests/hopweb/src/test/resources/docker/Dockerfile.web
b/integration-tests/hopweb/src/test/resources/docker/Dockerfile.web
deleted file mode 100644
index c9453cf14a..0000000000
--- a/integration-tests/hopweb/src/test/resources/docker/Dockerfile.web
+++ /dev/null
@@ -1,25 +0,0 @@
-# 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.
-
-FROM apache/hop-web:Development
-
-COPY resources/hop-config.json
"${CATALINA_HOME}"/webapps/ROOT/config/hop-config.json
-
-# Fix hop-config.json
-RUN sed -i 's/config\/projects/${HOP_CONFIG_FOLDER}\/projects/g'
"${CATALINA_HOME}"/webapps/ROOT/config/hop-config.json
-
-CMD ["/bin/bash", "/tmp/run-web.sh"]
\ No newline at end of file
diff --git
a/integration-tests/hopweb/src/test/resources/hopwebtest-docker.properties
b/integration-tests/hopweb/src/test/resources/hopwebtest-docker.properties
deleted file mode 100644
index c3089ecdf1..0000000000
--- a/integration-tests/hopweb/src/test/resources/hopwebtest-docker.properties
+++ /dev/null
@@ -1,20 +0,0 @@
-# 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.
-driverfile=/home/hop/src/test/resources/chromedriver
-headless=true
-transformsFile=src/test/resources/transforms.csv
-sleepStart=120000
diff --git a/integration-tests/hopweb/src/test/resources/hopwebtest.properties
b/integration-tests/hopweb/src/test/resources/hopwebtest.properties
deleted file mode 100644
index 0734dd4d22..0000000000
--- a/integration-tests/hopweb/src/test/resources/hopwebtest.properties
+++ /dev/null
@@ -1,19 +0,0 @@
-# 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.
-headless=true
-transformsFile=src/test/resources/transforms.csv
-sleepStart=10000
diff --git a/integration-tests/hopweb/src/test/resources/run-tests.sh
b/integration-tests/hopweb/src/test/resources/run-tests.sh
deleted file mode 100644
index cd9fd27994..0000000000
--- a/integration-tests/hopweb/src/test/resources/run-tests.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/bash
-
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-echo "Starting docker deamon"
-
-sudo systemctl start docker
-
-echo "Starting Hop Web UI tests"
-
-# make sure the chrome driver is executable.
-chmod +x /home/hop/src/test/resources/chromedriver
-if [ $? -eq 0 ]
-then
- echo "Chrome driver is executable"
-else
- echo "Failed to make sure Chrome driver is executable. Tests won't run"
- exit 1
-fi
-
-echo "Configuration file used for the tests:"
-echo "======================================"
-cat /home/hop/src/test/resources/hopwebtest.properties
-
-# run the Selenium Hop Web tests
-echo "Starting to run Hop Web UI tests"
-mvn test
-
-# copy the reports and generated images to the surefire volume
-# the build will be marked as failed when there are failed tests, so don't
bother checking the exit code.
-echo "copying test reports"
-sudo cp -r target/surefire-reports/* /surefire-reports
-if [ -d "target/images" ] ; then
- echo "copying screenshots"
- sudo cp -r target/images /surefire-reports
-fi
diff --git a/integration-tests/scripts/run-tests-docker.sh
b/integration-tests/scripts/run-tests-docker.sh
index ad8213f4df..76d64e7ddd 100755
--- a/integration-tests/scripts/run-tests-docker.sh
+++ b/integration-tests/scripts/run-tests-docker.sh
@@ -245,7 +245,7 @@ if docker info >/dev/null 2>&1; then
fi
for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
- if [[ "$d" != *"scripts/" ]] && [[ "$d" != *"surefire-reports/" ]] && [[
"$d" != *"hopweb/" ]]; then
+ if [[ "$d" != *"scripts/" ]] && [[ "$d" != *"surefire-reports/" ]]; then
if [ -d "$d" ] && { [ ! -f "$d/disabled.txt" ] || is_included "$(basename
"$d")"; }; then
# Project root: MDI target_file=…-injected.hpl writes here.
chmod a+rwx "$d" 2>/dev/null || true
@@ -427,7 +427,7 @@ EOF
# Loop over project folders
for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
- if [[ "$d" == *"scripts/" ]] || [[ "$d" == *"surefire-reports/" ]] || [[
"$d" == *"hopweb/" ]]; then
+ if [[ "$d" == *"scripts/" ]] || [[ "$d" == *"surefire-reports/" ]]; then
continue
fi
diff --git a/integration-tests/scripts/run-tests.sh
b/integration-tests/scripts/run-tests.sh
index 968fc4b419..5c6ddcc8b8 100755
--- a/integration-tests/scripts/run-tests.sh
+++ b/integration-tests/scripts/run-tests.sh
@@ -376,7 +376,7 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
#cleanup project testcases
rm -f "${TMP_TESTCASES}"
- if [[ "$d" != *"scripts/" ]] && [[ "$d" != *"surefire-reports/" ]] && [[
"$d" != *"hopweb/" ]]; then
+ if [[ "$d" != *"scripts/" ]] && [[ "$d" != *"surefire-reports/" ]]; then
# If there is a file called disabled.txt the project is disabled, unless
the run explicitly
# opted in with INCLUDE_DISABLED=true (see run-tests-docker.sh).
diff --git a/pom.xml b/pom.xml
index 6981493b32..590958e799 100644
--- a/pom.xml
+++ b/pom.xml
@@ -650,6 +650,20 @@
<module>assemblies</module>
</modules>
</profile>
+ <!-- Selenium tests that drive Hop Web in a container. Opt-in: they
need Docker
+ and take minutes, so they stay out of the normal build. Run them
with
+ mvn -Pwebtest -pl web-tests test -->
+ <profile>
+ <id>webtest</id>
+ <activation>
+ <property>
+ <name>webtest</name>
+ </property>
+ </activation>
+ <modules>
+ <module>web-tests</module>
+ </modules>
+ </profile>
<profile>
<id>assembly</id>
<activation>
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index d7a77da623..c926282637 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -1427,6 +1427,30 @@ public class ContextDialog extends Dialog {
return wSearch;
}
+ /**
+ * The filtered item whose name is exactly the search text, ignoring case,
or null if there is
+ * none.
+ *
+ * <p>Relevance scoring on its own does not put it first: searching for
"Null if" scores "If null"
+ * higher, and "Table input" scores "Spark lake table input" higher, so
typing a transform's full
+ * name and pressing Enter gave you a different transform - and one whose
name shares words with
+ * others could not be selected by typing at all when it landed in another
category.
+ */
+ private Item exactNameMatch(String text) {
+ for (Item item : filteredItems) {
+ if (isExactName(item.getAction().getName(), text)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ /** Whether a name is exactly what was searched for, ignoring case and
surrounding space. */
+ static boolean isExactName(String name, String searchText) {
+ String wanted = Const.trim(searchText);
+ return StringUtils.isNotEmpty(wanted) &&
wanted.equalsIgnoreCase(Const.trim(name));
+ }
+
public void filter(String text) {
if (text == null) {
@@ -1451,10 +1475,25 @@ public class ContextDialog extends Dialog {
filteredItems.sort((a, b) -> Double.compare(scores.get(b),
scores.get(a)));
}
+ // An item called exactly what was typed comes first, whatever it scored.
+ //
+ Item exactMatch = exactNameMatch(text);
+ if (exactMatch != null) {
+ filteredItems.remove(exactMatch);
+ filteredItems.add(0, exactMatch);
+ }
+
if (filteredItems.isEmpty()) {
selectItem(null, false);
}
+ // Typing something's full name selects that thing, even if the selection
was already on a
+ // result that survived the narrowing.
+ //
+ else if (exactMatch != null) {
+ selectItem(exactMatch, false);
+ }
+
// if selected item is exclude, change to a new default selection: first
in the list
//
else if (!filteredItems.contains(selectedItem)) {
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
new file mode 100644
index 0000000000..3e5bb62d06
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
@@ -0,0 +1,68 @@
+/*
+ * 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.dialog;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The rule that decides which entry typing a full name selects.
+ *
+ * <p>Relevance scoring alone put "If null" above "Null if" and "Spark lake
table input" above
+ * "Table input", so typing a transform's own name and pressing Enter created
a different one.
+ */
+class ContextDialogTest {
+
+ @Test
+ @DisplayName("a name equal to the search text is an exact match")
+ void matchesTheSameName() {
+ assertTrue(ContextDialog.isExactName("Null if", "Null if"));
+ }
+
+ @Test
+ @DisplayName("case and surrounding space do not matter")
+ void ignoresCaseAndSpace() {
+ assertTrue(ContextDialog.isExactName("Avro decode", "avro DECODE"));
+ assertTrue(ContextDialog.isExactName(" Table input ", "Table input"));
+ assertTrue(ContextDialog.isExactName("Table input", " Table input "));
+ }
+
+ @Test
+ @DisplayName("a name that merely contains the search text is not an exact
match")
+ void doesNotMatchASubstring() {
+ assertFalse(ContextDialog.isExactName("Spark lake table input", "Table
input"));
+ assertFalse(ContextDialog.isExactName("If null", "Null if"));
+ }
+
+ @Test
+ @DisplayName("an empty search matches nothing, so the ordinary ranking is
left alone")
+ void emptySearchMatchesNothing() {
+ assertFalse(ContextDialog.isExactName("Table input", ""));
+ assertFalse(ContextDialog.isExactName("Table input", " "));
+ assertFalse(ContextDialog.isExactName("Table input", null));
+ }
+
+ @Test
+ @DisplayName("a missing name matches nothing")
+ void nullNameMatchesNothing() {
+ assertFalse(ContextDialog.isExactName(null, "Table input"));
+ }
+}
diff --git a/web-tests/pom.xml b/web-tests/pom.xml
new file mode 100644
index 0000000000..6311210a9d
--- /dev/null
+++ b/web-tests/pom.xml
@@ -0,0 +1,123 @@
+<?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</artifactId>
+ <version>2.20.0-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>hop-web-integration-tests</artifactId>
+ <name>Hop Web Integration Tests</name>
+ <description>Selenium UI tests that drive Hop Web in a
container.</description>
+
+ <properties>
+ <hopweb.browser>auto</hopweb.browser>
+ <hopweb.build>false</hopweb.build>
+ <hopweb.excludedGroups>full</hopweb.excludedGroups>
+ <hopweb.groups></hopweb.groups>
+ <hopweb.headless>true</hopweb.headless>
+ <hopweb.image>apache/hop-web:Development</hopweb.image>
+ <hopweb.pull>true</hopweb.pull>
+ <hopweb.startupTimeout>300</hopweb.startupTimeout>
+ <hopweb.url></hopweb.url>
+ <jacoco.skip>true</jacoco.skip>
+ <maven.deploy.skip>true</maven.deploy.skip>
+ <maven.install.skip>true</maven.install.skip>
+ <selenium.version>4.48.0</selenium.version>
+ <testcontainers.version>2.0.5</testcontainers.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.seleniumhq.selenium</groupId>
+ <artifactId>selenium-java</artifactId>
+ <version>${selenium.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers-selenium</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>org.seleniumhq.selenium</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-test</id>
+ <configuration>
+
<argLine>-Dfile.encoding=${project.build.sourceEncoding}</argLine>
+ <groups>${hopweb.groups}</groups>
+
<excludedGroups>${hopweb.excludedGroups}</excludedGroups>
+ <trimStackTrace>false</trimStackTrace>
+ <systemPropertyVariables>
+ <hopweb.image>${hopweb.image}</hopweb.image>
+ <hopweb.pull>${hopweb.pull}</hopweb.pull>
+ <hopweb.build>${hopweb.build}</hopweb.build>
+
<hopweb.repository>${project.basedir}/..</hopweb.repository>
+ <hopweb.url>${hopweb.url}</hopweb.url>
+
<hopweb.browser>${hopweb.browser}</hopweb.browser>
+
<hopweb.headless>${hopweb.headless}</hopweb.headless>
+
<hopweb.startupTimeout>${hopweb.startupTimeout}</hopweb.startupTimeout>
+
<hopweb.artifacts>${project.build.directory}/hopweb-artifacts</hopweb.artifacts>
+ </systemPropertyVariables>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-checkstyle-plugin</artifactId>
+ <configuration>
+
<configLocation>${project.basedir}/../tools/maven/checkstyle.xml</configLocation>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+ <profiles>
+ <profile>
+ <id>webtest-full</id>
+ <properties>
+ <hopweb.excludedGroups></hopweb.excludedGroups>
+ <hopweb.groups>full</hopweb.groups>
+ </properties>
+ </profile>
+ </profiles>
+</project>
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/FailureArtifacts.java
b/web-tests/src/test/java/org/apache/hop/web/it/FailureArtifacts.java
new file mode 100644
index 0000000000..fad029f686
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/FailureArtifacts.java
@@ -0,0 +1,82 @@
+/*
+ * 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.web.it;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.Optional;
+import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.OutputType;
+import org.openqa.selenium.TakesScreenshot;
+import org.openqa.selenium.WebDriver;
+
+/**
+ * Writes a screenshot and the rendered DOM for every failing test.
+ *
+ * <p>A failing UI test says almost nothing on its own, and the browser is
gone by the time anyone
+ * reads the report. Capturing this centrally also keeps the tests themselves
free of the
+ * try/catch-and-screenshot noise the earlier suite was built from.
+ *
+ * <p>This hooks {@code afterTestExecution} rather than {@code TestWatcher}:
the latter runs after
+ * the {@code @AfterEach} cleanup, which closes leftover dialogs and so
photographs a GUI that has
+ * already been tidied up.
+ */
+public class FailureArtifacts implements AfterTestExecutionCallback {
+
+ @Override
+ public void afterTestExecution(ExtensionContext context) {
+ if (context.getExecutionException().isPresent()) {
+ capture(context.getRequiredTestClass().getSimpleName() + "." +
context.getDisplayName());
+ }
+ }
+
+ private void capture(String name) {
+ HopWebEnvironment environment = HopWebEnvironment.getIfStarted();
+ if (environment == null) {
+ // The failure was Hop Web not starting at all; there is no browser to
photograph.
+ return;
+ }
+ WebDriver driver = environment.getDriver();
+ String base = name.replaceAll("[^A-Za-z0-9.-]+",
"-").toLowerCase(Locale.ROOT);
+ Path directory = Path.of(System.getProperty("hopweb.artifacts",
"target/hopweb-artifacts"));
+ try {
+ Files.createDirectories(directory);
+ Files.write(
+ directory.resolve(base + ".png"),
+ ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES));
+ Files.writeString(directory.resolve(base + ".html"), dom(driver),
StandardCharsets.UTF_8);
+ System.out.println("Failure artifacts written to " +
directory.resolve(base) + ".{png,html}");
+ } catch (IOException e) {
+ System.out.println("Could not write failure artifacts for " + name + ":
" + e.getMessage());
+ }
+ }
+
+ /** The rendered DOM, not the page source: RAP builds the entire UI client
side. */
+ private String dom(WebDriver driver) {
+ return Optional.ofNullable(
+ (String)
+ ((JavascriptExecutor) driver)
+ .executeScript("return
document.documentElement.outerHTML"))
+ .orElse("");
+ }
+}
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/HopWebEnvironment.java
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebEnvironment.java
new file mode 100644
index 0000000000..7473d0abbb
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebEnvironment.java
@@ -0,0 +1,341 @@
+/*
+ * 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.web.it;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import org.apache.hop.web.it.pages.HopGuiPage;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.chrome.ChromeDriver;
+import org.openqa.selenium.chrome.ChromeOptions;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+import org.testcontainers.Testcontainers;
+import org.testcontainers.containers.BrowserWebDriverContainer;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.Network;
+import org.testcontainers.containers.output.ToStringConsumer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.PullPolicy;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+/**
+ * Owns the Hop Web container and the browser for the whole JVM.
+ *
+ * <p>Both are expensive to create - a cold Hop Web needs the better part of a
minute to build its
+ * GUI on the first request - so they are created once and shared by every
test class.
+ * Testcontainers reaps them when the JVM exits.
+ *
+ * <p>System properties (see the module pom for defaults):
+ *
+ * <ul>
+ * <li>{@code hopweb.image} - image under test, default {@code
apache/hop-web:Development}
+ * <li>{@code hopweb.build} - build the image from this working tree first,
for changes that are
+ * not committed yet. Overrides {@code hopweb.image}.
+ * <li>{@code hopweb.pull} - re-pull the image first, default true. Leave it
on: the Development
+ * tag is rebuilt from main daily, and a cached copy silently tests
whatever was pulled weeks
+ * ago. Re-pulling an unchanged image transfers nothing.
+ * <li>{@code hopweb.url} - URL of an already running Hop Web. Set it while
writing tests to skip
+ * container startup entirely, e.g. {@code
-Dhopweb.url=http://localhost:8080/ui}
+ * <li>{@code hopweb.browser} - {@code auto} (default), {@code container} or
{@code local}
+ * <li>{@code hopweb.headless} - only meaningful for a local browser
+ * </ul>
+ */
+public final class HopWebEnvironment {
+
+ private static final String LOCAL_IMAGE_VERSION = "local";
+ private static final String LOCAL_IMAGE = "hop-web:" + LOCAL_IMAGE_VERSION;
+
+ private static final String HOP_WEB_ALIAS = "hop-web";
+ private static final int HOP_WEB_PORT = 8080;
+
+ /** Where the container keeps the configuration Hop Web reads at startup. */
+ private static final String CONFIG_PATH =
"/usr/local/tomcat/webapps/ROOT/config/hop-config.json";
+
+ private static HopWebEnvironment instance;
+
+ private final String uiUrl;
+ private final WebDriver driver;
+
+ /**
+ * Everything Hop Web has printed, streamed as it runs. Null when the tests
drive a Hop Web that
+ * was started outside them, where there is no log to watch.
+ */
+ private ToStringConsumer serverLog;
+
+ private boolean uiOpened;
+
+ private HopWebEnvironment() {
+ Network network = Network.newNetwork();
+ String externalUrl = property("hopweb.url", "");
+ BrowserMode browserMode = BrowserMode.resolve(property("hopweb.browser",
"auto"));
+
+ String urlForBrowser;
+ if (!externalUrl.isBlank()) {
+ urlForBrowser = adaptExternalUrl(externalUrl, browserMode);
+ } else {
+ GenericContainer<?> hopWeb = startHopWeb(network);
+ urlForBrowser =
+ browserMode == BrowserMode.CONTAINER
+ ? "http://" + HOP_WEB_ALIAS + ":" + HOP_WEB_PORT + "/ui"
+ : "http://" + hopWeb.getHost() + ":" +
hopWeb.getMappedPort(HOP_WEB_PORT) + "/ui";
+ }
+
+ this.uiUrl = urlForBrowser;
+ this.driver = browserMode == BrowserMode.CONTAINER ?
containerBrowser(network) : localBrowser();
+ }
+
+ public static synchronized HopWebEnvironment get() {
+ if (instance == null) {
+ instance = new HopWebEnvironment();
+ // Testcontainers reaps what it started, but a locally launched
ChromeDriver would
+ // outlive the Surefire JVM and leave a browser behind on the build
agent.
+ Runtime.getRuntime().addShutdownHook(new Thread(instance::close));
+ }
+ return instance;
+ }
+
+ /** The environment if it was ever started, so failure handling cannot start
one itself. */
+ public static synchronized HopWebEnvironment getIfStarted() {
+ return instance;
+ }
+
+ private void close() {
+ try {
+ driver.quit();
+ } catch (RuntimeException e) {
+ // Nothing useful left to do while the JVM is going down.
+ }
+ }
+
+ public WebDriver getDriver() {
+ return driver;
+ }
+
+ /**
+ * Everything Hop Web has logged so far, or empty when the tests are pointed
at a Hop Web they did
+ * not start and so cannot read.
+ */
+ public String serverLog() {
+ return serverLog == null ? "" : serverLog.toUtf8String();
+ }
+
+ /**
+ * Reloads the Hop GUI in a new RAP session, abandoning whatever state the
old one was stuck in.
+ * Expensive, so it is a recovery path rather than something to do between
tests.
+ */
+ public void reopenUi() {
+ uiOpened = false;
+ openUi();
+ }
+
+ /**
+ * Loads the Hop GUI once per JVM and waits until it is actually usable.
+ *
+ * <p>A cold Hop Web answers {@code /ui} with HTTP 200 long before the GUI
exists: the RAP entry
+ * point only builds it on the first request, which means scanning the
plugin registry. Waiting
+ * for the main toolbar is what "started" really means here - the previous
version of these tests
+ * slept for two minutes instead, which was both slower and less reliable.
+ */
+ public void openUi() {
+ if (uiOpened) {
+ return;
+ }
+ long start = System.currentTimeMillis();
+ driver.get(uiUrl);
+ HopGuiPage.waitFor(driver, Duration.ofSeconds(startupTimeoutSeconds()))
+
.until(ExpectedConditions.presenceOfElementLocated(HopGuiPage.NEW_FILE));
+ System.out.println("Hop GUI ready in " + (System.currentTimeMillis() -
start) + " ms");
+ uiOpened = true;
+ }
+
+ /** The Hop Web URL as the browser has to address it, which is not always
how we address it. */
+ public String getUiUrl() {
+ return uiUrl;
+ }
+
+ private GenericContainer<?> startHopWeb(Network network) {
+ GenericContainer<?> container =
+ new GenericContainer<>(DockerImageName.parse(imageUnderTest()))
+ .withNetwork(network)
+ .withNetworkAliases(HOP_WEB_ALIAS)
+ .withExposedPorts(HOP_WEB_PORT)
+ // Hop Web only reports the version through the GUI, so log which
image actually ran:
+ // a daily job testing a published tag must not go green against a
stale image.
+ .withImagePullPolicy(
+ shouldPull() ? PullPolicy.alwaysPull() :
PullPolicy.defaultPolicy())
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource("hop-config.json"),
CONFIG_PATH)
+ .waitingFor(Wait.forHttp("/ui").forStatusCode(200))
+ .withStartupTimeout(Duration.ofSeconds(startupTimeoutSeconds()));
+ container.start();
+ serverLog = new ToStringConsumer();
+ container.followOutput(serverLog);
+ System.out.println(
+ "Hop Web container started from image "
+ + container.getDockerImageName()
+ + " (id "
+ + container.getContainerId()
+ + ")");
+ return container;
+ }
+
+ /**
+ * The image to test: the published one by default, or one built from this
working tree when
+ * {@code -Dhopweb.build=true} is given.
+ *
+ * <p>Building is for developing against changes that are not committed yet.
The daily job stays
+ * on the published tag deliberately - that is the Hop Web people actually
run, and rebuilding it
+ * nightly would be testing the build rather than the product.
+ */
+ private String imageUnderTest() {
+ if (buildsLocally()) {
+ buildLocalImage();
+ return LOCAL_IMAGE;
+ }
+ return property("hopweb.image", "apache/hop-web:Development");
+ }
+
+ private static boolean buildsLocally() {
+ return Boolean.parseBoolean(property("hopweb.build", "false"));
+ }
+
+ /** Pull the published image, but never a tag that only exists on this
machine. */
+ private static boolean shouldPull() {
+ return !buildsLocally() && Boolean.parseBoolean(property("hopweb.pull",
"true"));
+ }
+
+ /**
+ * Builds Hop Web from this working tree, through the project's own image
script rather than a
+ * second Dockerfile that would drift away from it.
+ *
+ * <p>The fast builder packages artifacts the Maven build already produced,
so build the
+ * assemblies first with {@code mvn clean install -DskipTests}.
+ */
+ private void buildLocalImage() {
+ Path repository = Path.of(property("hopweb.repository", ".."));
+ List<String> command =
+ List.of(
+ "./docker/build-hop-images.sh",
+ "--images",
+ "web",
+ "--builder",
+ "fast",
+ "--version",
+ LOCAL_IMAGE_VERSION);
+ System.out.println("Building " + LOCAL_IMAGE + " from " +
repository.toAbsolutePath());
+ try {
+ Process process =
+ new ProcessBuilder(command)
+ .directory(repository.toFile())
+ .redirectErrorStream(true)
+ .start();
+ try (BufferedReader output =
+ new BufferedReader(new InputStreamReader(process.getInputStream(),
UTF_8))) {
+ output.lines().forEach(System.out::println);
+ }
+ int status = process.waitFor();
+ if (status != 0) {
+ throw new IllegalStateException(
+ "Building "
+ + LOCAL_IMAGE
+ + " failed with exit code "
+ + status
+ + ". The fast builder packages what the Maven build produced,
so run"
+ + " 'mvn clean install -DskipTests' first.");
+ }
+ } catch (IOException e) {
+ throw new IllegalStateException("Could not run " + command.get(0), e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Interrupted while building " +
LOCAL_IMAGE, e);
+ }
+ }
+
+ private WebDriver containerBrowser(Network network) {
+ BrowserWebDriverContainer<?> browser =
+ new BrowserWebDriverContainer<>()
+ .withNetwork(network)
+ .withCapabilities(chromeOptions())
+ .withStartupTimeout(Duration.ofSeconds(startupTimeoutSeconds()));
+ browser.start();
+ return browser.getWebDriver();
+ }
+
+ private WebDriver localBrowser() {
+ // Selenium Manager resolves a matching chromedriver by itself since
Selenium 4.6.
+ return new ChromeDriver(chromeOptions());
+ }
+
+ private ChromeOptions chromeOptions() {
+ ChromeOptions options = new ChromeOptions();
+ if (Boolean.parseBoolean(property("hopweb.headless", "true"))) {
+ options.addArguments("--headless=new");
+ }
+ options.addArguments("--no-sandbox");
+ options.addArguments("--disable-dev-shm-usage");
+ options.addArguments("--window-size=1600,1000");
+ options.addArguments("--remote-allow-origins=*");
+ return options;
+ }
+
+ /** A browser in a container cannot reach the host's localhost without a
little help. */
+ private String adaptExternalUrl(String externalUrl, BrowserMode browserMode)
{
+ if (browserMode != BrowserMode.CONTAINER) {
+ return externalUrl;
+ }
+ URI uri = URI.create(externalUrl);
+ if (!"localhost".equals(uri.getHost()) &&
!"127.0.0.1".equals(uri.getHost())) {
+ return externalUrl;
+ }
+ int port = uri.getPort() < 0 ? 80 : uri.getPort();
+ Testcontainers.exposeHostPorts(port);
+ return externalUrl.replaceFirst(
+ "//" + uri.getHost() + ":" + port, "//host.testcontainers.internal:" +
port);
+ }
+
+ private static long startupTimeoutSeconds() {
+ return Long.parseLong(property("hopweb.startupTimeout", "300"));
+ }
+
+ private static String property(String name, String defaultValue) {
+ String value = System.getProperty(name);
+ return value == null || value.isBlank() ? defaultValue : value;
+ }
+
+ private enum BrowserMode {
+ CONTAINER,
+ LOCAL;
+
+ static BrowserMode resolve(String value) {
+ if ("auto".equalsIgnoreCase(value)) {
+ // The selenium/standalone-chrome images are amd64 only, so an arm64
developer machine
+ // has to drive the browser it already has.
+ return "aarch64".equals(System.getProperty("os.arch")) ? LOCAL :
CONTAINER;
+ }
+ return "local".equalsIgnoreCase(value) ? LOCAL : CONTAINER;
+ }
+ }
+}
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/HopWebSmokeTest.java
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebSmokeTest.java
new file mode 100644
index 0000000000..b545210df6
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebSmokeTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.web.it.pages.HopGuiPage;
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The daily signal: can Hop Web still be started, opened, and used to build a
pipeline?
+ *
+ * <p>Every test starts from a pipeline of its own, so a failure says what
actually broke instead of
+ * knocking over everything that runs after it.
+ */
+@DisplayName("Hop Web smoke")
+class HopWebSmokeTest extends HopWebTestBase {
+
+ private static final String TRANSFORM = "Generate rows";
+
+ @Test
+ @DisplayName("the Hop GUI is served and its main toolbar is usable")
+ void hopGuiIsUp() {
+ assertNotNull(driver.findElement(HopGuiPage.NEW_FILE), "new file toolbar
item");
+ assertNotNull(driver.findElement(HopGuiPage.OPEN_FILE), "open file toolbar
item");
+ assertNotNull(driver.findElement(HopGuiPage.SAVE_FILE), "save file toolbar
item");
+ assertTrue(hopGui.openDialogTitles().isEmpty(), "no dialog should be
blocking the GUI");
+ }
+
+ @Test
+ @DisplayName("a new pipeline opens on an empty canvas")
+ void createsAPipeline() {
+ PipelineGraphPage graph = hopGui.newPipeline();
+
+ assertNotNull(graph.canvas(), "pipeline graph canvas");
+ assertTrue(hopGui.hasTab("New pipeline"), "a tab named 'New pipeline'
should be open");
+ // An empty graph draws only the hint telling you where to click, never a
transform.
+ assertFalse(
+ graph.contains(TRANSFORM), "a new pipeline should be empty but drew "
+ graph.labels());
+ }
+
+ @Test
+ @DisplayName("a transform can be dropped on the canvas from the context
dialog")
+ void addsATransform() {
+ PipelineGraphPage graph = hopGui.newPipeline();
+
+ graph.addTransform(TRANSFORM);
+
+ assertTrue(
+ graph.contains(TRANSFORM), "graph should show " + TRANSFORM + ", drew
" + graph.labels());
+ }
+
+ @Test
+ @DisplayName("a transform dialog opens and closes again")
+ void opensTheTransformDialog() {
+ PipelineGraphPage graph = hopGui.newPipeline();
+ graph.addTransform(TRANSFORM);
+
+ graph.actOnTransform(TRANSFORM, "Edit", 0, 0);
+
+ assertEquals(TRANSFORM, hopGui.topDialogTitle(), "the transform dialog
should be on top");
+
+ hopGui.closeTopDialog();
+
+ assertTrue(hopGui.openDialogTitles().isEmpty(), "the transform dialog
should be gone");
+ }
+
+ @Test
+ @DisplayName("a transform can be deleted again")
+ void deletesATransform() {
+ PipelineGraphPage graph = hopGui.newPipeline();
+ graph.addTransform(TRANSFORM);
+
+ graph.actOnTransform(TRANSFORM, "Delete", 0, 0);
+ wait.until(d -> !graph.contains(TRANSFORM));
+
+ assertFalse(graph.contains(TRANSFORM), "graph still shows " +
graph.labels());
+ }
+}
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/HopWebTestBase.java
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebTestBase.java
new file mode 100644
index 0000000000..d896c6e334
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebTestBase.java
@@ -0,0 +1,94 @@
+/*
+ * 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.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.List;
+import org.apache.hop.web.it.pages.HopGuiPage;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+/** Shared setup: one Hop Web, one browser, and artifacts written whenever a
test fails. */
+@ExtendWith(FailureArtifacts.class)
+public abstract class HopWebTestBase {
+
+ /** Long enough for Hop Web to open a transform dialog on a loaded CI
machine. */
+ protected static final Duration TIMEOUT = Duration.ofSeconds(30);
+
+ protected static WebDriver driver;
+ protected static WebDriverWait wait;
+ protected static HopGuiPage hopGui;
+
+ @BeforeAll
+ static void startHopWeb() {
+ HopWebEnvironment environment = HopWebEnvironment.get();
+ environment.openUi();
+ driver = environment.getDriver();
+ wait = HopGuiPage.waitFor(driver, TIMEOUT);
+ hopGui = new HopGuiPage(driver, TIMEOUT);
+ hopGui.dismissWelcomeDialog();
+ }
+
+ /**
+ * Leaves the GUI with no dialog open, whatever the test did.
+ *
+ * <p>One test tripping over an open dialog used to take the rest of the
suite down with it; the
+ * previous version reacted by reloading the browser mid-run, which lost the
session and produced
+ * a cascade of failures that all pointed at the wrong test.
+ */
+ private int serverLogMark;
+
+ @BeforeEach
+ void markServerLog() {
+ serverLogMark = HopWebEnvironment.get().serverLog().length();
+ }
+
+ /**
+ * Fails the test if Hop Web crashed while it ran, even though the browser
looked fine.
+ *
+ * <p>Plenty goes wrong server side without reaching the page: a dialog can
open perfectly while
+ * the thread it started to populate its widgets dies. Asserting only on
what is visible declares
+ * those green.
+ */
+ @AfterEach
+ void failOnServerCrashes() {
+ String log = HopWebEnvironment.get().serverLog();
+ if (log.length() <= serverLogMark) {
+ return;
+ }
+ List<String> crashes = ServerLog.crashes(log.substring(serverLogMark));
+ assertTrue(crashes.isEmpty(), () -> "Hop Web crashed during this test: " +
crashes);
+ }
+
+ @AfterEach
+ void closeLeftoverDialogs() {
+ try {
+ hopGui.closeAllDialogs();
+ } catch (RuntimeException e) {
+ // A dialog that refuses to close is the next test's problem to report,
not this test's:
+ // failing here would replace a real failure with a cleanup one.
+ System.out.println("Could not close dialog " + hopGui.topDialogTitle() +
": " + e);
+ }
+ }
+}
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/ServerLog.java
b/web-tests/src/test/java/org/apache/hop/web/it/ServerLog.java
new file mode 100644
index 0000000000..ffbf8a8d6f
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/ServerLog.java
@@ -0,0 +1,79 @@
+/*
+ * 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.web.it;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Finds crashes in what Hop Web logged.
+ *
+ * <p>Not everything that goes wrong reaches the browser. A dialog can open
looking perfectly
+ * healthy while the thread it started to fill its widgets has died -
CheckSumDialog does exactly
+ * that, calling the session scoped {@code PropsUi} from a plain {@code new
Thread}, which RAP
+ * rejects with "Invalid thread access". Nothing about the page says so; only
the server log does.
+ * Watching it turns an entire class of Hop Web bug into a test failure
without the tests needing to
+ * know anything about the dialog in question.
+ */
+public final class ServerLog {
+
+ /**
+ * An exception nobody caught. Deliberately narrow: Hop Web routinely logs
handled problems -
+ * malformed i18n patterns, CSS properties RAP does not implement - and
treating those as failures
+ * would leave the check permanently red and therefore ignored.
+ */
+ private static final String UNCAUGHT = "Exception in thread ";
+
+ /** Stack frames worth quoting back; the rest of a trace is noise in a
failure message. */
+ private static final String HOP_FRAME = "at org.apache.hop.";
+
+ private ServerLog() {}
+
+ /** One entry per uncaught exception, each naming the Hop code that started
it. */
+ public static List<String> crashes(String log) {
+ List<String> crashes = new ArrayList<>();
+ String[] lines = log.split("\n");
+ for (int i = 0; i < lines.length; i++) {
+ if (!lines[i].startsWith(UNCAUGHT)) {
+ continue;
+ }
+ crashes.add(lines[i].trim() + culprit(lines, i));
+ }
+ return crashes;
+ }
+
+ /**
+ * The deepest Hop frame in the trace, which is the code that started the
thread rather than the
+ * shared helper it happened to die in.
+ *
+ * <p>Reporting the topmost frame instead names {@code
PropsUiImpl.getInstanceInternal} for every
+ * one of these, which says nothing about which dialog to go and fix.
+ */
+ private static String culprit(String[] lines, int start) {
+ String deepest = null;
+ for (int i = start + 1; i < lines.length && i <= start + 60; i++) {
+ String line = lines[i].trim();
+ if (line.startsWith(HOP_FRAME)) {
+ deepest = line;
+ } else if (!line.isEmpty() && !line.startsWith("at ")) {
+ break;
+ }
+ }
+ return deepest == null ? "" : " (" + deepest + ")";
+ }
+}
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/ServerLogTest.java
b/web-tests/src/test/java/org/apache/hop/web/it/ServerLogTest.java
new file mode 100644
index 0000000000..c6507f741e
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/ServerLogTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class ServerLogTest {
+
+ /** Verbatim from Hop Web opening the Add a checksum dialog. */
+ private static final String CHECKSUM_CRASH =
+ """
+ JSON metadata in folder
/usr/local/tomcat/webapps/ROOT/config/projects/default/metadata
+ Exception in thread "Thread-9" java.lang.IllegalStateException: Invalid
thread access
+ \tat org.eclipse.rap.rwt.RWT.checkContext(RWT.java:765)
+ \tat org.eclipse.rap.rwt.RWT.getUISession(RWT.java:647)
+ \tat
org.eclipse.rap.rwt.SingletonUtil.getSessionInstance(SingletonUtil.java:58)
+ \tat
org.apache.hop.ui.core.PropsUiImpl.getInstanceInternal(PropsUiImpl.java:26)
+ \tat org.apache.hop.ui.core.PropsUi.getInstance(PropsUi.java:157)
+ \tat org.apache.hop.ui.core.ConstUi.sortFieldNames(ConstUi.java:254)
+ \tat
org.apache.hop.pipeline.transforms.checksum.CheckSumDialog.setComboBoxes(CheckSumDialog.java:291)
+ \tat java.base/java.lang.Thread.run(Thread.java:1583)
+ """;
+
+ @Test
+ @DisplayName("an uncaught exception is reported, named by the dialog that
caused it")
+ void reportsUncaughtExceptions() {
+ List<String> crashes = ServerLog.crashes(CHECKSUM_CRASH);
+
+ assertEquals(1, crashes.size(), () -> "expected one crash, got " +
crashes);
+ assertTrue(crashes.get(0).contains("Invalid thread access"),
crashes.get(0));
+ // The dialog, not PropsUiImpl: every one of these dies in the same shared
helper, so naming
+ // that would say nothing about where to go and fix it.
+ assertTrue(crashes.get(0).contains("CheckSumDialog.setComboBoxes"),
crashes.get(0));
+ }
+
+ @Test
+ @DisplayName("handled problems Hop Web logs all the time are not failures")
+ void ignoresHandledProblems() {
+ String noise =
+ """
+ Format problem with key=[SparkSqlDialog.Sql.Tooltip], locale=[en_US] :
\
+ java.lang.IllegalArgumentException: can't parse argument number: ...
+ java.lang.IllegalArgumentException: can't parse argument number: ...
+ \tat
java.base/java.text.MessageFormat.makeFormat(MessageFormat.java:1449)
+ org.w3c.css.sac.CSSException: Failed to read property box-shadow
+ """;
+
+ assertEquals(List.of(), ServerLog.crashes(noise));
+ }
+
+ @Test
+ @DisplayName("a quiet log has nothing to report")
+ void quietLogIsClean() {
+ assertEquals(List.of(), ServerLog.crashes(""));
+ assertEquals(List.of(), ServerLog.crashes("Started Hop Web\nJSON metadata
in folder /x\n"));
+ }
+
+ @Test
+ @DisplayName("several crashes are reported separately")
+ void reportsEachCrash() {
+ assertEquals(2, ServerLog.crashes(CHECKSUM_CRASH + CHECKSUM_CRASH).size());
+ }
+}
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/TransformDialogSweepTest.java
b/web-tests/src/test/java/org/apache/hop/web/it/TransformDialogSweepTest.java
new file mode 100644
index 0000000000..5a1c11a67d
--- /dev/null
+++
b/web-tests/src/test/java/org/apache/hop/web/it/TransformDialogSweepTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvFileSource;
+
+/**
+ * Adds every transform in transforms.csv to a pipeline, opens its dialog,
closes it and removes it
+ * again.
+ *
+ * <p>Tagged {@code full} because it takes the better part of an hour: the
daily job runs {@link
+ * HopWebSmokeTest} instead and this sweep is started explicitly with {@code
mvn -Pwebtest-full}.
+ */
+@Tag("full")
+@DisplayName("Transform dialogs")
+class TransformDialogSweepTest extends HopWebTestBase {
+
+ private static PipelineGraphPage graph;
+
+ @BeforeAll
+ static void openPipeline() {
+ graph = hopGui.newPipeline();
+ }
+
+ /**
+ * Starts every case on an empty canvas.
+ *
+ * <p>A transform left behind by a failed case sits exactly where the next
case clicks, so that
+ * click opens the leftover's context dialog instead of the one that creates
a transform - and
+ * every remaining case then fails for a reason that has nothing to do with
it. Rather than unpick
+ * that, abandon the pipeline and carry on with a clean one.
+ *
+ * <p>This has to happen before the test rather than after it: JUnit runs a
subclass
+ * {@code @AfterEach} before the base class one, so cleaning up here would
race the dialog closing
+ * that {@link HopWebTestBase} does.
+ */
+ @BeforeEach
+ void startFromACleanCanvas() {
+ if (!hopGui.openDialogTitles().isEmpty()) {
+ // A dialog that will not close would break every later case. Start a
fresh session.
+ HopWebEnvironment.get().reopenUi();
+ hopGui.dismissWelcomeDialog();
+ graph = hopGui.newPipeline();
+ return;
+ }
+ if (!graph.isEmpty()) {
+ graph = hopGui.newPipeline();
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @CsvFileSource(resources = "/transforms.csv")
+ void opensAndClosesTheDialog(String transformName) {
+ graph.addTransform(transformName);
+ assertTrue(graph.contains(transformName), "transform was not added to the
graph");
+
+ graph.actOnTransform(transformName, "Edit", 0, 0);
+ String title = hopGui.awaitDialog();
+ // Not asserted to equal the transform name: a dialog is free to call
itself something else,
+ // as "Fake" does with its "Fake data" dialog. Opening at all is the thing
under test, and
+ // actOnTransform has already confirmed the click landed on the right
transform.
+ assertNotNull(title, "no dialog opened for " + transformName);
+
+ // Some transforms put a notice in front of their dialog, so close
whatever is stacked up.
+ hopGui.closeAllDialogs();
+ assertTrue(
+ hopGui.openDialogTitles().isEmpty(),
+ "dialogs left open after " + transformName + ": " +
hopGui.openDialogTitles());
+
+ graph.actOnTransform(transformName, "Delete", 0, 0);
+ wait.until(d -> !graph.contains(transformName));
+ assertFalse(graph.contains(transformName), "transform was not removed from
the graph");
+ }
+}
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/pages/ContextDialog.java
b/web-tests/src/test/java/org/apache/hop/web/it/pages/ContextDialog.java
new file mode 100644
index 0000000000..13ed8f4198
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/pages/ContextDialog.java
@@ -0,0 +1,136 @@
+/*
+ * 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.web.it.pages;
+
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.Keys;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.interactions.Actions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+/**
+ * The Hop context dialog: the searchable grid of transforms and actions.
+ *
+ * <p>Its entries are painted on a canvas and cannot be addressed
individually, but the dialog puts
+ * the keyboard focus in its search field and Enter accepts the highlighted
entry. Typing and
+ * pressing Enter therefore replaces the coordinate arithmetic the earlier
tests used to click the
+ * first tile - arithmetic that stopped working once the dialog grew taller
than the window.
+ */
+public class ContextDialog {
+
+ /**
+ * The context dialog names itself: "Select the action to execute or the
transform to create:", or
+ * "Select the action to take on transform 'X':".
+ *
+ * <p>It used to be detected as "a second large canvas over the graph",
which is true of it but
+ * also true of transform dialogs that draw on a canvas themselves - Filter
rows puts its
+ * condition editor on one - so closing the context dialog appeared never to
finish.
+ */
+ private static final String TITLE_PREFIX = "Select the action";
+
+ /**
+ * A fingerprint of what the dialog is showing: its own size plus the
description it prints for
+ * the highlighted entry. Both change as the search narrows, and neither
depends on guessing which
+ * canvas belongs to the dialog - a guess that could come up empty and
report "settled"
+ * immediately, so Enter fired before the results had filtered at all.
+ */
+ private static final String RENDER_STATE =
+ "const sh=[...document.body.children].filter(d=>{"
+ + "if(d.tagName!=='DIV')return false;"
+ + "const z=parseInt(getComputedStyle(d).zIndex);"
+ + "const r=d.getBoundingClientRect();"
+ + "return z>=100000&&r.width>100&&r.height>100;});"
+ + "const top=sh[sh.length-1];"
+ + "if(!top)return '';"
+ + "const r=top.getBoundingClientRect();"
+ + "return
Math.round(r.width)+'x'+Math.round(r.height)+'|'+(top.innerText||'');";
+
+ private final WebDriver driver;
+ private final WebDriverWait wait;
+
+ ContextDialog(WebDriver driver, WebDriverWait wait) {
+ this.driver = driver;
+ this.wait = wait;
+ }
+
+ void awaitOpen() {
+ wait.until(d -> isOpen());
+ }
+
+ public boolean isOpen() {
+ String title = title();
+ return title != null && title.startsWith(TITLE_PREFIX);
+ }
+
+ public String title() {
+ return HopGuiPage.topDialogTitle(driver);
+ }
+
+ /** Filters the entries and accepts the one left highlighted. */
+ public void choose(String search) {
+ choose(search, 0);
+ }
+
+ /**
+ * Filters the entries and accepts the one {@code skip} places after the
highlight.
+ *
+ * <p>The highlighted entry is not necessarily the one that was typed:
searching for the exact
+ * name "Null if" highlights "If null", and "Table input" does not highlight
"Table input". Hop
+ * used to expose {@code HOP_CONTEXT_DIALOG_STRICT_SEARCH} ("Needed for
automated UI testing") for
+ * precisely this, but it was removed with the search rework in #6125 and
nothing replaced it, so
+ * the caller has to walk the entries and check what it got.
+ */
+ public void choose(String search, int skip) {
+ // The dialog focuses its search field on open, so the keystrokes need no
target.
+ new Actions(driver).sendKeys(search).perform();
+ awaitFilterSettled();
+ for (int i = 0; i < skip; i++) {
+ new Actions(driver).sendKeys(Keys.ARROW_RIGHT).perform();
+ }
+ if (skip > 0) {
+ awaitFilterSettled();
+ }
+ new Actions(driver).sendKeys(Keys.ENTER).perform();
+ wait.until(d -> !isOpen());
+ }
+
+ public void cancel() {
+ new Actions(driver).sendKeys(Keys.ESCAPE).perform();
+ wait.until(d -> !isOpen());
+ }
+
+ /**
+ * Waits until the filtered list stops changing size. Filtering is a server
round trip, and Enter
+ * pressed while it is still in flight accepts whichever entry was
highlighted beforehand.
+ */
+ private void awaitFilterSettled() {
+ String[] previous = {null};
+ wait.until(
+ d -> {
+ String state = renderState();
+ boolean unchanged = state.equals(previous[0]);
+ previous[0] = state;
+ return unchanged;
+ });
+ }
+
+ private String renderState() {
+ Object state = ((JavascriptExecutor) driver).executeScript(RENDER_STATE);
+ return state == null ? "" : state.toString();
+ }
+}
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/pages/HopGuiPage.java
b/web-tests/src/test/java/org/apache/hop/web/it/pages/HopGuiPage.java
new file mode 100644
index 0000000000..b46e39896b
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/pages/HopGuiPage.java
@@ -0,0 +1,279 @@
+/*
+ * 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.web.it.pages;
+
+import java.time.Duration;
+import java.util.List;
+import org.openqa.selenium.By;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.Keys;
+import org.openqa.selenium.NoSuchElementException;
+import org.openqa.selenium.TimeoutException;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.interactions.Actions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+/** The Hop GUI shell: main toolbar, the "new" menu, and the dialogs stacked
on top of it. */
+public class HopGuiPage {
+
+ /**
+ * Every visible RAP shell that is big enough to be a window, outermost
first. Index 0 is the Hop
+ * GUI itself, so anything beyond it is an open dialog. A shell's first line
of text is its title,
+ * which is how these tests identify dialogs - far steadier than the
positional {@code
+ * //body/div[5]/div[1]} the previous suite matched on.
+ */
+ private static final String SHELL_TITLES =
+ "return [...document.body.children].filter(d=>{"
+ + "if(d.tagName!=='DIV')return false;"
+ + "const z=parseInt(getComputedStyle(d).zIndex);"
+ + "const r=d.getBoundingClientRect();"
+ + "return z>=100000&&r.width>100&&r.height>100;})"
+ + ".map(d=>(d.innerText||'').split('\\n')[0].trim());";
+
+ /**
+ * How often to re-check a condition. The default of 500ms is the single
biggest cost in this
+ * suite: nothing it waits for is slow, but every wait was rounded up to
half a second, so a
+ * transform that takes 200ms of real work billed several seconds.
+ */
+ public static final Duration POLL_INTERVAL = Duration.ofMillis(50);
+
+ /** A wait that polls often enough not to dominate the measurement. */
+ public static WebDriverWait waitFor(WebDriver driver, Duration timeout) {
+ return (WebDriverWait) new WebDriverWait(driver,
timeout).pollingEvery(POLL_INTERVAL);
+ }
+
+ private final WebDriver driver;
+ private final WebDriverWait wait;
+
+ public HopGuiPage(WebDriver driver, Duration timeout) {
+ this.driver = driver;
+ this.wait = waitFor(driver, timeout);
+ }
+
+ /**
+ * Locates a widget by the GUI element id it was declared with, for example
{@code
+ * toolbar-10010-new} from {@code HopGui.ID_MAIN_TOOLBAR_NEW}.
+ *
+ * <p>The DOM id is not the GUI element id: {@code GuiToolbarWidgets}
prefixes it with the id of
+ * the HopGui instance owning the widget, and since the RAP session
isolation work (issue #8047)
+ * that is a UUID minted per session. {@code toolbar-10010-new} therefore
reaches the browser as
+ * {@code dffad89a-...-toolbar-10010-new} and can only be matched on its
suffix. Matching the
+ * exact id is what silently broke the previous generation of these tests.
+ */
+ public static By guiElement(String guiElementId) {
+ return By.cssSelector("[id$='-" + guiElementId + "']");
+ }
+
+ public static final By NEW_FILE = guiElement("toolbar-10010-new");
+ public static final By OPEN_FILE = guiElement("toolbar-10020-open");
+ public static final By SAVE_FILE = guiElement("toolbar-10040-save");
+
+ /** Titles of the dialogs currently open on top of the Hop GUI, innermost
last. */
+ public static List<String> openDialogTitles(WebDriver driver) {
+ @SuppressWarnings("unchecked")
+ List<String> titles = (List<String>) ((JavascriptExecutor)
driver).executeScript(SHELL_TITLES);
+ // Drop the Hop GUI shell itself; only the dialogs above it are
interesting.
+ return titles.isEmpty() ? titles : titles.subList(1, titles.size());
+ }
+
+ public static String topDialogTitle(WebDriver driver) {
+ List<String> titles = openDialogTitles(driver);
+ return titles.isEmpty() ? null : titles.get(titles.size() - 1);
+ }
+
+ public List<String> openDialogTitles() {
+ return openDialogTitles(driver);
+ }
+
+ public String topDialogTitle() {
+ return topDialogTitle(driver);
+ }
+
+ /**
+ * Closes the welcome dialog if this Hop Web was configured to show it. The
image used by the
+ * daily job turns it off through hop-config.json, but a developer pointing
the tests at their own
+ * Hop Web usually has not.
+ */
+ public void dismissWelcomeDialog() {
+ if (!openDialogTitles().isEmpty()) {
+ closeTopDialog();
+ }
+ }
+
+ /**
+ * Waits for a dialog to appear and returns its title.
+ *
+ * <p>Choosing an entry that opens a dialog only tells you the context
dialog closed; the dialog
+ * itself can take noticeably longer to build - Cassandra input is one that
does - so anything
+ * reading the title straight afterwards is racing it.
+ */
+ public String awaitDialog() {
+ return wait.until(d -> topDialogTitle());
+ }
+
+ /** How long to give Escape before falling back to the dialog's own button.
*/
+ private static final Duration ESCAPE_GRACE = Duration.ofSeconds(2);
+
+ /**
+ * Buttons that dismiss a dialog without applying anything. Not "OK": some
of these dialogs are
+ * the real transform dialog and confirming would change the pipeline.
+ */
+ private static final List<String> DISMISS_BUTTONS = List.of("Cancel",
"Close");
+
+ /**
+ * Closes one dialog, identified by title, and waits until it is really gone.
+ *
+ * <p>Counted per title rather than tested for absence: a transform can
stack two dialogs with the
+ * same name - CSV file input shows a notice called "CSV file input" and
then its actual dialog -
+ * so "no dialog by that name" would never come true.
+ *
+ * <p>Escape does not reach every dialog; that same notice ignores it.
Falling back to the
+ * dialog's own dismiss button is what a user would do anyway.
+ */
+ public void closeDialog(String title) {
+ long before = countOpen(title);
+ new Actions(driver).sendKeys(Keys.ESCAPE).perform();
+ if (closed(title, before, ESCAPE_GRACE)) {
+ return;
+ }
+ for (String label : DISMISS_BUTTONS) {
+ if (clickIfVisible(label)) {
+ break;
+ }
+ }
+ wait.until(d -> countOpen(title) < before);
+ }
+
+ /** Closes every open dialog, however many deep they are stacked. */
+ public void closeAllDialogs() {
+ for (int attempt = 0; attempt < 5 && !openDialogTitles().isEmpty();
attempt++) {
+ closeTopDialog();
+ }
+ }
+
+ private long countOpen(String title) {
+ return openDialogTitles().stream().filter(title::equals).count();
+ }
+
+ private boolean closed(String title, long before, Duration timeout) {
+ try {
+ waitFor(driver, timeout).until(d -> countOpen(title) < before);
+ return true;
+ } catch (TimeoutException e) {
+ return false;
+ }
+ }
+
+ /** Clicks a button by its label if it is on screen, without waiting for one
that is not. */
+ public boolean clickIfVisible(String label) {
+ return driver.findElements(parentOfLabelled(label)).stream()
+ .filter(WebElement::isDisplayed)
+ .findFirst()
+ .map(
+ element -> {
+ click(element);
+ return true;
+ })
+ .orElse(false);
+ }
+
+ /** Sends Escape and waits until whatever was on top is gone. */
+ public void closeTopDialog() {
+ String title = topDialogTitle();
+ if (title == null) {
+ return;
+ }
+ closeDialog(title);
+ }
+
+ /** Creates a new pipeline and returns its graph, ready to be edited. */
+ public PipelineGraphPage newPipeline() {
+ clickWidget(NEW_FILE);
+ clickMenuItem("Pipeline");
+ PipelineGraphPage graph = new PipelineGraphPage(driver, wait);
+ graph.awaitReady();
+ return graph;
+ }
+
+ /**
+ * Clicks a toolbar item. The GUI element id lands on the item's {@code
<img>}, but the widget
+ * carrying the click listener is two levels up.
+ */
+ public void clickWidget(By locator) {
+ WebElement image = wait.until(d -> d.findElement(locator));
+ click(image.findElement(By.xpath("./../..")));
+ }
+
+ /**
+ * Whether a visible element carries exactly this label, which for a file
that was just created is
+ * its tab.
+ */
+ public boolean hasTab(String title) {
+ return
driver.findElements(labelled(title)).stream().anyMatch(WebElement::isDisplayed);
+ }
+
+ /** Clicks an entry in an open menu by its label. */
+ public void clickMenuItem(String label) {
+ click(visibleByLabel(label));
+ }
+
+ /**
+ * The widget carrying a given label. RAP paints the text in a child div, so
the element that
+ * takes the click is its parent.
+ */
+ private WebElement visibleByLabel(String label) {
+ return wait.until(
+ d ->
+ d.findElements(parentOfLabelled(label)).stream()
+ .filter(WebElement::isDisplayed)
+ .findFirst()
+ .orElse(null));
+ }
+
+ private void click(WebElement element) {
+ if (element == null) {
+ throw new NoSuchElementException("No element to click");
+ }
+ new Actions(driver).moveToElement(element).click().perform();
+ }
+
+ /**
+ * An element whose text is this label.
+ *
+ * <p>Compared with the whitespace normalised, because Hop pads its button
labels: the Cancel
+ * button's text node is literally {@code " Cancel "}, so an exact match
silently finds nothing
+ * and any fallback built on it quietly does nothing at all.
+ */
+ private static By labelled(String label) {
+ return By.xpath("//div[normalize-space(text())=" + xpathLiteral(label) +
"]");
+ }
+
+ /** The widget owning a label: RAP paints the text in a child of the element
that is clicked. */
+ private static By parentOfLabelled(String label) {
+ return By.xpath("//div[normalize-space(text())=" + xpathLiteral(label) +
"]/..");
+ }
+
+ /** Quotes a label for XPath, which has no escape character of its own. */
+ static String xpathLiteral(String value) {
+ if (!value.contains("'")) {
+ return "'" + value + "'";
+ }
+ return "concat('" + value.replace("'", "',\"'\",'") + "')";
+ }
+}
diff --git
a/web-tests/src/test/java/org/apache/hop/web/it/pages/PipelineGraphPage.java
b/web-tests/src/test/java/org/apache/hop/web/it/pages/PipelineGraphPage.java
new file mode 100644
index 0000000000..8d93c8b8f2
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/pages/PipelineGraphPage.java
@@ -0,0 +1,175 @@
+/*
+ * 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.web.it.pages;
+
+import java.time.Duration;
+import java.util.List;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.TimeoutException;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.interactions.Actions;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+/**
+ * The pipeline graph of the active tab.
+ *
+ * <p>The graph is drawn on a {@code <canvas>}, so there is nothing to click
by name. What it does
+ * have is the SVG that Hop Web renders server side and lays over that canvas
(see {@code
+ * CanvasSvgFacadeImpl}); its {@code <text>} nodes carry the transform names,
which is what the
+ * assertions here read. Interaction still goes to the canvas, but only ever
at coordinates this
+ * class chose itself.
+ */
+public class PipelineGraphPage {
+
+ /** The graph canvas is the only large visible canvas; every other one is a
sash or a header. */
+ private static final String GRAPH_CANVAS =
+ "return [...document.querySelectorAll('canvas')].find(c=>{"
+ + "const r=c.getBoundingClientRect();"
+ + "return
r.width>500&&r.height>400&&c.offsetParent!==null;})||null;";
+
+ private static final String GRAPH_TEXTS =
+ "const
svg=[...document.querySelectorAll('svg')].find(s=>s.getBoundingClientRect().width>100);"
+ + "return svg?
[...svg.querySelectorAll('text')].map(t=>t.textContent.trim())"
+ + ".filter(t=>t.length>0) : [];";
+
+ /**
+ * What an empty graph draws: the hint telling you where to click. Captured
once from the first
+ * pipeline of the session, which is empty beyond doubt because nothing else
exists yet.
+ *
+ * <p>It has to be a session constant rather than something each page
re-reads. Reading it per
+ * pipeline raced the tab switch: the labels still belonged to the previous
tab, so a leftover
+ * transform got recorded as what "empty" looks like, and nothing added
afterwards ever registered
+ * as new.
+ */
+ private static List<String> emptyLabels;
+
+ private final WebDriver driver;
+ private final WebDriverWait wait;
+
+ PipelineGraphPage(WebDriver driver, WebDriverWait wait) {
+ this.driver = driver;
+ this.wait = wait;
+ }
+
+ void awaitReady() {
+ wait.until(d -> canvas() != null);
+ if (emptyLabels == null) {
+ wait.until(d -> !labels().isEmpty());
+ emptyLabels = labels();
+ } else {
+ // Also the wait for the new tab to actually be the one on screen.
+ wait.until(d -> isEmpty());
+ }
+ }
+
+ /** Whether the graph holds no transforms, as opposed to holding no labels
at all. */
+ public boolean isEmpty() {
+ return labels().equals(emptyLabels);
+ }
+
+ public WebElement canvas() {
+ return (WebElement) ((JavascriptExecutor)
driver).executeScript(GRAPH_CANVAS);
+ }
+
+ /** Every label currently drawn on the graph: transform names, and hop and
note text. */
+ public List<String> labels() {
+ @SuppressWarnings("unchecked")
+ List<String> texts = (List<String>) ((JavascriptExecutor)
driver).executeScript(GRAPH_TEXTS);
+ return texts;
+ }
+
+ public boolean contains(String label) {
+ return labels().contains(label);
+ }
+
+ /** How many entries after the highlight to try before giving up on a name.
*/
+ private static final int MAX_CANDIDATES = 3;
+
+ /**
+ * Drops a transform on the graph, at an offset from the middle of the
canvas.
+ *
+ * <p>Typing the name and accepting the highlight is not enough on its own,
because the context
+ * dialog does not rank an exact name match first - see {@link
ContextDialog#choose(String, int)}.
+ * So this checks what actually landed, and if it is the wrong transform,
removes it and takes the
+ * next entry instead.
+ */
+ public void addTransform(String transformName, int offsetX, int offsetY) {
+ for (int candidate = 0; candidate < MAX_CANDIDATES; candidate++) {
+ clickAt(offsetX, offsetY);
+ contextDialog().choose(transformName, candidate);
+
+ String added = awaitAdded();
+ if (transformName.equals(added)) {
+ return;
+ }
+ if (added != null) {
+ actOnTransform(added, "Delete", offsetX, offsetY);
+ wait.until(d -> isEmpty());
+ }
+ }
+ throw new AssertionError(
+ "Could not add '"
+ + transformName
+ + "': none of the first "
+ + MAX_CANDIDATES
+ + " entries matching that search was it");
+ }
+
+ /** The label that appeared on the graph, or null if nothing did. */
+ private String awaitAdded() {
+ try {
+ return shortWait().until(d -> addedLabel());
+ } catch (TimeoutException e) {
+ return null;
+ }
+ }
+
+ private String addedLabel() {
+ return labels().stream().filter(label ->
!emptyLabels.contains(label)).findFirst().orElse(null);
+ }
+
+ private WebDriverWait shortWait() {
+ return HopGuiPage.waitFor(driver, Duration.ofSeconds(5));
+ }
+
+ public void addTransform(String transformName) {
+ addTransform(transformName, 0, 0);
+ }
+
+ /** Opens the transform's own context dialog by clicking it, and picks an
entry from it. */
+ public void actOnTransform(String transformName, String action, int offsetX,
int offsetY) {
+ clickAt(offsetX, offsetY);
+ ContextDialog dialog = contextDialog();
+ // The context dialog of a transform names it in the title, which is the
cheapest confirmation
+ // that the click landed on the transform and not on empty canvas.
+ wait.until(d -> dialog.title() != null &&
dialog.title().contains(transformName));
+ dialog.choose(action);
+ }
+
+ public ContextDialog contextDialog() {
+ ContextDialog dialog = new ContextDialog(driver, wait);
+ dialog.awaitOpen();
+ return dialog;
+ }
+
+ /** Clicks the canvas at an offset from its middle. */
+ public void clickAt(int offsetX, int offsetY) {
+ new Actions(driver).moveToElement(canvas(), offsetX,
offsetY).click().perform();
+ }
+}
diff --git
a/integration-tests/hopweb/src/test/resources/docker/resources/hop-config.json
b/web-tests/src/test/resources/hop-config.json
similarity index 100%
rename from
integration-tests/hopweb/src/test/resources/docker/resources/hop-config.json
rename to web-tests/src/test/resources/hop-config.json
diff --git a/integration-tests/hopweb/src/test/resources/transforms.csv
b/web-tests/src/test/resources/transforms.csv
similarity index 59%
rename from integration-tests/hopweb/src/test/resources/transforms.csv
rename to web-tests/src/test/resources/transforms.csv
index f56c624377..f521c7a580 100644
--- a/integration-tests/hopweb/src/test/resources/transforms.csv
+++ b/web-tests/src/test/resources/transforms.csv
@@ -1,220 +1,275 @@
-# 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.
-Abort
-Add a checksum
-Add constants
-Add sequence
-Add value fields changing sequence
-Add XML
-Analytic query
-Apache Tika
-Append streams
-Avro Decode
-Avro Encode
-Avro File Input
-Avro File Output
-Azure Event Hubs Listener
-Azure Event Hubs Writer
-Beam BigQuery Input
-Beam BigQuery Output
-Beam Bigtable Input
-Beam Bigtable Output
-Beam File Input
-Beam File Output
-Beam GCP Pub/Sub : Publish
-Beam GCP Pub/Sub : Subscribe
-Beam Kafka Consume
-Beam Kafka Produce
-Beam Kinesis Consume
-Beam Kinesis Produce
-Beam Timestamp
-Beam Window
-Block until transforms finish
-Blocking transform
-Calculator
-Call DB procedure
-Cassandra input
-Cassandra output
-Change file encoding
-Check if file is locked
-Check if webservice is available
-Clone row
-Closure generator
-Coalesce Fields
-Column exists
-Combination lookup/update
-Concat Fields
-Copy rows to result
-Credit card validator
-Data grid
-Data validator
-Database join
-Database lookup
-De-serialize from file
-Delay row
-Delete
-Detect empty stream
-Dimension lookup/update
-Doris bulk loader
-Dummy (do nothing)
-Dynamic SQL row
-EDI to XML
-Email messages input
-Enhanced JSON Output
-ETL metadata injection
-Execute a process
-Execute row SQL script
-Execute SQL script
-Execute Unit Tests
-Execution Information
-Fake data
-File exists
-File Metadata
-Filter rows
-Formula
-Fuzzy match
-Generate random value
-Generate rows
-Get data from XML
-Get file names
-Get files from result
-Get files rows count
-Get Neo4j logging info
-Get records from stream
-Get rows from result
-Get Server Status
-Get subfolder names
-Get system info
-Get table names
-Get variables
-Group by
-HTTP client
-HTTP post
-Identify last row in a stream
-If Null
-Injector
-Insert / update
-Java filter
-Join rows (cartesian product)
-JSON input
-JSON output
-Kafka Consumer
-Kafka Producer
-LDAP input
-LDAP output
-Load file content in memory
-Mail
-Mapping Input
-Mapping Output
-Memory group by
-Merge join
-Merge rows (diff)
-Metadata Input
-Metadata structure of stream
-Microsoft Access output
-Microsoft Excel input
-Microsoft Excel writer
-MonetDB bulk loader
-MongoDB Delete
-MongoDB input
-MongoDB output
-Multiway merge join
-Neo4j Cypher
-Neo4j Generate CSVs
-Neo4j Graph Output
-Neo4j Import
-Neo4J Output
-Neo4j Split Graph
-Null if
-Number range
-Oracle bulk loader
-Parquet File Input
-Parquet File Output
-PGP decrypt stream
-PGP encrypt stream
-Pipeline executor
-Pipeline Logging
-Pipeline Probe
-PostgreSQL Bulk Loader
-Process files
-Properties input
-Properties output
-Regex evaluation
-Replace in string
-Reservoir sampling
-REST client
-Row denormaliser
-Row flattener
-Row normaliser
-Rules accumulator
-Rules executor
-Run SSH commands
-Salesforce delete
-Salesforce input
-Salesforce insert
-Salesforce update
-Salesforce upsert
-Sample rows
-SAS Input
-Select values
-Serialize to file
-Set field value
-Set field value to a constant
-Set files in result
-Set variables
-Simple Mapping (sub-pipeline)
-Snowflake Bulk Loader
-Sort rows
-Sorted merge
-Split field to rows
-Split fields
-Splunk Input
-SQL file output
-SSTable output
-Standardize phone number
-Stream lookup
-Stream Schema Merge
-String operations
-Strings cut
-Switch / case
-Synchronize after merge
-Table compare
-Table exists
-Table input
-Table output
-Teradata Fastload bulk loader
-Text file input
-Text file input (deprecated)
-Text file output
-Token Replacement
-Unique rows
-Unique rows (HashSet)
-Update
-User defined Java expression
-Value mapper
-Vertica bulk loader
-Web services lookup
-Workflow executor
-Workflow Logging
-Write to log
-XML input stream (StAX)
-XML join
-XML output
-XSD validator
-XSL Transformation
-YAML input
-Zip file
\ No newline at end of file
+# 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.
+#
+# Every transform Hop declares, resolved from the @Transform annotations and
their
+# English message bundles. Regenerate rather than edit by hand: the previous
list had
+# drifted to Title Case ('Avro Decode' for 'Avro decode'), which made every
entry after
+# the ninth fail, and it was missing 55 transforms added since it was last
touched.
+(Experimental) Beam Hive catalog input
+Abort
+Add a checksum
+Add constants
+Add sequence
+Add value fields changing sequence
+Add XML
+Analytic query
+Apache Tika
+Append streams
+Avro decode
+Avro encode
+Avro file input
+Avro file output
+AWS SNS notify
+AWS SQS reader
+Azure Event Hubs listener
+Azure Event Hubs writer
+Beam Bigtable input
+Beam Bigtable output
+Beam file input
+Beam file output
+Beam GCP Pub/Sub: publish
+Beam GCP Pub/Sub: subscribe
+Beam Kafka consume
+Beam Kafka produce
+Beam Kinesis consume
+Beam Kinesis produce
+Beam timestamp
+Beam window
+BigQuery input
+BigQuery output
+Binary File Output
+Block until transforms finish
+Blocking transform
+Calculator
+Call DB procedure
+Cassandra input
+Cassandra output
+Change file encoding
+Check if file is locked
+Check if webservice is available
+Clone row
+Closure generator
+Coalesce fields
+Column exists
+Combination lookup/update
+Concat fields
+Copy rows to result
+CrateDB bulk loader
+Credit card validator
+CSV file input
+Data grid
+Data set input
+Data set output
+Data stream input
+Data stream output
+Data validator
+Database impact input
+Database join
+Database lookup
+DDL
+De-serialize from file
+Delay row
+Delete
+Detect empty stream
+Detect language
+Dimension lookup/update
+Doris bulk loader
+Dummy (do nothing)
+Dynamic SQL row
+EDI to XML
+Email messages input
+Enhanced JSON output
+ETL metadata injection
+Execute a process
+Execute row SQL script
+Execute SQL script
+Execute unit tests
+Execution information
+Fake
+Fake data
+File exists
+File metadata
+Filter rows
+Formula
+Fuzzy match
+Generate random value
+Generate rows
+Generate Snowflake ID
+Get data from XML
+Get file names
+Get files from result
+Get files rows count
+Get JDBC metadata
+Get Neo4j logging info
+Get records from stream
+Get rows from result
+Get server status
+Get subfolder names
+Get system info
+Get table names
+Get variables
+Google Analytics 4
+Google Sheets input
+Google Sheets output
+Group by
+HTML to text
+HTTP client
+HTTP post
+Identify last row in a stream
+If null
+Injector
+Insert / update
+Java filter
+JavaScript
+JMS consumer
+JMS producer
+Join rows (cartesian product)
+JSON input
+JSON normalize input
+JSON output
+Kafka consumer
+Kafka producer
+Language model chat
+LDAP input
+LDAP output
+Load file content in memory
+Mail
+Mapping input
+Mapping output
+Memory group by
+Merge join
+Merge rows (diff)
+Metadata input
+Metadata structure of stream
+Microsoft Access output
+Microsoft Excel input
+Microsoft Excel writer
+MonetDB bulk loader
+MongoDB delete
+MongoDB input
+MongoDB output
+MS SQL Server bulk loader
+Multi Mapping
+Multiway merge join
+MySQL bulk loader
+Neo4j Cypher
+Neo4j Cypher builder
+Neo4j generate CSVs
+Neo4j graph output
+Neo4j import
+Neo4j output
+Neo4j split graph
+Null if
+Number range
+OData input
+Oracle bulk loader
+Parquet file input
+Parquet file output
+PGP decrypt stream
+PGP encrypt stream
+Pipeline executor
+Pipeline logging
+Pipeline probe
+PostgreSQL bulk loader
+Process files
+Properties input
+Properties output
+Redis input
+Redis output
+Redshift bulk loader
+Regex evaluation
+Repeat fields
+Replace in string
+Reservoir sampling
+REST client
+Row denormaliser
+Row flattener
+Row normaliser
+Rules accumulator
+Rules executor
+Run SSH commands
+Salesforce delete
+Salesforce input
+Salesforce insert
+Salesforce update
+Salesforce upsert
+Sample rows
+SAS input
+Schema mapping
+Script
+Select values
+Serialize to file
+Set field value
+Set field value to a constant
+Set files in result
+Set variables
+SFTP Put
+Simple mapping (sub-pipeline)
+Snowflake bulk loader
+Sort rows
+Sorted merge
+Sorted schema merge
+Spark file input
+Spark file output
+Spark lake table input
+Spark lake table maintenance
+Spark lake table merge
+Spark lake table output
+Spark SQL
+Split field to rows
+Split fields
+Splunk input
+SQL file output
+SSTable output
+Standardize phone number
+Stanford simple NLP
+Stream lookup
+Stream schema merge
+String operations
+Strings cut
+Surefire report output
+Switch / case
+Synchronize after merge
+Table compare
+Table exists
+Table input
+Table output
+Teradata FastLoad bulk loader
+Text file input
+Text file output
+Token replacement
+Unique rows
+Unique rows (HashSet)
+Update
+User defined Java class
+User defined Java expression
+Value mapper
+vCard input
+vCard output
+Vertica bulk loader
+Web services lookup
+Workflow executor
+Workflow logging
+Write to log
+XML input stream (StAX)
+XML join
+XML output
+XML output (Advanced)
+XSD validator
+XSL transformation
+YAML input
+Zip file