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

Reply via email to