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 d37cb2fcf3 [IT] make selecting elements in selenium simpler (#8208)
d37cb2fcf3 is described below

commit d37cb2fcf39a2bb9c94445bc59fbfb9b15db5ca0
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Sep 1 12:46:29 2026 +0200

    [IT] make selecting elements in selenium simpler (#8208)
---
 .../org/apache/hop/ui/hopgui/HopWebEntryPoint.java |  30 +-
 .../org/apache/hop/ui/hopgui/TestIdFacadeImpl.java | 109 +++++++
 .../org/apache/hop/ui/hopgui/canvas-svg.js         |  67 +++++
 .../apache/hop/ui/hopgui/HopWebEntryPointTest.java |  30 ++
 .../org/apache/hop/ui/hopgui/TestIdFacadeImpl.java |  29 ++
 .../apache/hop/ui/core/gui/GuiToolbarWidgets.java  |  35 ++-
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java |  24 ++
 .../org/apache/hop/ui/hopgui/TestIdFacade.java     |  66 +++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |   2 +
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |   2 +
 .../java/org/apache/hop/web/it/BrowserConsole.java | 102 +++++++
 .../org/apache/hop/web/it/HopWebCanvasTest.java    | 104 +++++++
 .../org/apache/hop/web/it/HopWebDialogTest.java    |  71 +++++
 .../org/apache/hop/web/it/HopWebEnvironment.java   |  62 +++-
 .../java/org/apache/hop/web/it/HopWebFileTest.java |  93 ++++++
 .../apache/hop/web/it/HopWebNavigationTest.java    |  97 ++++++
 .../java/org/apache/hop/web/it/HopWebRunTest.java  |  99 +++++++
 .../org/apache/hop/web/it/HopWebSessionTest.java   | 122 ++++++++
 .../org/apache/hop/web/it/HopWebSmokeTest.java     |  17 ++
 .../java/org/apache/hop/web/it/HopWebTestBase.java |  51 +++-
 .../test/java/org/apache/hop/web/it/ServerLog.java |  52 +++-
 .../java/org/apache/hop/web/it/ServerLogTest.java  |  55 +++-
 .../hop/web/it/pages/ExecutionResultsPanel.java    | 118 ++++++++
 .../org/apache/hop/web/it/pages/HopGuiPage.java    | 326 +++++++++++++++++++--
 .../apache/hop/web/it/pages/PipelineGraphPage.java | 290 ++++++++++++++----
 .../apache/hop/web/it/pages/PreviewDataDialog.java |  94 ++++++
 web-tests/src/test/resources/transforms.csv        |  11 +-
 27 files changed, 2048 insertions(+), 110 deletions(-)

diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index b61e7ecec1..23b2e53aa4 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -404,6 +404,18 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
         .toArray(String[]::new);
   }
 
+  /**
+   * Whether this shortcut is a plain character - a letter, a digit, 
punctuation or space - with no
+   * CTRL, ALT, SHIFT or command held down.
+   */
+  private static boolean isUnmodifiedPrintableCharacter(KeyboardShortcut 
shortcut, int keyCode) {
+    if (shortcut.isAlt() || shortcut.isControl() || shortcut.isCommand() || 
shortcut.isShift()) {
+      return false;
+    }
+    // Special keys (F1, arrows, HOME, ...) have bit 24 set and type nothing, 
so they are fine.
+    return keyCode >= 32 && keyCode < 127;
+  }
+
   /**
    * Convert a KeyboardShortcut to RAP format for ACTIVE_KEYS / CANCEL_KEYS. 
RAP only supports CTRL,
    * ALT, SHIFT (not META), so we use CTRL+ for all command/control shortcuts; 
on Mac the browser
@@ -418,13 +430,17 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
     }
 
     int keyCode = shortcut.getKeyCode();
-    // Never register unmodified SPACE as a shortcut - it would capture every 
space key press
-    // and prevent typing space in text fields (see RAP ACTIVE_KEYS behavior).
-    if ((keyCode == ' ' || keyCode == 32)
-        && !shortcut.isAlt()
-        && !shortcut.isControl()
-        && !shortcut.isCommand()
-        && !shortcut.isShift()) {
+    // Never register a shortcut that is a printable character with no 
modifier held. RAP cancels
+    // the browser's own handling of every key it is told about, so 
registering one takes that
+    // character away from typing everywhere in Hop Web, whatever has the 
focus: the bare "z" that
+    // opens a referenced object on the pipeline canvas made it impossible to 
type the letter z
+    // anywhere, and searching the context dialog for "fuzzy match" arrived as 
"fuy match".
+    //
+    // Nothing is lost that a browser could have delivered: the key handler 
already refuses to act
+    // on an unmodified printable character while a text widget has the focus, 
so such a shortcut
+    // could only ever have fired on a canvas - and there is no way to tell 
the browser to cancel
+    // the key in one place and not in another.
+    if (isUnmodifiedPrintableCharacter(shortcut, keyCode)) {
       return null;
     }
 
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java
new file mode 100644
index 0000000000..6c32d2b959
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java
@@ -0,0 +1,109 @@
+/*
+ * 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.hop.core.logging.LogChannel;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.client.service.JavaScriptExecutor;
+import org.eclipse.rap.rwt.widgets.WidgetUtil;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * Hop Web: writes the id onto the widget's element as {@code data-hop-id}.
+ *
+ * <p>RAP gives each widget a generated id ({@code w123}) that it hands to the 
client, and the
+ * client keeps a registry from that id to the widget object. Asking the 
client to set an HTML
+ * attribute on the widget it already knows is therefore enough - no theming 
variant, no markup, and
+ * nothing that changes what the widget is or how it behaves.
+ *
+ * <p>The attribute is remembered by the client widget and re-applied whenever 
it builds its
+ * element, so it survives the widget being hidden, re-laid out or 
re-parented, and it does not
+ * matter whether this runs before or after the widget first appears.
+ *
+ * <p>RAP's own {@code enableUITests} mode is not used: it renders the 
generated {@code w123} ids,
+ * which depend on creation order and so name nothing durable.
+ */
+public class TestIdFacadeImpl extends TestIdFacade {
+
+  /**
+   * The script has to wait for the widget it names.
+   *
+   * <p>RAP appends a script where the code calls for it, but only creates 
widgets at the end of the
+   * request, so a script that ran straight away would look up a widget the 
client has not been told
+   * about yet. Yielding once is normally enough - the whole message is 
applied in one go - and the
+   * retries cover a widget whose creation the server holds back to a later 
request.
+   */
+  private static final int MAX_TRIES = 20;
+
+  private static final int RETRY_MILLIS = 50;
+
+  @Override
+  protected void setInternal(Widget widget, String testId) {
+    try {
+      String rwtId = WidgetUtil.getId(widget);
+      if (rwtId == null || rwtId.isEmpty()) {
+        return;
+      }
+      JavaScriptExecutor executor = 
RWT.getClient().getService(JavaScriptExecutor.class);
+      if (executor == null) {
+        return;
+      }
+      executor.execute(
+          "(function(){var tries=0;var name=function(){try{"
+              + "var w=rwt.remote.ObjectRegistry.getObject('"
+              + escape(rwtId)
+              + "');"
+              + "if(w&&w.setHtmlAttribute){w.setHtmlAttribute('"
+              + TestIdFacade.ATTRIBUTE
+              + "','"
+              + escape(testId)
+              + "');return;}"
+              + "}catch(e){return;}"
+              + "if(++tries<"
+              + MAX_TRIES
+              + "){setTimeout(name,"
+              + RETRY_MILLIS
+              + ");}};setTimeout(name,0);})();");
+    } catch (Exception e) {
+      // Naming a widget is never worth failing the GUI over.
+      LogChannel.UI.logDebug("Could not set " + TestIdFacade.ATTRIBUTE + " " + 
testId + ": " + e);
+    }
+  }
+
+  /**
+   * Ids reach the browser inside a quoted JavaScript string, so anything that 
is not plainly an
+   * identifier is replaced rather than escaped. Ids come from Hop's own 
annotations, but a plugin
+   * is free to put anything in one.
+   */
+  private static String escape(String value) {
+    StringBuilder escaped = new StringBuilder(value.length());
+    for (int i = 0; i < value.length(); i++) {
+      char c = value.charAt(i);
+      boolean safe =
+          (c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || (c >= '0' && c <= '9')
+              || c == '-'
+              || c == '_'
+              || c == '.'
+              || c == ':';
+      escaped.append(safe ? c : '-');
+    }
+    return escaped.toString();
+  }
+}
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
index 59376aabe6..537ca8be2a 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
@@ -529,6 +529,72 @@
             this._fetchAndRender(0);
         },
 
+        /**
+         * Publishes the graph the server just drew on the overlay element, so 
anything outside the
+         * renderer can read what is on the canvas without parsing the SVG.
+         *
+         * The SVG is a picture: its <text> nodes are whatever the icons 
happen to draw, so an icon
+         * that spells "AWSSNS" or "csv" looks exactly like a transform name 
to a reader. The area
+         * owners are the model behind that picture - the same list the 
renderer hit-tests clicks
+         * against - so a UI test can ask for "the transform called X" and get 
the rectangle the
+         * server put it in rather than guessing from glyphs.
+         *
+         * The model is a live accessor rather than a snapshot: magnification 
and offset change on
+         * the client between server paints, so screen coordinates have to be 
computed when they
+         * are asked for.
+         */
+        _publishGraphModel: function () {
+            if (!this._overlay) {
+                return;
+            }
+            var self = this;
+            this._overlay.setAttribute("data-hop-canvas", "graph");
+            this._overlay.setAttribute("data-hop-revision", 
String(this._revision || 0));
+            this._overlay.hopGraph = {
+                revision: this._revision || 0,
+                areas: this._areas || [],
+                props: function () {
+                    return self._getCanvasProps();
+                },
+                /** Every transform/action on the graph, with where it is 
drawn right now. */
+                nodes: function () {
+                    var props = self._getCanvasProps();
+                    if (props.magnification == null && self._props
+                        && self._props.magnification != null) {
+                        props = self._props;
+                    }
+                    var overlayRect = self._overlay.getBoundingClientRect();
+                    var nodes = [];
+                    (self._areas || []).forEach(function (area) {
+                        if (area.areaType !== "TRANSFORM_ICON" && 
area.areaType !== "ACTION_ICON") {
+                            return;
+                        }
+                        var name = iconOwnerName(area);
+                        if (name == null) {
+                            return;
+                        }
+                        var rect = graphRectToScreen(
+                            area.x, area.y, area.width, area.height, props);
+                        nodes.push({
+                            kind: area.areaType === "ACTION_ICON" ? "action" : 
"transform",
+                            name: name,
+                            // Relative to the overlay, which covers exactly 
the canvas.
+                            x: rect.left,
+                            y: rect.top,
+                            width: rect.width,
+                            height: rect.height,
+                            centerX: rect.left + rect.width / 2,
+                            centerY: rect.top + rect.height / 2,
+                            // Relative to the viewport, for a driver that 
clicks in page space.
+                            viewportX: overlayRect.left + rect.left + 
rect.width / 2,
+                            viewportY: overlayRect.top + rect.top + 
rect.height / 2
+                        });
+                    });
+                    return nodes;
+                }
+            };
+        },
+
         _syncOverlayLayout: function (canvas) {
             if (!this._overlay || !canvas) {
                 return;
@@ -597,6 +663,7 @@
                     self._revision = data.revision;
                     self._areas = data.areas || [];
                     self._props = data.props || {};
+                    self._publishGraphModel();
                     if (data.svg && self._svgHost) {
                         self._svgHost.innerHTML = data.svg;
                         var svg = self._svgHost.querySelector("svg");
diff --git 
a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java 
b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
index 022dbef3ab..41ad7cc834 100644
--- a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.hopgui;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -99,6 +100,35 @@ class HopWebEntryPointTest {
     assertEquals(cancelledShortcuts.length, 
Arrays.stream(cancelledShortcuts).distinct().count());
   }
 
+  @Test
+  void refusesBareLetterShortcuts() {
+    // RAP cancels the browser's handling of every key it is told about, so a 
bare "z" - the
+    // pipeline canvas shortcut that opens a referenced object - took the 
letter z away from every
+    // text field in Hop Web.
+    KeyboardShortcut shortcut = mock(KeyboardShortcut.class);
+    when(shortcut.getKeyCode()).thenReturn((int) 'z');
+
+    assertNull(new HopWebEntryPoint().convertToRapFormat(shortcut));
+  }
+
+  @Test
+  void stillMapsTheSameLetterWithAModifier() {
+    KeyboardShortcut shortcut = mock(KeyboardShortcut.class);
+    when(shortcut.getKeyCode()).thenReturn((int) 'z');
+    when(shortcut.isControl()).thenReturn(true);
+
+    assertEquals("CTRL+Z", new 
HopWebEntryPoint().convertToRapFormat(shortcut));
+  }
+
+  @Test
+  void keepsUnmodifiedSpecialKeys() {
+    // Special keys type nothing, so cancelling them costs the browser nothing.
+    KeyboardShortcut shortcut = mock(KeyboardShortcut.class);
+    when(shortcut.getKeyCode()).thenReturn(SWT.ARROW_LEFT);
+
+    assertEquals("ARROW_LEFT", new 
HopWebEntryPoint().convertToRapFormat(shortcut));
+  }
+
   @Test
   void mapsMacCommandShortcutToRapControlShortcut() {
     KeyboardShortcut shortcut = mock(KeyboardShortcut.class);
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java
new file mode 100644
index 0000000000..31368306c6
--- /dev/null
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/TestIdFacadeImpl.java
@@ -0,0 +1,29 @@
+/*
+ * 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.eclipse.swt.widgets.Widget;
+
+/** Desktop: there is no DOM to name a widget in, and SWTBot addresses widgets 
directly. */
+public class TestIdFacadeImpl extends TestIdFacade {
+
+  @Override
+  protected void setInternal(Widget widget, String testId) {
+    // Nothing to do outside the browser.
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
index e2715de600..3b2ccb9506 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
@@ -40,6 +40,7 @@ import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.widget.svg.SvgLabelFacade;
 import org.apache.hop.ui.core.widget.svg.SvgLabelListener;
+import org.apache.hop.ui.hopgui.TestIdFacade;
 import org.apache.hop.ui.hopgui.TextSizeUtilFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
@@ -310,7 +311,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     label.setToolTipText(Const.NVL(toolbarItem.getToolTip(), ""));
     PropsUi.setLook(label, Props.WIDGET_STYLE_TOOLBAR);
     label.pack();
-    widgetsMap.put(toolbarItem.getId(), label);
+    register(toolbarItem, label);
     Listener listener = getListener(toolbarItem);
     label.addListener(SWT.MouseUp, listener);
   }
@@ -332,7 +333,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     Listener listener = getListener(toolbarItem);
     combo.addListener(SWT.Selection, listener);
     combo.addListener(SWT.DefaultSelection, listener);
-    widgetsMap.put(toolbarItem.getId(), combo);
+    register(toolbarItem, combo);
   }
 
   private void addWebToolbarText(GuiToolbarItem toolbarItem, Composite parent) 
{
@@ -353,7 +354,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     text.addListener(SWT.Selection, listener);
     text.addListener(SWT.DefaultSelection, listener);
     addTextEnterKeyListener(text, listener);
-    widgetsMap.put(toolbarItem.getId(), text);
+    register(toolbarItem, text);
   }
 
   private void addWebToolbarGap(Composite parent) {
@@ -400,7 +401,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
         new RowData(checkbox.getSize().x + toolbarItem.getExtraWidth(), 
SWT.DEFAULT));
     Listener listener = getListener(toolbarItem);
     checkbox.addListener(SWT.Selection, listener);
-    widgetsMap.put(toolbarItem.getId(), checkbox);
+    register(toolbarItem, checkbox);
   }
 
   private void addWebToolbarButtonToComposite(GuiToolbarItem toolbarItem, 
Composite parent) {
@@ -462,7 +463,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     composite.pack();
     composite.setLayoutData(new RowData(composite.getSize().x, 
composite.getSize().y));
 
-    widgetsMap.put(toolbarItem.getId(), composite);
+    register(toolbarItem, composite);
     textLabelMap.put(toolbarItem.getId(), textLabel);
 
     setToolItemKeyboardShortcutForComposite(composite, toolbarItem);
@@ -496,7 +497,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     labelSeparator.setWidth(label.getSize().x);
     labelSeparator.setControl(label);
     toolItemMap.put(toolbarItem.getId(), labelSeparator);
-    widgetsMap.put(toolbarItem.getId(), label);
+    register(toolbarItem, label);
     Listener listener = getListener(toolbarItem);
     label.addListener(SWT.MouseUp, listener);
   }
@@ -523,7 +524,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     combo.addListener(SWT.Selection, listener);
     combo.addListener(SWT.DefaultSelection, listener);
     toolItemMap.put(toolbarItem.getId(), comboSeparator);
-    widgetsMap.put(toolbarItem.getId(), combo);
+    register(toolbarItem, combo);
     PropsUi.setLook(combo, Props.WIDGET_STYLE_TOOLBAR);
   }
 
@@ -564,7 +565,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     textSeparator.setWidth(200 + toolbarItem.getExtraWidth() + gap);
     textSeparator.setControl(wrapper);
     toolItemMap.put(toolbarItem.getId(), textSeparator);
-    widgetsMap.put(toolbarItem.getId(), text);
+    register(toolbarItem, text);
   }
 
   private void addToolbarCheckbox(GuiToolbarItem toolbarItem, ToolBar toolBar) 
{
@@ -581,7 +582,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     Listener listener = getListener(toolbarItem);
     checkbox.addListener(SWT.Selection, listener);
     toolItemMap.put(toolbarItem.getId(), checkboxSeparator);
-    widgetsMap.put(toolbarItem.getId(), checkbox);
+    register(toolbarItem, checkbox);
   }
 
   private void addToolbarButton(GuiToolbarItem toolbarItem, ToolBar toolBar) {
@@ -598,9 +599,23 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     item.addListener(SWT.Selection, listener);
     toolItemMap.put(toolbarItem.getId(), item);
     widgetsMap.put(toolbarItem.getId(), item.getParent());
+    TestIdFacade.set(item, toolbarItem.getId());
     setToolItemKeyboardShortcut(item, toolbarItem);
   }
 
+  /**
+   * Remembers the widget that carries a toolbar item, and gives it the item's 
id in the browser.
+   *
+   * <p>The id is the one from the {@code GuiToolbarElement} annotation, which 
already names the
+   * toolbar it belongs to, so a test asks for the entry it means rather than 
for an icon at a
+   * position. It is not unique on its own: a graph toolbar exists once per 
open tab, so a caller
+   * outside takes the visible match.
+   */
+  private void register(GuiToolbarItem toolbarItem, Control widget) {
+    widgetsMap.put(toolbarItem.getId(), widget);
+    TestIdFacade.set(widget, toolbarItem.getId());
+  }
+
   private String findImageFilename(GuiToolbarItem toolbarItem) {
     String imageLocation;
     if (StringUtils.isEmpty(toolbarItem.getImageMethod())) {
@@ -712,7 +727,7 @@ public class GuiToolbarWidgets extends BaseGuiWidgets 
implements IToolbarWidgetR
     item.setWidth(composite.getSize().x);
     item.setControl(composite);
 
-    widgetsMap.put(toolbarItem.getId(), composite);
+    register(toolbarItem, composite);
     textLabelMap.put(toolbarItem.getId(), textLabel);
     toolItemMap.put(toolbarItem.getId(), item);
   }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index cbda06fa44..970a24a51d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -177,6 +177,20 @@ import org.eclipse.swt.widgets.ToolItem;
 @Setter
 public class HopGui
     implements IActionContextHandlersProvider, ISearchableProvider, 
IHasHopMetadataProvider {
+
+  /**
+   * What a perspective's sidebar button is called in the browser, plus the 
perspective's plugin id.
+   * Switching perspective is the first thing any Hop Web test has to do, and 
the buttons are icons
+   * with no text to go by.
+   */
+  public static final String PERSPECTIVE_TEST_ID_PREFIX = "perspective-";
+
+  /**
+   * What a perspective's own content area is called in the browser. Exactly 
one is visible at a
+   * time, so it says which perspective is showing rather than which button 
was pressed.
+   */
+  public static final String PERSPECTIVE_CONTENT_TEST_ID_PREFIX = 
"perspective-content-";
+
   private static final Class<?> PKG = HopGui.class;
 
   public static final String TEXT_EDITOR_FOCUS_DATA = HopGui.class.getName() + 
".textEditorFocus";
@@ -1031,6 +1045,9 @@ public class HopGui
         imageLabel.setToolTipText(tooltip);
         imageLabel.setData("org.eclipse.rap.rwt.customVariant", 
"sidebarButton");
         SvgLabelFacade.setData(perspective.getId() + "-sidebar", imageLabel, 
imagePath, imageSize);
+        // The composite is what a click has to land on; the image only draws 
the icon.
+        TestIdFacade.set(comp, PERSPECTIVE_TEST_ID_PREFIX + 
perspective.getId());
+        TestIdFacade.set(imageLabel, PERSPECTIVE_TEST_ID_PREFIX + 
perspective.getId() + "-icon");
 
         // Center the label in the composite
         GridData gd = new GridData(SWT.CENTER, SWT.CENTER, true, true);
@@ -1038,6 +1055,7 @@ public class HopGui
       } else {
         Canvas canvas = new Canvas(parent, SWT.NONE);
         composite = canvas;
+        TestIdFacade.set(canvas, PERSPECTIVE_TEST_ID_PREFIX + 
perspective.getId());
         canvas.setToolTipText(tooltip);
         canvas.setBackground(normalBg);
         imageLabel = null;
@@ -2445,6 +2463,10 @@ public class HopGui
     //
     StackLayout layout = (StackLayout) mainPerspectivesComposite.getLayout();
     layout.topControl = perspective.getControl();
+    // Only the perspective on top is visible, so naming every perspective's 
own control is what
+    // tells a browser which perspective is showing - the sidebar buttons all 
look alike.
+    TestIdFacade.set(
+        perspective.getControl(), PERSPECTIVE_CONTENT_TEST_ID_PREFIX + 
perspective.getId());
     mainPerspectivesComposite.layout();
 
     // Notify the perspective that it has been activated.
@@ -2537,6 +2559,7 @@ public class HopGui
         imgLabel.setLayoutData(new GridData(SWT.CENTER, SWT.CENTER, true, 
true));
         String svgId = "sidebar-bottom-" + d.getId();
         SvgLabelFacade.setData(svgId, imgLabel, d.getImagePath(), 
d.getImageSize());
+        TestIdFacade.set(comp, svgId);
 
         GridData compGd = new GridData();
         compGd.widthHint = buttonSize;
@@ -2603,6 +2626,7 @@ public class HopGui
         canvas.setToolTipText(d.getTooltip());
         canvas.setBackground(normalBg);
         canvas.setData("descriptor", d);
+        TestIdFacade.set(canvas, "sidebar-bottom-" + d.getId());
         canvas.setData("selected", d.getSelectedSupplier().getAsBoolean());
         canvas.setData("hovered", false);
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/TestIdFacade.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/TestIdFacade.java
new file mode 100644
index 0000000000..32523322f7
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/TestIdFacade.java
@@ -0,0 +1,66 @@
+/*
+ * 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.eclipse.swt.widgets.Widget;
+
+/**
+ * Gives a widget a name the browser can see, as {@code data-hop-id} on the 
element Hop Web renders
+ * it to. A no-op on the desktop, where widgets are addressed through SWT 
itself.
+ *
+ * <p>Without this, a browser driving Hop Web can only go by what is painted: 
the text in a label,
+ * the position of an icon, the order of the shells on screen. All three 
change for reasons that
+ * have nothing to do with the thing being addressed - a translation, a new 
toolbar entry, a wider
+ * window - so tests written against them break on unrelated work and, worse, 
quietly click
+ * something else. A {@code data-hop-id} is chosen by the code that builds the 
widget and only
+ * changes when that code does.
+ *
+ * <p>Ids are the ones Hop already uses internally, so there is nothing new to 
keep in step: a
+ * toolbar item carries its {@code GuiToolbarElement} id, a perspective button 
carries {@code
+ * perspective-} plus the perspective's plugin id.
+ *
+ * <p>Ids are not unique on their own. The same toolbar exists once per open 
tab, so a selector has
+ * to take the visible match; that is a property of the GUI rather than of the 
id.
+ */
+public abstract class TestIdFacade {
+
+  /** The attribute Hop Web renders these ids to. */
+  public static final String ATTRIBUTE = "data-hop-id";
+
+  private static final TestIdFacade IMPL;
+
+  static {
+    IMPL = (TestIdFacade) ImplementationLoader.newInstance(TestIdFacade.class);
+  }
+
+  /**
+   * Names a widget for the browser. Safe to call from shared UI code: the 
desktop implementation
+   * does nothing, and neither does Hop Web for a widget it cannot reach.
+   *
+   * @param widget the widget to name, ignored when null or disposed
+   * @param testId the name, ignored when null or empty
+   */
+  public static void set(Widget widget, String testId) {
+    if (widget == null || widget.isDisposed() || testId == null || 
testId.isEmpty()) {
+      return;
+    }
+    IMPL.setInternal(widget, testId);
+  }
+
+  protected abstract void setInternal(Widget widget, String testId);
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 9185370722..a97f718051 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -166,6 +166,7 @@ import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiExtensionPoint;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.ServerPushSessionFacade;
+import org.apache.hop.ui.hopgui.TestIdFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
@@ -618,6 +619,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
     //
     canvas = new Canvas(sashForm, SWT.NO_BACKGROUND | SWT.BORDER);
     canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom 
handling
+    TestIdFacade.set(canvas, "pipeline-graph-canvas");
     Listener listener = CanvasListener.getInstance();
     canvas.addListener(SWT.MouseDown, listener);
     canvas.addListener(SWT.MouseMove, listener);
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index f3292ae794..d61339984a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -125,6 +125,7 @@ import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiExtensionPoint;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.ServerPushSessionFacade;
+import org.apache.hop.ui.hopgui.TestIdFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
@@ -521,6 +522,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
     //
     canvas = new Canvas(sashForm, SWT.NO_BACKGROUND | SWT.BORDER);
     canvas.setData("hop-zoom-canvas", "true"); // Mark this canvas for zoom 
handling
+    TestIdFacade.set(canvas, "workflow-graph-canvas");
     Listener listener = CanvasListener.getInstance();
     canvas.addListener(SWT.MouseDown, listener);
     canvas.addListener(SWT.MouseMove, listener);
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/BrowserConsole.java 
b/web-tests/src/test/java/org/apache/hop/web/it/BrowserConsole.java
new file mode 100644
index 0000000000..c345d7b699
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/BrowserConsole.java
@@ -0,0 +1,102 @@
+/*
+ * 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.List;
+import java.util.logging.Level;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.logging.LogEntries;
+import org.openqa.selenium.logging.LogEntry;
+import org.openqa.selenium.logging.LogType;
+
+/**
+ * The other half of {@link ServerLog}: what went wrong in the browser.
+ *
+ * <p>Hop Web is a Java UI painted by a JavaScript client, and the two fail in 
different places. A
+ * server side crash never reaches the page; a client side one - RAP asking 
for a widget the server
+ * has already forgotten, an image that will not load, a protocol message it 
cannot apply - never
+ * reaches the server log. Reading both means a test does not have to assert 
on the specific thing
+ * that broke to notice that something did.
+ */
+public final class BrowserConsole {
+
+  /**
+   * Errors the browser reports that say nothing about Hop.
+   *
+   * <p>Kept as short as possible. Every entry here is a hole in the check, so 
an entry has to
+   * describe something Hop cannot fix rather than something it has not fixed 
yet.
+   */
+  private static final List<String> NOISE =
+      List.of(
+          // Chrome asks for one whether the application serves one or not.
+          "favicon.ico",
+          // RAP probes for browser features and expects some of the probes to 
fail.
+          "Failed to load resource: net::ERR_",
+          // Headless Chrome has no fonts to preload from and says so on every 
page load.
+          "Failed to decode downloaded font",
+          "OTS parsing error");
+
+  private BrowserConsole() {}
+
+  /**
+   * Errors logged by the browser since the last call, worst first.
+   *
+   * <p>Reading the log drains it, which is what makes "since the last call" 
work: a test marks the
+   * start by draining, and reads at the end.
+   *
+   * <p>Returns nothing at all when the driver cannot serve browser logs. That 
is not a failure to
+   * report - a browser that does not implement the (non-standard) log 
endpoint would otherwise fail
+   * every test in the suite for a reason that has nothing to do with Hop.
+   */
+  public static List<String> errors(WebDriver driver) {
+    LogEntries entries = read(driver);
+    if (entries == null) {
+      return List.of();
+    }
+    return entries.getAll().stream()
+        .filter(entry -> entry.getLevel().intValue() >= 
Level.SEVERE.intValue())
+        .map(LogEntry::getMessage)
+        .filter(message -> NOISE.stream().noneMatch(message::contains))
+        .distinct()
+        .toList();
+  }
+
+  /** Throws away whatever is buffered, so the next read only covers what 
happens after this. */
+  public static void drain(WebDriver driver) {
+    errors(driver);
+  }
+
+  /**
+   * Whether this browser hands its console over at all.
+   *
+   * <p>Worth asking before concluding anything from an empty result: a 
browser that does not
+   * implement the endpoint reports no errors for the same reason a healthy 
one does.
+   */
+  public static boolean isSupported(WebDriver driver) {
+    return read(driver) != null;
+  }
+
+  private static LogEntries read(WebDriver driver) {
+    try {
+      return driver.manage().logs().get(LogType.BROWSER);
+    } catch (RuntimeException e) {
+      // The log endpoint is not part of WebDriver proper, so a browser is 
entitled to refuse it.
+      return null;
+    }
+  }
+}
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/HopWebCanvasTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebCanvasTest.java
new file mode 100644
index 0000000000..5d54710af5
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebCanvasTest.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.web.it;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Working on the canvas itself: moving things, undoing, zooming.
+ *
+ * <p>None of this is shared with the fat client. The graph is drawn into a 
{@code <canvas>} whose
+ * mouse handling, drag tracking and zoom are Hop Web's own, and every one of 
them has been broken
+ * at least once - dragging in issue #7227, zoom in #6442, and a canvas that 
reacted to a click on
+ * the preview icon by going into drag mode in #6285.
+ */
+@DisplayName("The canvas")
+class HopWebCanvasTest extends HopWebTestBase {
+
+  private static final String TRANSFORM = "Generate rows";
+
+  /** Half a transform's width; a move has to be bigger than this to be 
visible at all. */
+  private static final int TOLERANCE = 25;
+
+  @Test
+  @DisplayName("a transform can be dragged somewhere else")
+  void dragsATransform() {
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(TRANSFORM);
+    int[] before = graph.transformOffset(TRANSFORM);
+
+    graph.dragTransform(TRANSFORM, 150, 90);
+
+    wait.until(d -> Math.abs(graph.transformOffset(TRANSFORM)[0] - before[0]) 
> TOLERANCE);
+    int[] after = graph.transformOffset(TRANSFORM);
+    assertTrue(
+        Math.abs(after[0] - before[0] - 150) < TOLERANCE
+            && Math.abs(after[1] - before[1] - 90) < TOLERANCE,
+        () ->
+            "expected the transform to move by about 150,90 but it went from "
+                + before[0]
+                + ","
+                + before[1]
+                + " to "
+                + after[0]
+                + ","
+                + after[1]);
+  }
+
+  @Test
+  @DisplayName("undo takes a transform away again, redo puts it back")
+  void undoesAndRedoes() {
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(TRANSFORM);
+
+    graph.undo(hopGui);
+
+    wait.until(d -> !graph.contains(TRANSFORM));
+    assertFalse(graph.contains(TRANSFORM), () -> "undo left " + 
graph.labels());
+
+    graph.redo(hopGui);
+
+    wait.until(d -> graph.contains(TRANSFORM));
+    assertTrue(graph.contains(TRANSFORM), () -> "redo left " + graph.labels());
+  }
+
+  @Test
+  @DisplayName("zooming in and out changes how big the graph is drawn")
+  void zooms() {
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(TRANSFORM);
+    int normal = graph.transformIconSize(TRANSFORM);
+
+    hopGui.clickWidget(PipelineGraphPage.ZOOM_IN);
+
+    wait.until(d -> graph.transformIconSize(TRANSFORM) > normal);
+    int zoomedIn = graph.transformIconSize(TRANSFORM);
+
+    hopGui.clickWidget(PipelineGraphPage.ZOOM_OUT);
+
+    wait.until(d -> graph.transformIconSize(TRANSFORM) < zoomedIn);
+    assertTrue(
+        graph.transformIconSize(TRANSFORM) <= normal,
+        () -> "zooming back out left the icon at " + 
graph.transformIconSize(TRANSFORM));
+  }
+}
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/HopWebDialogTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebDialogTest.java
new file mode 100644
index 0000000000..62bfda9645
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebDialogTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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 org.apache.hop.web.it.pages.ExecutionResultsPanel;
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What is typed into a transform dialog is what the pipeline then does.
+ *
+ * <p>The dialog sweep opens all 275 transform dialogs and closes them again, 
which catches a dialog
+ * that will not open and nothing else. Everything after that - typing into a 
field, the field
+ * reaching the transform, the transform running with it - has never been 
tested in Hop Web, and it
+ * is where the RAP specific bugs live: text fields that ignored the arrow 
keys (issue #7833), a
+ * dialog whose grid did not work at all (issue #4475), fields that came back 
empty (issue #7301).
+ *
+ * <p>The assertion is deliberately made at the far end, on rows the engine 
actually produced,
+ * rather than on the dialog showing the value back: a field that displays 
what was typed and drops
+ * it on OK looks perfectly healthy from the browser.
+ */
+@DisplayName("Transform dialogs")
+class HopWebDialogTest extends HopWebTestBase {
+
+  private static final String TRANSFORM = "Generate rows";
+
+  @Test
+  @DisplayName("a value typed into a dialog reaches the running pipeline")
+  void dialogValuesReachTheEngine() {
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(TRANSFORM);
+
+    graph.actOnTransform(TRANSFORM, "Edit", 0, 0);
+    hopGui.awaitDialog();
+    hopGui.enterDialogField("Limit", "5");
+    hopGui.clickButton("OK");
+    hopGui.awaitNoDialog();
+
+    // A pipeline can only be run once it has a name of its own.
+    hopGui.saveFileAs(
+        HopWebEnvironment.scratchFolder()
+            + "/hop-web-it-dialog-"
+            + System.currentTimeMillis()
+            + ".hpl");
+
+    ExecutionResultsPanel results = graph.run(hopGui, TRANSFORM);
+
+    assertEquals(
+        "5",
+        results.metricsOf(TRANSFORM).get("Written (rows)"),
+        () -> "the limit typed into the dialog did not reach the engine: " + 
results.metrics());
+  }
+}
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
index edb22a4320..840746879f 100644
--- 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
@@ -25,11 +25,15 @@ import java.io.InputStreamReader;
 import java.net.URI;
 import java.nio.file.Path;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.logging.Level;
 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.logging.LogType;
+import org.openqa.selenium.logging.LoggingPreferences;
 import org.openqa.selenium.support.ui.ExpectedConditions;
 import org.testcontainers.Testcontainers;
 import org.testcontainers.containers.BrowserWebDriverContainer;
@@ -89,6 +93,9 @@ public final class HopWebEnvironment {
 
   private final String uiUrl;
   private final WebDriver driver;
+  private final BrowserMode browserMode;
+  private final Network network;
+  private final List<WebDriver> extraBrowsers = new ArrayList<>();
 
   /**
    * Everything Hop Web has printed, streamed as it runs. Null when the tests 
drive a Hop Web that
@@ -115,6 +122,8 @@ public final class HopWebEnvironment {
     }
 
     this.uiUrl = urlForBrowser;
+    this.browserMode = browserMode;
+    this.network = network;
     this.driver = browserMode == BrowserMode.CONTAINER ? 
containerBrowser(network) : localBrowser();
   }
 
@@ -134,13 +143,45 @@ public final class HopWebEnvironment {
   }
 
   private void close() {
+    extraBrowsers.forEach(HopWebEnvironment::quietly);
+    quietly(driver);
+  }
+
+  private static void quietly(WebDriver browser) {
     try {
-      driver.quit();
+      browser.quit();
     } catch (RuntimeException e) {
       // Nothing useful left to do while the JVM is going down.
     }
   }
 
+  /**
+   * A second browser, looking at the same Hop Web through a session of its 
own.
+   *
+   * <p>A second window of the same browser would not do: it carries the same 
session cookie, so Hop
+   * Web would hand it the GUI the first window is already using and nothing 
about session isolation
+   * would be under test. A separate browser gets a separate RAP session, 
which is what the state in
+   * Hop Web is scoped to (issue #8047).
+   *
+   * <p>The caller closes it with {@link #closeBrowser}; whatever is left over 
is closed with the
+   * JVM.
+   */
+  public WebDriver openAnotherBrowser() {
+    WebDriver browser =
+        browserMode == BrowserMode.CONTAINER ? containerBrowser(network) : 
localBrowser();
+    extraBrowsers.add(browser);
+    browser.get(uiUrl);
+    HopGuiPage.waitFor(browser, Duration.ofSeconds(startupTimeoutSeconds()))
+        
.until(ExpectedConditions.presenceOfElementLocated(HopGuiPage.NEW_FILE));
+    return browser;
+  }
+
+  /** Closes a browser opened with {@link #openAnotherBrowser}. */
+  public void closeBrowser(WebDriver browser) {
+    extraBrowsers.remove(browser);
+    quietly(browser);
+  }
+
   public WebDriver getDriver() {
     return driver;
   }
@@ -182,6 +223,21 @@ public final class HopWebEnvironment {
     uiOpened = true;
   }
 
+  /**
+   * The samples project, as the Hop Web under test sees it.
+   *
+   * <p>A path rather than a project name: switching the active project 
changes what every later
+   * test in the session sees, and a file dialog takes an absolute path 
without any of that.
+   */
+  public static String samplesFolder() {
+    return property("hopweb.samples", 
"/usr/local/tomcat/webapps/ROOT/config/projects/samples");
+  }
+
+  /** A folder the Hop Web under test may write test files into. */
+  public static String scratchFolder() {
+    return property("hopweb.scratch", "/tmp");
+  }
+
   /** The Hop Web URL as the browser has to address it, which is not always 
how we address it. */
   public String getUiUrl() {
     return uiUrl;
@@ -310,6 +366,10 @@ public final class HopWebEnvironment {
     options.addArguments("--disable-dev-shm-usage");
     options.addArguments("--window-size=1600,1000");
     options.addArguments("--remote-allow-origins=*");
+    // Without this the browser keeps its console to itself and BrowserConsole 
reads an empty log.
+    LoggingPreferences logging = new LoggingPreferences();
+    logging.enable(LogType.BROWSER, Level.WARNING);
+    options.setCapability("goog:loggingPrefs", logging);
     return options;
   }
 
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/HopWebFileTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebFileTest.java
new file mode 100644
index 0000000000..116f20ec56
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebFileTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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 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;
+
+/**
+ * Saving a file and getting it back.
+ *
+ * <p>Hop Web cannot use the operating system's file dialog the way the fat 
client does, so both
+ * buttons go through Hop's own {@code HopVfsFileDialog} - code the desktop 
Hop never runs. Until
+ * now the suite asserted that the Save and Open buttons were on the toolbar 
and never pressed
+ * either.
+ *
+ * <p>Each test reads its file back in a <em>new</em> session. Opening it in 
the session that wrote
+ * it proves very little: Hop would simply bring the tab it already has to the 
front, and a file
+ * that was never written would pass.
+ */
+@DisplayName("Saving and opening files")
+class HopWebFileTest extends HopWebTestBase {
+
+  private static final String FIRST = "Generate rows";
+  private static final String SECOND = "Dummy (do nothing)";
+
+  /** A file of this test's own, so nothing depends on what another test left 
behind. */
+  private String scratchFile(String name) {
+    return HopWebEnvironment.scratchFolder()
+        + "/hop-web-it-"
+        + name
+        + "-"
+        + System.currentTimeMillis()
+        + ".hpl";
+  }
+
+  /** Reloads the GUI, which is the only way to be sure nothing is open any 
more. */
+  private void inANewSession() {
+    HopWebEnvironment.get().reopenUi();
+    hopGui.dismissWelcomeDialog();
+  }
+
+  @Test
+  @DisplayName("a pipeline can be saved and read back")
+  void savesAndReopens() {
+    String path = scratchFile("save-as");
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(FIRST);
+
+    hopGui.saveFileAs(path);
+
+    inANewSession();
+    PipelineGraphPage reopened = hopGui.openPipeline(path, FIRST);
+    assertTrue(reopened.contains(FIRST), () -> "the file came back as " + 
reopened.labels());
+  }
+
+  @Test
+  @DisplayName("saving again keeps the changes made since")
+  void savesChanges() {
+    String path = scratchFile("save");
+    PipelineGraphPage graph = hopGui.newPipeline();
+    graph.addTransform(FIRST);
+    hopGui.saveFileAs(path);
+
+    // Save, not Save as: the file already has a name, so this is the button a 
user presses all
+    // day and the one that stopped working in issue #6362.
+    graph.addTransform(SECOND, 150, 0);
+    hopGui.clickWidget(HopGuiPage.SAVE_FILE);
+
+    inANewSession();
+    PipelineGraphPage reopened = hopGui.openPipeline(path, FIRST);
+    assertTrue(
+        reopened.contains(SECOND), () -> "the second transform is missing: " + 
reopened.labels());
+  }
+}
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/HopWebNavigationTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebNavigationTest.java
new file mode 100644
index 0000000000..1c322ff235
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebNavigationTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.assertTrue;
+
+import org.apache.hop.web.it.pages.HopGuiPage;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The shell around the editors: the perspective sidebar and the project and 
environment the GUI
+ * says it is working in.
+ *
+ * <p>None of this used to be reachable from a test. The sidebar is a column 
of icons with no text,
+ * the project and environment are icons with a label beside them, and the 
only thing that told them
+ * apart was where they happened to sit on screen. They are addressed here by 
the names Hop Web puts
+ * on them (see {@code TestIdFacade}).
+ */
+@DisplayName("Perspectives and project")
+class HopWebNavigationTest extends HopWebTestBase {
+
+  /** Where the GUI starts, and where every test here has to leave it. */
+  private static final String EXPLORER = "explorer-perspective";
+
+  private static final String METADATA = "metadata-perspective";
+
+  private static final String CONFIGURATION = "configuration";
+
+  /**
+   * The perspective is session state: a test that left another one up would 
hand the next test a
+   * GUI with no canvas in it.
+   */
+  @AfterEach
+  void backToTheExplorer() {
+    if (!EXPLORER.equals(hopGui.activePerspective())) {
+      hopGui.switchToPerspective(EXPLORER);
+    }
+  }
+
+  @Test
+  @DisplayName("the GUI starts in the explorer perspective")
+  void startsInTheExplorer() {
+    assertEquals(EXPLORER, hopGui.activePerspective());
+  }
+
+  @Test
+  @DisplayName("the sidebar switches perspective, and back again")
+  void switchesPerspective() {
+    hopGui.switchToPerspective(METADATA);
+    assertEquals(METADATA, hopGui.activePerspective());
+
+    hopGui.switchToPerspective(CONFIGURATION);
+    assertEquals(CONFIGURATION, hopGui.activePerspective());
+
+    hopGui.switchToPerspective(EXPLORER);
+    assertEquals(EXPLORER, hopGui.activePerspective());
+  }
+
+  @Test
+  @DisplayName("only one perspective is on screen at a time")
+  void showsOnePerspective() {
+    hopGui.switchToPerspective(METADATA);
+
+    assertTrue(hopGui.isVisible(HopGuiPage.testId("perspective-content-" + 
METADATA)));
+    assertFalse(
+        hopGui.isVisible(HopGuiPage.testId("perspective-content-" + EXPLORER)),
+        "the explorer perspective is still on screen behind the metadata one");
+  }
+
+  @Test
+  @DisplayName("the bottom toolbar names the project being worked in")
+  void namesTheProject() {
+    // The container is configured with the default project and no 
environment; asserting on the
+    // name rather than on "something is there" is what would catch a GUI that 
quietly lost track
+    // of which project it has open.
+    assertEquals("default", hopGui.projectName());
+  }
+}
diff --git a/web-tests/src/test/java/org/apache/hop/web/it/HopWebRunTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebRunTest.java
new file mode 100644
index 0000000000..f6c058ef05
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebRunTest.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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.web.it.pages.ExecutionResultsPanel;
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.apache.hop.web.it.pages.PreviewDataDialog;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Running a pipeline, which is the one thing Hop Web is for and the one thing 
the suite never did.
+ *
+ * <p>It is also where the failures nobody sees live. Everything up to here 
happens on the request
+ * thread RAP hands the UI; an execution does not. It starts threads of its 
own that report progress
+ * back into widgets, and RAP - unlike the fat client's SWT - refuses any of 
that from a thread that
+ * is not the UI thread of the session. Two of those (issues #8195 and #7896) 
shipped past a green
+ * suite because nothing in it ever pressed Run.
+ */
+@DisplayName("Running a pipeline")
+class HopWebRunTest extends HopWebTestBase {
+
+  /** A sample that reads nothing from outside itself, so it runs the same 
anywhere. */
+  private static final String PIPELINE = "/transforms/add-constants.hpl";
+
+  private static final String GENERATOR = "generate 1 row";
+  private static final String CONSTANTS = "add constants";
+
+  private PipelineGraphPage openSample() {
+    return hopGui.openPipeline(HopWebEnvironment.samplesFolder() + PIPELINE, 
GENERATOR);
+  }
+
+  @Test
+  @DisplayName("a pipeline runs and every transform reports itself finished")
+  void runsToCompletion() {
+    PipelineGraphPage graph = openSample();
+
+    ExecutionResultsPanel results = graph.run(hopGui, GENERATOR, CONSTANTS);
+
+    // run() already waited for every transform to be Finished; this says 
which ones took part,
+    // so a pipeline that "finished" without ever starting its transforms is 
not a pass.
+    assertNotNull(results.metricsOf(GENERATOR), "no metrics for " + GENERATOR);
+    assertNotNull(results.metricsOf(CONSTANTS), "no metrics for " + CONSTANTS);
+  }
+
+  @Test
+  @DisplayName("the metrics report the rows that were really moved")
+  void reportsRowCounts() {
+    PipelineGraphPage graph = openSample();
+
+    ExecutionResultsPanel results = graph.run(hopGui, GENERATOR, CONSTANTS);
+
+    Map<String, String> generator = results.metricsOf(GENERATOR);
+    Map<String, String> constants = results.metricsOf(CONSTANTS);
+    // The sample generates one row and adds constants to it. Asserting the 
counts rather than the
+    // status is what tells a pipeline that ran from one that only claimed to.
+    assertEquals("1", generator.get("Written (rows)"), () -> GENERATOR + " 
wrote " + generator);
+    assertEquals("1", constants.get("Read (rows)"), () -> CONSTANTS + " read " 
+ constants);
+    assertEquals("0", constants.get("Errors (rows)"), () -> CONSTANTS + " 
errored " + constants);
+  }
+
+  @Test
+  @DisplayName("previewing a transform shows the rows it produced")
+  void previewsRows() {
+    PipelineGraphPage graph = openSample();
+
+    PreviewDataDialog preview = graph.preview(hopGui, CONSTANTS);
+
+    assertEquals(1, preview.rowCount(), "the sample produces exactly one row");
+    List<List<String>> rows = preview.rows();
+    // The constants the sample adds. Reading the data back is the only 
assertion in the suite that
+    // says Hop Web moved the right values, rather than the right number of 
them.
+    assertTrue(
+        rows.stream().anyMatch(row -> row.contains("abcdefgh")),
+        () -> "the previewed row does not carry the constants: " + rows);
+    preview.close();
+  }
+}
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/HopWebSessionTest.java 
b/web-tests/src/test/java/org/apache/hop/web/it/HopWebSessionTest.java
new file mode 100644
index 0000000000..694dbada13
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/HopWebSessionTest.java
@@ -0,0 +1,122 @@
+/*
+ * 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.assertTrue;
+
+import java.util.List;
+import org.apache.hop.web.it.pages.HopGuiPage;
+import org.apache.hop.web.it.pages.PipelineGraphPage;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.WebDriver;
+
+/**
+ * Two people using the same Hop Web at the same time.
+ *
+ * <p>This is the failure mode that belongs to Hop Web alone. The fat client 
has one user, one
+ * window and one set of images, so anything held in a {@code static} field is 
correct there and
+ * shared by everybody here. Isolating that state took a release of its own 
(issue #8047), and a
+ * cache of session scoped images left in a static field is what made Hop Web 
throw "Argument not
+ * valid" at whoever was still working after somebody else's session ended 
(issue #3508).
+ *
+ * <p>A second browser rather than a second window: windows of one browser 
share the session cookie,
+ * so Hop Web would hand both the same session and there would be nothing to 
isolate.
+ */
+@DisplayName("Two sessions at once")
+class HopWebSessionTest extends HopWebTestBase {
+
+  private static final String MINE = "Generate rows";
+  private static final String THEIRS = "Dummy (do nothing)";
+
+  /**
+   * Leaves a healthy session behind for whatever runs next.
+   *
+   * <p>Not tidiness: a session that has been through this comes out damaged. 
Hop Web currently
+   * fails to save its GUI options once a second session has been opened - 
"Could not find file with
+   * URI .../hop-config.json.new ... no base URI was provided" - and puts a 
modal error dialog up,
+   * which every later test in that session would then fail on rather than on 
its own subject.
+   */
+  @AfterAll
+  static void startAFreshSession() {
+    HopWebEnvironment.get().reopenUi();
+    try {
+      hopGui.dismissWelcomeDialog();
+    } catch (RuntimeException e) {
+      // Cleanup, not a test: whatever is still on screen is the next test's 
to report.
+      System.out.println("Could not clear the reloaded GUI: " + e);
+    }
+  }
+
+  @Test
+  @DisplayName("what one session builds is invisible to the other")
+  void sessionsDoNotShareTheirGui() {
+    WebDriver other = HopWebEnvironment.get().openAnotherBrowser();
+    try {
+      HopGuiPage otherGui = new HopGuiPage(other, TIMEOUT);
+      otherGui.dismissWelcomeDialog();
+
+      PipelineGraphPage mine = hopGui.newPipeline();
+      mine.addTransform(MINE);
+      PipelineGraphPage theirs = otherGui.newPipeline();
+      theirs.addTransform(THEIRS);
+
+      assertTrue(mine.contains(MINE), () -> "this session lost its own work: " 
+ mine.labels());
+      assertFalse(
+          mine.contains(THEIRS), () -> "this session sees the other one's 
work: " + mine.labels());
+      assertTrue(
+          theirs.contains(THEIRS), () -> "the other session lost its work: " + 
theirs.labels());
+      assertFalse(
+          theirs.contains(MINE),
+          () -> "the other session sees this one's work: " + theirs.labels());
+      assertEquals(
+          List.of(), BrowserConsole.errors(other), "the second session's 
browser reported errors");
+    } finally {
+      HopWebEnvironment.get().closeBrowser(other);
+    }
+  }
+
+  @Test
+  @DisplayName("a session going away leaves the other one working")
+  void oneSessionEndingDoesNotBreakTheOther() {
+    WebDriver other = HopWebEnvironment.get().openAnotherBrowser();
+    HopGuiPage otherGui = new HopGuiPage(other, TIMEOUT);
+    otherGui.dismissWelcomeDialog();
+    PipelineGraphPage theirs = otherGui.newPipeline();
+    theirs.addTransform(THEIRS);
+    // Opening a dialog is what puts the shared, session scoped resources - 
images above all - to
+    // work, so the session that is about to end has really used them.
+    theirs.actOnTransform(THEIRS, "Edit", 0, 0);
+    otherGui.awaitDialog();
+    otherGui.closeAllDialogs();
+
+    HopWebEnvironment.get().closeBrowser(other);
+
+    // Everything the departed session touched, done again here. If its images 
went with it, this
+    // is where it shows - as "Argument not valid" in the server log, which 
the base class fails on.
+    PipelineGraphPage mine = hopGui.newPipeline();
+    mine.addTransform(MINE);
+    mine.actOnTransform(MINE, "Edit", 0, 0);
+    assertEquals(MINE, hopGui.topDialogTitle(), "the dialog did not open after 
the other session");
+    hopGui.closeTopDialog();
+    assertTrue(mine.contains(MINE), () -> "the graph stopped drawing: " + 
mine.labels());
+  }
+}
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
index b545210df6..ca113313fb 100644
--- 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
@@ -21,11 +21,13 @@ 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.Assumptions.assumeTrue;
 
 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;
+import org.openqa.selenium.JavascriptExecutor;
 
 /**
  * The daily signal: can Hop Web still be started, opened, and used to build a 
pipeline?
@@ -47,6 +49,21 @@ class HopWebSmokeTest extends HopWebTestBase {
     assertTrue(hopGui.openDialogTitles().isEmpty(), "no dialog should be 
blocking the GUI");
   }
 
+  @Test
+  @DisplayName("a browser side failure would be noticed")
+  void watchesTheBrowserConsole() {
+    // Half of what breaks in Hop Web breaks in the browser and is never 
mentioned server side, so
+    // every test reads the console afterwards. A check nobody can see working 
is a check that
+    // quietly stops working, hence this: provoke one error and confirm the 
reader sees it.
+    assumeTrue(BrowserConsole.isSupported(driver), "this browser does not hand 
over its console");
+
+    ((JavascriptExecutor) driver).executeScript("console.error('hop web 
selenium self check');");
+
+    assertTrue(
+        BrowserConsole.errors(driver).stream().anyMatch(e -> e.contains("self 
check")),
+        "the browser console is not being read, so no test can fail on what 
happens in it");
+  }
+
   @Test
   @DisplayName("a new pipeline opens on an empty canvas")
   void createsAPipeline() {
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
index d896c6e334..d98ac801e3 100644
--- 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
@@ -60,25 +60,58 @@ public abstract class HopWebTestBase {
   private int serverLogMark;
 
   @BeforeEach
-  void markServerLog() {
+  void markLogs() {
     serverLogMark = HopWebEnvironment.get().serverLog().length();
+    BrowserConsole.drain(driver);
   }
 
   /**
-   * Fails the test if Hop Web crashed while it ran, even though the browser 
looked fine.
+   * Fails the test if either side of Hop Web reported a failure while it ran, 
even though the page
+   * 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.
+   * <p>Plenty goes wrong without reaching what a test asserts on: a dialog 
can open perfectly while
+   * the thread it started to populate its widgets dies, and the browser can 
fail to apply what the
+   * server sent without the server ever hearing about it. Asserting only on 
what is visible
+   * declares both green.
    */
   @AfterEach
-  void failOnServerCrashes() {
+  void failOnCrashes() {
+    List<String> crashes = serverCrashes();
+    List<String> browserErrors = BrowserConsole.errors(driver);
+    String errorDialog = openErrorDialog();
+    assertTrue(
+        crashes.isEmpty() && browserErrors.isEmpty() && errorDialog == null,
+        () ->
+            "Hop Web reported failures during this test:"
+                + (crashes.isEmpty() ? "" : "\n  server: " + String.join("\n   
       ", crashes))
+                + (browserErrors.isEmpty()
+                    ? ""
+                    : "\n  browser: " + String.join("\n           ", 
browserErrors))
+                + (errorDialog == null ? "" : "\n  dialog: " + errorDialog));
+  }
+
+  /**
+   * What Hop is reporting in an error dialog, if it put one on screen.
+   *
+   * <p>The third place a failure can surface, and the one neither log sees: 
Hop catches the
+   * exception, tells the user about it in a dialog and writes nothing 
anywhere. Without this a test
+   * only notices when the modal dialog blocks whatever it does next - which 
is usually the next
+   * test, so the failure gets reported against innocent code.
+   */
+  private String openErrorDialog() {
+    if (!hopGui.openDialogTitles().contains(HopGuiPage.ERROR_DIALOG)) {
+      return null;
+    }
+    String text = hopGui.topDialogText().replace("\n", " ");
+    return text.length() > 500 ? text.substring(0, 500) + "..." : text;
+  }
+
+  private List<String> serverCrashes() {
     String log = HopWebEnvironment.get().serverLog();
     if (log.length() <= serverLogMark) {
-      return;
+      return List.of();
     }
-    List<String> crashes = ServerLog.crashes(log.substring(serverLogMark));
-    assertTrue(crashes.isEmpty(), () -> "Hop Web crashed during this test: " + 
crashes);
+    return ServerLog.crashes(log.substring(serverLogMark));
   }
 
   @AfterEach
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
index ffbf8a8d6f..95f2bb2517 100644
--- 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
@@ -18,7 +18,9 @@
 package org.apache.hop.web.it;
 
 import java.util.ArrayList;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Finds crashes in what Hop Web logged.
@@ -39,22 +41,60 @@ public final class ServerLog {
    */
   private static final String UNCAUGHT = "Exception in thread ";
 
+  /**
+   * Failures that are a Hop Web bug whether or not somebody caught them.
+   *
+   * <p>Watching only for uncaught exceptions misses most of this class of 
bug: Hop wraps nearly
+   * every UI callback in a {@code try/catch} that logs and carries on, and 
RAP's own life cycle
+   * catches what escapes that. The three signatures below have no benign 
reading in a running Hop
+   * Web, so they can be treated as failures wherever they appear in the log:
+   *
+   * <ul>
+   *   <li><b>Invalid thread access</b> - a background thread touched a widget 
or a session scoped
+   *       singleton (issues #8195, #7896). The fat client tolerates far more 
of this than RAP does.
+   *   <li><b>Widget is disposed</b> - a widget used after its shell, or its 
whole session, went
+   *       away; in Hop Web that is usually state that outlived the session it 
belongs to.
+   *   <li><b>Argument not valid</b> - SWT's complaint about, among other 
things, an image disposed
+   *       by somebody else, which is what a {@code static} cache of session 
scoped resources
+   *       produces once a session times out (issue #3508).
+   * </ul>
+   */
+  private static final List<String> ALWAYS_A_BUG =
+      List.of("Invalid thread access", "Widget is disposed", "Argument not 
valid");
+
   /** 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. */
+  /**
+   * One entry per distinct crash, each naming the Hop code that started it.
+   *
+   * <p>Distinct rather than one per occurrence: a broken repaint logs the 
same failure on every
+   * paint, and a hundred copies of one line say no more than the line does.
+   */
   public static List<String> crashes(String log) {
-    List<String> crashes = new ArrayList<>();
+    Set<String> crashes = new LinkedHashSet<>();
     String[] lines = log.split("\n");
     for (int i = 0; i < lines.length; i++) {
-      if (!lines[i].startsWith(UNCAUGHT)) {
-        continue;
+      if (isCrash(lines[i])) {
+        crashes.add(lines[i].trim() + culprit(lines, i));
       }
-      crashes.add(lines[i].trim() + culprit(lines, i));
     }
-    return crashes;
+    return new ArrayList<>(crashes);
+  }
+
+  private static boolean isCrash(String line) {
+    if (line.startsWith(UNCAUGHT)) {
+      return true;
+    }
+    // Not on continuation lines: the signature appears again in every "Caused 
by" and in the
+    // message RAP re-throws, and each repeat would be reported as a crash of 
its own.
+    String stripped = line.stripLeading();
+    if (line.startsWith("\t") || stripped.startsWith("at ") || 
stripped.startsWith("Caused by")) {
+      return false;
+    }
+    return ALWAYS_A_BUG.stream().anyMatch(line::contains);
   }
 
   /**
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
index c6507f741e..34ecf3108a 100644
--- 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
@@ -53,6 +53,55 @@ class ServerLogTest {
     assertTrue(crashes.get(0).contains("CheckSumDialog.setComboBoxes"), 
crashes.get(0));
   }
 
+  /** Verbatim from Hop Web painting a graph with an image another session had 
disposed. */
+  private static final String DISPOSED_IMAGE_CRASH =
+      """
+      SEVERE [qtp-1] org.eclipse.rap.rwt.internal.lifecycle.UIThread 
java.lang.IllegalArgumentException: Argument not valid
+      \tat org.eclipse.swt.SWT.error(SWT.java:4527)
+      \tat org.eclipse.swt.graphics.GC.drawImage(GC.java:1234)
+      \tat org.apache.hop.ui.hopgui.shared.SwtGc.drawImage(SwtGc.java:212)
+      \tat 
org.eclipse.rap.rwt.internal.lifecycle.UIThread.run(UIThread.java:104)
+      """;
+
+  @Test
+  @DisplayName("a failure Hop caught and logged is a crash too")
+  void reportsLoggedFailures() {
+    List<String> crashes = ServerLog.crashes(DISPOSED_IMAGE_CRASH);
+
+    assertEquals(1, crashes.size(), () -> "expected one crash, got " + 
crashes);
+    assertTrue(crashes.get(0).contains("Argument not valid"), crashes.get(0));
+    assertTrue(crashes.get(0).contains("SwtGc.drawImage"), crashes.get(0));
+  }
+
+  @Test
+  @DisplayName("a widget used after its session went away is a crash")
+  void reportsDisposedWidgets() {
+    String log =
+        "ERROR: org.eclipse.swt.SWTException: Widget is disposed\n"
+            + "\tat 
org.apache.hop.ui.hopgui.HopGui.handleFileCapabilities(HopGui.java:900)\n";
+
+    assertEquals(1, ServerLog.crashes(log).size());
+  }
+
+  @Test
+  @DisplayName("the same failure logged over and over is reported once")
+  void collapsesRepeats() {
+    // A broken repaint logs on every paint; a hundred identical lines say no 
more than one.
+    assertEquals(1, ServerLog.crashes(DISPOSED_IMAGE_CRASH.repeat(20)).size());
+  }
+
+  @Test
+  @DisplayName("the cause of a reported failure is not counted a second time")
+  void ignoresCausedBy() {
+    String log =
+        "ERROR org.eclipse.swt.SWTException: Invalid thread access\n"
+            + "\tat 
org.apache.hop.ui.core.gui.GuiResource.getImage(GuiResource.java:1)\n"
+            + "Caused by: java.lang.IllegalStateException: Invalid thread 
access\n"
+            + "\tat org.eclipse.rap.rwt.RWT.checkContext(RWT.java:765)\n";
+
+    assertEquals(1, ServerLog.crashes(log).size());
+  }
+
   @Test
   @DisplayName("handled problems Hop Web logs all the time are not failures")
   void ignoresHandledProblems() {
@@ -63,6 +112,8 @@ class ServerLogTest {
         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
+        INFO Disposing the image cache of a session that timed out
+        \tat org.apache.hop.pipeline.Pipeline.run: Invalid thread access 
recovered
         """;
 
     assertEquals(List.of(), ServerLog.crashes(noise));
@@ -76,8 +127,8 @@ class ServerLogTest {
   }
 
   @Test
-  @DisplayName("several crashes are reported separately")
+  @DisplayName("different crashes are reported separately")
   void reportsEachCrash() {
-    assertEquals(2, ServerLog.crashes(CHECKSUM_CRASH + CHECKSUM_CRASH).size());
+    assertEquals(2, ServerLog.crashes(CHECKSUM_CRASH + 
DISPOSED_IMAGE_CRASH).size());
   }
 }
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/pages/ExecutionResultsPanel.java
 
b/web-tests/src/test/java/org/apache/hop/web/it/pages/ExecutionResultsPanel.java
new file mode 100644
index 0000000000..754f7a74b1
--- /dev/null
+++ 
b/web-tests/src/test/java/org/apache/hop/web/it/pages/ExecutionResultsPanel.java
@@ -0,0 +1,118 @@
+/*
+ * 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.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.WebDriver;
+
+/**
+ * The panel that appears under a graph once it has been run: Logging, Metrics 
and Problems.
+ *
+ * <p>Only the metrics table is read. The logging tab is a StyledText, which 
RAP does not paint as
+ * text a browser can read back, so a test cannot see what it says; the 
metrics table is an ordinary
+ * table and says the same thing more precisely anyway - how many rows each 
transform actually moved
+ * and whether it finished.
+ */
+public class ExecutionResultsPanel {
+
+  /** The metrics table has fifteen columns; no other table in the panel comes 
close. */
+  private static final int MIN_COLUMNS = 10;
+
+  /**
+   * The metrics table, as headers and rows of cells.
+   *
+   * <p>Found by its own column names rather than by position, and taken from 
the innermost element
+   * that has both those names and rows under it - the table is nested several 
containers deep and
+   * every one of them "contains" the header text.
+   *
+   * <p>A row is recognised by having exactly two children per column: a table 
row lays out as an
+   * alternating run of spacer and cell elements, which is also why only the 
odd children are read.
+   * The cells cannot simply be filtered on having text, because a metric that 
is genuinely empty
+   * would drop out and shift every column after it.
+   */
+  private static final String METRICS =
+      "const candidates=[...document.querySelectorAll('div')].filter("
+          + "d=>d.textContent.includes('Transform 
Name')&&d.textContent.includes('Written (rows)'));"
+          + "for(let i=candidates.length-1;i>=0;i--){"
+          + "const table=candidates[i];"
+          + "const 
head=[...table.children].find(c=>c.textContent.includes('Transform Name'));"
+          + "if(!head)continue;"
+          + "const 
headers=[...head.children].map(c=>c.textContent.trim()).filter(t=>t.length>0);"
+          + "if(headers.length<"
+          + MIN_COLUMNS
+          + ")continue;"
+          + "const rows=[...table.querySelectorAll('div')].filter(d=>"
+          + 
"d.children.length===headers.length*2&&[...d.children].every(c=>c.children.length===0));"
+          + "if(rows.length===0)continue;"
+          + "return {headers:headers,"
+          + 
"rows:rows.map(r=>[...r.children].filter((c,n)=>n%2===1).map(c=>c.textContent.trim()))};}"
+          + "return {headers:[],rows:[]};";
+
+  private final WebDriver driver;
+  private final HopGuiPage hopGui;
+
+  ExecutionResultsPanel(WebDriver driver, HopGuiPage hopGui) {
+    this.driver = driver;
+    this.hopGui = hopGui;
+  }
+
+  /** Brings one of the panel's tabs to the front. */
+  public ExecutionResultsPanel selectTab(String name) {
+    hopGui.clickButton(name);
+    return this;
+  }
+
+  /**
+   * One entry per transform, each column of the metrics table keyed by its 
header.
+   *
+   * <p>Empty until a run has actually produced metrics, which is what callers 
wait on.
+   */
+  public List<Map<String, String>> metrics() {
+    @SuppressWarnings("unchecked")
+    Map<String, Object> table =
+        (Map<String, Object>) ((JavascriptExecutor) 
driver).executeScript(METRICS);
+    @SuppressWarnings("unchecked")
+    List<String> headers = (List<String>) table.get("headers");
+    @SuppressWarnings("unchecked")
+    List<List<String>> rows = (List<List<String>>) table.get("rows");
+    return rows.stream().map(row -> row(headers, row)).toList();
+  }
+
+  private static Map<String, String> row(List<String> headers, List<String> 
cells) {
+    if (headers.size() != cells.size()) {
+      throw new IllegalStateException(
+          "The metrics table has " + headers.size() + " columns but a row of " 
+ cells.size());
+    }
+    Map<String, String> row = new LinkedHashMap<>();
+    for (int i = 0; i < headers.size(); i++) {
+      row.put(headers.get(i), cells.get(i));
+    }
+    return row;
+  }
+
+  /** The metrics of one transform, or null while it has none. */
+  public Map<String, String> metricsOf(String transformName) {
+    return metrics().stream()
+        .filter(row -> transformName.equals(row.get("Transform Name")))
+        .findFirst()
+        .orElse(null);
+  }
+}
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
index b46e39896b..1d8e6fbb9c 100644
--- 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
@@ -67,22 +67,72 @@ public class HopGuiPage {
   }
 
   /**
-   * 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}.
+   * Locates a widget by the name Hop Web gives it in the DOM, which for 
anything declared through a
+   * {@code GuiToolbarElement} is that annotation's id - {@code 
toolbar-10010-new}, {@code
+   * HopGuiPipelineGraph-ToolBar-10010-Run}, {@code 
ExplorerPerspective-Toolbar-10300-Refresh}.
    *
-   * <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.
+   * <p>Hop puts these on the widget itself (see {@code TestIdFacade}), so the 
match is the element
+   * that takes the click. Before that these tests went by the id RAP renders 
inside an icon's
+   * markup, which is only present on widgets that draw an SVG, is prefixed 
with a per-session UUID
+   * so it could only be matched on its suffix, and sits two levels below the 
widget that listens.
+   *
+   * <p>The id is not unique: every open file has a graph toolbar carrying the 
same ids, so callers
+   * take the visible match.
    */
+  public static By testId(String hopId) {
+    return By.cssSelector("[data-hop-id='" + hopId + "']");
+  }
+
+  /** Same thing under the name the tests used before ids reached the widgets 
themselves. */
   public static By guiElement(String guiElementId) {
-    return By.cssSelector("[id$='-" + guiElementId + "']");
+    return testId(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");
+  public static final By NEW_FILE = testId("toolbar-10010-new");
+  public static final By OPEN_FILE = testId("toolbar-10020-open");
+  public static final By SAVE_FILE = testId("toolbar-10040-save");
+  public static final By SAVE_FILE_AS = testId("toolbar-10050-save-as");
+
+  /** The project shown in the bottom toolbar, which is also the button that 
changes it. */
+  public static final By PROJECT = testId("toolbar-item-10000-project");
+
+  /** The environment shown next to it. */
+  public static final By ENVIRONMENT = 
testId("toolbar-item-20000-environment");
+
+  /**
+   * The text fields of the dialog on top, in the order the dialog lays them 
out.
+   *
+   * <p>By position rather than by name, because a RAP text field carries 
neither: {@code
+   * GuiToolbarWidgets} only puts ids on the widgets declared through the GUI 
element annotations,
+   * and a dialog builds its fields in plain SWT code.
+   */
+  private static final String TOP_INPUTS =
+      "const shells=[...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=shells[shells.length-1]||document;"
+          + "return [...top.querySelectorAll('input')]"
+          + ".filter(i=>i.type==='text'&&i.offsetParent!==null);";
+
+  /** The title Hop gives the dialog it reports a failure in. */
+  public static final String ERROR_DIALOG = "Error";
+
+  /**
+   * Turns "an error dialog is in the way" into a failure that says so.
+   *
+   * <p>Hop reports a failure in a modal dialog, and everything a test tries 
afterwards - clicking
+   * the canvas, opening the context dialog - simply does not happen, so the 
test dies of a timeout
+   * naming whatever it happened to be waiting for. The error itself is on 
screen the whole time.
+   */
+  public static void failIfErrorDialog(WebDriver driver) {
+    if (!openDialogTitles(driver).contains(ERROR_DIALOG)) {
+      return;
+    }
+    Object text = ((JavascriptExecutor) driver).executeScript(TOP_SHELL_TEXT);
+    throw new AssertionError("Hop Web is showing an error dialog: " + text);
+  }
 
   /** Titles of the dialogs currently open on top of the Hop GUI, innermost 
last. */
   public static List<String> openDialogTitles(WebDriver driver) {
@@ -105,17 +155,50 @@ public class HopGuiPage {
     return topDialogTitle(driver);
   }
 
+  /** Everything the dialog on top has written on it, or empty when there is 
no dialog. */
+  public String topDialogText() {
+    Object text = ((JavascriptExecutor) driver).executeScript(TOP_SHELL_TEXT);
+    return text == null ? "" : text.toString();
+  }
+
+  private static final String TOP_SHELL_TEXT =
+      "const shells=[...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=shells[shells.length-1];"
+          + "return top?top.innerText:'';";
+
   /**
    * 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()) {
+    if (openDialogTitles().isEmpty()) {
+      return;
+    }
+    try {
       closeTopDialog();
+      return;
+    } catch (RuntimeException e) {
+      // Not everything that looks like a dialog is one. A freshly loaded Hop 
Web puts a loading
+      // splash on top (issue #8182) that has no button to press and goes away 
by itself, so this
+      // waits it out rather than failing - and if something else really is 
stuck there, the test
+      // that then cannot do its work says far more about it than a failure in 
setup would.
+      System.out.println("Could not close " + topDialogTitle() + ", waiting 
for it to go: " + e);
+    }
+    try {
+      waitFor(driver, SPLASH_GRACE).until(d -> openDialogTitles().isEmpty());
+    } catch (RuntimeException e) {
+      System.out.println("Still on screen: " + openDialogTitles());
     }
   }
 
+  /** How long a loading splash may still be up after the toolbar has 
appeared. */
+  private static final Duration SPLASH_GRACE = Duration.ofSeconds(5);
+
   /**
    * Waits for a dialog to appear and returns its title.
    *
@@ -131,10 +214,12 @@ public class HopGuiPage {
   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.
+   * Buttons that dismiss a dialog without applying anything. Not "OK" first: 
some of these dialogs
+   * are the real transform dialog and confirming would change the pipeline. 
"OK" is the last
+   * resort, because a message dialog - an error Hop is reporting, above all - 
has nothing else, and
+   * one of those left standing is modal: it blocks every test that comes 
after it.
    */
-  private static final List<String> DISMISS_BUTTONS = List.of("Cancel", 
"Close");
+  private static final List<String> DISMISS_BUTTONS = List.of("Cancel", 
"Close", "OK");
 
   /**
    * Closes one dialog, identified by title, and waits until it is really gone.
@@ -180,10 +265,29 @@ public class HopGuiPage {
     }
   }
 
+  /**
+   * Whether an element is really on screen.
+   *
+   * <p>{@code isDisplayed()} is not enough once more than one file is open: 
every tab keeps its
+   * whole widget tree in the document, so a label like "Metrics" exists once 
per tab and all but
+   * one of them are drawn nowhere. Clicking one of those fails with "has no 
size and location",
+   * which is the browser saying exactly this.
+   */
+  private boolean isOnScreen(WebElement element) {
+    Object onScreen =
+        ((JavascriptExecutor) driver)
+            .executeScript(
+                "const r=arguments[0].getBoundingClientRect();"
+                    + "return r.width>0&&r.height>0&&r.bottom>0&&r.right>0"
+                    + "&&r.top<window.innerHeight&&r.left<window.innerWidth;",
+                element);
+    return Boolean.TRUE.equals(onScreen);
+  }
+
   /** 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)
+        .filter(this::isOnScreen)
         .findFirst()
         .map(
             element -> {
@@ -202,6 +306,120 @@ public class HopGuiPage {
     closeDialog(title);
   }
 
+  /** The text fields of the dialog on top, in layout order. */
+  public List<WebElement> dialogInputs() {
+    @SuppressWarnings("unchecked")
+    List<WebElement> inputs =
+        (List<WebElement>) ((JavascriptExecutor) 
driver).executeScript(TOP_INPUTS);
+    return inputs;
+  }
+
+  /**
+   * Replaces what a text field contains.
+   *
+   * <p>Selects with a triple click rather than a select-all chord, which 
would have to be Control
+   * on Linux and Command on macOS - and the platform that decides is the 
browser's, not the one
+   * running the tests, so a containerised browser on a Mac would need the 
Linux one.
+   *
+   * <p>Not {@code clear()} either: that empties the DOM element without the 
RAP client noticing, so
+   * the server keeps the old text and the field silently reverts.
+   */
+  public void enterText(WebElement input, String text) {
+    new Actions(driver).moveToElement(input).click().click().click().perform();
+    input.sendKeys(text);
+    wait.until(d -> text.equals(input.getDomProperty("value")));
+  }
+
+  /** Replaces what the n-th text field of the dialog on top contains. */
+  public void enterDialogText(int index, String text) {
+    enterText(dialogInputs().get(index), text);
+  }
+
+  /**
+   * Types into the field a dialog puts next to a given label.
+   *
+   * <p>Hop lays its dialogs out as a column of labels with their fields to 
the right, and neither
+   * carries anything a test could match on, so the field is found by where it 
is drawn: the text
+   * input on the same line as the label, to the right of it.
+   */
+  public void enterDialogField(String label, String text) {
+    WebElement field =
+        (WebElement) ((JavascriptExecutor) 
driver).executeScript(FIELD_BESIDE_LABEL, label);
+    if (field == null) {
+      throw new AssertionError(
+          "The dialog '" + topDialogTitle() + "' has no field next to a label 
'" + label + "'");
+    }
+    enterText(field, text);
+  }
+
+  private static final String FIELD_BESIDE_LABEL =
+      "const wanted=arguments[0];"
+          + "const shells=[...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=shells[shells.length-1];"
+          + "if(!top)return null;"
+          + "const label=[...top.querySelectorAll('div')].find("
+          + "d=>d.children.length===0&&d.textContent.trim()===wanted);"
+          + "if(!label)return null;"
+          + "const lr=label.getBoundingClientRect();"
+          + "const line=lr.y+lr.height/2;"
+          + "let best=null,bestX=Infinity;"
+          + "[...top.querySelectorAll('input')].forEach(i=>{"
+          + "if(i.type!=='text'||i.offsetParent===null)return;"
+          + "const r=i.getBoundingClientRect();"
+          + "if(Math.abs(r.y+r.height/2-line)>12||r.x<lr.x)return;"
+          + "if(r.x<bestX){bestX=r.x;best=i;}});"
+          + "return best;";
+
+  /** Clicks a button by its label, waiting for it to be there. */
+  public void clickButton(String label) {
+    click(visibleByLabel(label));
+  }
+
+  /**
+   * Opens a file through Hop Web's own file dialog.
+   *
+   * <p>Web has no native file dialog to fall back on, so this is Hop's {@code 
HopVfsFileDialog} -
+   * an entirely different implementation from the one the fat client uses on 
the same button, and
+   * one that only these tests ever exercise.
+   */
+  public void openFile(String path) {
+    clickWidget(OPEN_FILE);
+    awaitDialog();
+    enterDialogText(0, path);
+    clickButton("Open");
+    awaitNoDialog();
+  }
+
+  /** Saves the active file under a new name, through that same file dialog. */
+  public void saveFileAs(String path) {
+    clickWidget(SAVE_FILE_AS);
+    awaitDialog();
+    enterDialogText(0, path);
+    clickButton("Save");
+    // Saving over a file that is already there asks first.
+    if ("Warning".equals(topDialogTitle())) {
+      clickButton("Yes");
+    }
+    awaitNoDialog();
+  }
+
+  /** Waits until nothing is stacked on top of the Hop GUI any more. */
+  public void awaitNoDialog() {
+    wait.until(d -> openDialogTitles().isEmpty());
+  }
+
+  /** Opens a pipeline file and returns its graph once the transform named is 
on screen. */
+  public PipelineGraphPage openPipeline(String path, String transformOnIt) {
+    openFile(path);
+    PipelineGraphPage graph = new PipelineGraphPage(driver, wait);
+    graph.awaitLabel(transformOnIt);
+    return graph;
+  }
+
   /** Creates a new pipeline and returns its graph, ready to be edited. */
   public PipelineGraphPage newPipeline() {
     clickWidget(NEW_FILE);
@@ -211,13 +429,75 @@ public class HopGuiPage {
     return graph;
   }
 
+  /** Clicks a widget, waiting for it to be the one on screen. */
+  public void clickWidget(By locator) {
+    click(visible(locator));
+  }
+
   /**
-   * 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.
+   * The first match that is actually on screen.
+   *
+   * <p>The first match in the DOM is not necessarily it: every open file has 
a graph toolbar of its
+   * own and all of them carry the same ids, and a perspective that is not on 
top keeps its widgets
+   * around rather than disposing them.
    */
-  public void clickWidget(By locator) {
-    WebElement image = wait.until(d -> d.findElement(locator));
-    click(image.findElement(By.xpath("./../..")));
+  public WebElement visible(By locator) {
+    WebElement element =
+        wait.until(
+            d ->
+                
d.findElements(locator).stream().filter(this::isOnScreen).findFirst().orElse(null));
+    if (element == null) {
+      throw new NoSuchElementException("Nothing visible matches " + locator);
+    }
+    return element;
+  }
+
+  /** Whether anything matching this locator is on screen right now. */
+  public boolean isVisible(By locator) {
+    return driver.findElements(locator).stream().anyMatch(this::isOnScreen);
+  }
+
+  /**
+   * Switches to a perspective by its plugin id - {@code 
explorer-perspective}, {@code
+   * metadata-perspective}, {@code execution-perspective}, {@code 
configuration}.
+   *
+   * <p>Returns once that perspective's own content is the one on screen 
rather than once the button
+   * has been clicked: the sidebar buttons are icons that all look alike, and 
Hop swaps the
+   * perspectives by moving one control of a stack to the top, so the content 
is what says which
+   * perspective actually won.
+   */
+  public void switchToPerspective(String perspectiveId) {
+    clickWidget(testId(PERSPECTIVE_PREFIX + perspectiveId));
+    wait.until(d -> isVisible(perspectiveContent(perspectiveId)));
+  }
+
+  /** The perspective on screen, by plugin id, or null while none of the known 
ones is up. */
+  public String activePerspective() {
+    List<WebElement> contents =
+        driver.findElements(By.cssSelector("[data-hop-id^='" + 
PERSPECTIVE_CONTENT_PREFIX + "']"));
+    return contents.stream()
+        .filter(this::isOnScreen)
+        .map(e -> 
e.getAttribute("data-hop-id").substring(PERSPECTIVE_CONTENT_PREFIX.length()))
+        .findFirst()
+        .orElse(null);
+  }
+
+  private static final String PERSPECTIVE_PREFIX = "perspective-";
+
+  private static final String PERSPECTIVE_CONTENT_PREFIX = 
"perspective-content-";
+
+  private static By perspectiveContent(String perspectiveId) {
+    return testId(PERSPECTIVE_CONTENT_PREFIX + perspectiveId);
+  }
+
+  /** The project the GUI says it is working in. */
+  public String projectName() {
+    return visible(PROJECT).getText().trim();
+  }
+
+  /** The environment the GUI says it is working in, empty when none is 
chosen. */
+  public String environmentName() {
+    return visible(ENVIRONMENT).getText().trim();
   }
 
   /**
@@ -225,7 +505,7 @@ public class HopGuiPage {
    * its tab.
    */
   public boolean hasTab(String title) {
-    return 
driver.findElements(labelled(title)).stream().anyMatch(WebElement::isDisplayed);
+    return 
driver.findElements(labelled(title)).stream().anyMatch(this::isOnScreen);
   }
 
   /** Clicks an entry in an open menu by its label. */
@@ -241,7 +521,7 @@ public class HopGuiPage {
     return wait.until(
         d ->
             d.findElements(parentOfLabelled(label)).stream()
-                .filter(WebElement::isDisplayed)
+                .filter(this::isOnScreen)
                 .findFirst()
                 .orElse(null));
   }
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
index 8d93c8b8f2..33cfaa0eb2 100644
--- 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
@@ -19,6 +19,9 @@ package org.apache.hop.web.it.pages;
 
 import java.time.Duration;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.openqa.selenium.By;
 import org.openqa.selenium.JavascriptExecutor;
 import org.openqa.selenium.TimeoutException;
 import org.openqa.selenium.WebDriver;
@@ -29,35 +32,64 @@ 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.
+ * <p>The graph is drawn on a {@code <canvas>}, so there is nothing to click 
by name. Hop Web lays
+ * an SVG the server rendered over that canvas and publishes, on the same 
element, the model behind
+ * that picture: the area owners, which say where each transform was drawn and 
what it is called
+ * (see {@code canvas-svg.js}). This class reads that model and clicks at the 
coordinates it gives.
+ *
+ * <p>Reading the SVG instead does not work. Its {@code <text>} nodes are 
whatever the drawing
+ * happens to contain, and a dozen transform icons spell something themselves 
- "AWSSNS", "csv",
+ * "AXO", "&lt;/&gt;" - so an icon is indistinguishable from a transform name. 
Every one of those
+ * transforms failed the dialog sweep for that reason alone.
  */
 public class PipelineGraphPage {
 
-  /** The graph canvas is the only large visible canvas; every other one is a 
sash or a header. */
+  /**
+   * The overlay Hop Web draws the graph on, and hangs the graph model off.
+   *
+   * <p>One per session rather than one per tab: the client keeps a single 
renderer and moves its
+   * overlay to whichever canvas is on top, so there is nothing to 
disambiguate.
+   */
+  private static final String OVERLAY =
+      "const 
overlay=[...document.querySelectorAll('[data-hop-canvas=\"graph\"]')]"
+          + ".find(e=>e.offsetParent!==null)"
+          + "||document.querySelector('[data-hop-canvas=\"graph\"]');";
+
+  /** Every transform on the graph, with the rectangle it is drawn in right 
now. */
+  private static final String NODES =
+      OVERLAY + "return (overlay&&overlay.hopGraph)? overlay.hopGraph.nodes() 
: [];";
+
+  /**
+   * The graph canvas of the tab on screen.
+   *
+   * <p>Taken from the widget Hop names rather than by size: several tabs have 
a canvas of exactly
+   * the same size in exactly the same place, and a perspective that is not on 
top keeps its own.
+   */
   private static final String GRAPH_CANVAS =
-      "return [...document.querySelectorAll('canvas')].find(c=>{"
+      "const 
widget=[...document.querySelectorAll('[data-hop-id=\"pipeline-graph-canvas\"]')]"
+          + ".find(e=>e.offsetParent!==null);"
+          + "if(widget){const 
inner=widget.querySelector('canvas');if(inner)return inner;}"
+          + "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.
+   * Where a transform sits relative to the middle of the canvas, which is how 
a click is addressed,
+   * plus how big its icon is drawn.
    *
-   * <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.
+   * <p>The model gives viewport coordinates, so the canvas it has to be 
measured against is read at
+   * the same moment: a scrolled or resized window moves one without moving 
the other.
    */
-  private static List<String> emptyLabels;
+  private static final String TRANSFORM_AT =
+      OVERLAY
+          + "if(!overlay||!overlay.hopGraph)return null;"
+          + "const name=arguments[0];const canvas=arguments[1];"
+          + "const node=overlay.hopGraph.nodes().find(n=>n.name===name);"
+          + "if(!node)return null;"
+          + "const r=canvas.getBoundingClientRect();"
+          + "return [Math.round(node.viewportX-(r.x+r.width/2)),"
+          + "Math.round(node.viewportY-(r.y+r.height/2)),"
+          + "Math.round(node.width)];";
 
   private final WebDriver driver;
   private final WebDriverWait wait;
@@ -68,34 +100,51 @@ public class PipelineGraphPage {
   }
 
   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());
-    }
+    wait.until(d -> canvas() != null && hasGraphModel());
+    // Also the wait for the new tab to actually be the one on screen.
+    wait.until(d -> isEmpty());
+  }
+
+  /** Waits until the graph on screen is the one holding this transform. */
+  void awaitLabel(String label) {
+    wait.until(d -> canvas() != null && contains(label));
+  }
+
+  /** Whether the client has published a graph yet, which it does on the first 
paint. */
+  private boolean hasGraphModel() {
+    return Boolean.TRUE.equals(
+        ((JavascriptExecutor) driver)
+            .executeScript(OVERLAY + "return !!(overlay&&overlay.hopGraph);"));
   }
 
-  /** Whether the graph holds no transforms, as opposed to holding no labels 
at all. */
+  /** Whether the graph holds no transforms. */
   public boolean isEmpty() {
-    return labels().equals(emptyLabels);
+    return transformNames().isEmpty();
   }
 
   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() {
+  /** The transforms on the graph, named as the pipeline names them. */
+  public List<String> transformNames() {
     @SuppressWarnings("unchecked")
-    List<String> texts = (List<String>) ((JavascriptExecutor) 
driver).executeScript(GRAPH_TEXTS);
-    return texts;
+    List<Map<String, Object>> nodes =
+        (List<Map<String, Object>>) ((JavascriptExecutor) 
driver).executeScript(NODES);
+    return nodes.stream().map(node -> 
String.valueOf(node.get("name"))).toList();
+  }
+
+  /** What the graph holds, under the name the tests have always called it. */
+  public List<String> labels() {
+    return transformNames();
   }
 
   public boolean contains(String label) {
-    return labels().contains(label);
+    return transformNames().contains(label);
+  }
+
+  private long count(String transformName) {
+    return transformNames().stream().filter(transformName::equals).count();
   }
 
   /** How many entries after the highlight to try before giving up on a name. 
*/
@@ -105,22 +154,26 @@ public class PipelineGraphPage {
    * 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.
+   * dialog does not always rank an exact name match first - see {@link 
ContextDialog#choose(String,
+   * int)}. So this waits for the transform it asked for, and if something 
else turned up instead,
+   * removes it and takes the next entry.
    */
   public void addTransform(String transformName, int offsetX, int offsetY) {
+    // How many of these the graph held before, rather than whether it held 
any: a transform is not
+    // always the first thing added to a pipeline.
+    long before = count(transformName);
+    List<String> namesBefore = transformNames();
     for (int candidate = 0; candidate < MAX_CANDIDATES; candidate++) {
       clickAt(offsetX, offsetY);
       contextDialog().choose(transformName, candidate);
 
-      String added = awaitAdded();
-      if (transformName.equals(added)) {
+      if (awaitAdded(transformName, before)) {
         return;
       }
-      if (added != null) {
-        actOnTransform(added, "Delete", offsetX, offsetY);
-        wait.until(d -> isEmpty());
+      String wrong = somethingElseAdded(namesBefore);
+      if (wrong != null) {
+        actOnTransform(wrong, "Delete", offsetX, offsetY);
+        wait.until(d -> transformNames().size() == namesBefore.size());
       }
     }
     throw new AssertionError(
@@ -128,20 +181,26 @@ public class PipelineGraphPage {
             + transformName
             + "': none of the first "
             + MAX_CANDIDATES
-            + " entries matching that search was it");
+            + " entries matching that search was it, the graph holds "
+            + transformNames());
   }
 
-  /** The label that appeared on the graph, or null if nothing did. */
-  private String awaitAdded() {
+  /** Whether one more transform of this name turned up within the short wait. 
*/
+  private boolean awaitAdded(String transformName, long before) {
     try {
-      return shortWait().until(d -> addedLabel());
+      shortWait().until(d -> count(transformName) > before);
+      return true;
     } catch (TimeoutException e) {
-      return null;
+      return false;
     }
   }
 
-  private String addedLabel() {
-    return labels().stream().filter(label -> 
!emptyLabels.contains(label)).findFirst().orElse(null);
+  /** The name of whatever else landed on the graph, or null if nothing did. */
+  private String somethingElseAdded(List<String> namesBefore) {
+    return transformNames().stream()
+        .filter(name -> !namesBefore.contains(name))
+        .findFirst()
+        .orElse(null);
   }
 
   private WebDriverWait shortWait() {
@@ -164,12 +223,143 @@ public class PipelineGraphPage {
 
   public ContextDialog contextDialog() {
     ContextDialog dialog = new ContextDialog(driver, wait);
-    dialog.awaitOpen();
+    try {
+      dialog.awaitOpen();
+    } catch (TimeoutException e) {
+      // The usual reason the context dialog never comes is that Hop has a 
modal error dialog up.
+      HopGuiPage.failIfErrorDialog(driver);
+      throw e;
+    }
     return dialog;
   }
 
+  /** The offset from the middle of the canvas at which this transform is 
drawn. */
+  public int[] transformOffset(String transformName) {
+    List<Number> drawn = drawnAt(transformName);
+    return new int[] {drawn.get(0).intValue(), drawn.get(1).intValue()};
+  }
+
+  /** How big this transform's icon is drawn, which is what a zoom level 
changes. */
+  public int transformIconSize(String transformName) {
+    return drawnAt(transformName).get(2).intValue();
+  }
+
+  private List<Number> drawnAt(String transformName) {
+    @SuppressWarnings("unchecked")
+    List<Number> drawn =
+        (List<Number>)
+            ((JavascriptExecutor) driver).executeScript(TRANSFORM_AT, 
transformName, canvas());
+    if (drawn == null) {
+      throw new AssertionError(
+          "The graph does not hold a transform called '"
+              + transformName
+              + "', only "
+              + transformNames());
+    }
+    return drawn;
+  }
+
+  /**
+   * Drags a transform across the canvas.
+   *
+   * <p>In steps rather than in one jump: a single move is one mouse event, 
and the graph only
+   * starts a drag once it has seen the pointer move while the button is down.
+   */
+  public void dragTransform(String transformName, int dx, int dy) {
+    int[] from = transformOffset(transformName);
+    new Actions(driver)
+        .moveToElement(canvas(), from[0], from[1])
+        .clickAndHold()
+        .moveByOffset(dx / 2, dy / 2)
+        .moveByOffset(dx - dx / 2, dy - dy / 2)
+        .release()
+        .perform();
+  }
+
+  /** Undoes the last change to the graph. */
+  public void undo(HopGuiPage hopGui) {
+    hopGui.clickWidget(UNDO);
+  }
+
+  /** Redoes what was undone. */
+  public void redo(HopGuiPage hopGui) {
+    hopGui.clickWidget(REDO);
+  }
+
+  /** Clicks a transform where it is drawn, wherever the pipeline put it. */
+  public void clickTransform(String transformName) {
+    int[] offset = transformOffset(transformName);
+    clickAt(offset[0], offset[1]);
+  }
+
+  /** Opens a transform's context dialog where the transform is, and picks an 
entry from it. */
+  public void actOnTransform(String transformName, String action) {
+    clickTransform(transformName);
+    ContextDialog dialog = contextDialog();
+    wait.until(d -> dialog.title() != null && 
dialog.title().contains(transformName));
+    dialog.choose(action);
+  }
+
   /** 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();
   }
+
+  /**
+   * Previews one transform and waits for its rows.
+   *
+   * <p>Two dialogs deep: the context action opens the debug dialog, where 
"Quick Launch" runs the
+   * pipeline far enough to fill the transform's preview and then shows the 
rows.
+   */
+  public PreviewDataDialog preview(HopGuiPage hopGui, String transformName) {
+    actOnTransform(transformName, "Preview & debug output");
+    hopGui.awaitDialog();
+    hopGui.clickButton("Quick Launch");
+    HopGuiPage.waitFor(driver, EXECUTION_TIMEOUT)
+        .until(d -> "Examine preview data".equals(hopGui.topDialogTitle()));
+    return new PreviewDataDialog(driver, hopGui);
+  }
+
+  /** The graph's own toolbar, whose ids are declared on {@code 
HopGuiPipelineGraph}. */
+  private static final String TOOLBAR = "HopGuiPipelineGraph-ToolBar-";
+
+  public static final By RUN = HopGuiPage.testId(TOOLBAR + "10010-Run");
+  public static final By PREVIEW = HopGuiPage.testId(TOOLBAR + 
"10050-Preview");
+  public static final By UNDO = HopGuiPage.testId(TOOLBAR + "10100-Undo");
+  public static final By REDO = HopGuiPage.testId(TOOLBAR + "10110-Redo");
+  public static final By ZOOM_IN = HopGuiPage.testId(TOOLBAR + 
"10520-Zoom-In");
+  public static final By ZOOM_OUT = HopGuiPage.testId(TOOLBAR + 
"10510-Zoom-Out");
+
+  /** How long a sample pipeline may take to start, run and report itself 
finished. */
+  private static final Duration EXECUTION_TIMEOUT = Duration.ofSeconds(60);
+
+  /**
+   * Runs the pipeline and waits until every transform reports itself finished.
+   *
+   * <p>Only pipelines that have been saved can be run: Hop asks an unsaved 
one to be saved first,
+   * which is a different dialog and a different test.
+   */
+  public ExecutionResultsPanel run(HopGuiPage hopGui, String... transforms) {
+    hopGui.clickWidget(RUN);
+    // "Run Options" carries the run configuration, the log level and the 
parameters. Everything
+    // in it is already set the way the pipeline wants, so the test only has 
to launch.
+    hopGui.awaitDialog();
+    hopGui.clickButton("Launch");
+    hopGui.awaitNoDialog();
+
+    ExecutionResultsPanel results = new ExecutionResultsPanel(driver, hopGui);
+    results.selectTab("Metrics");
+    // Waiting for "everything on screen has finished" is not enough: the 
table fills in transform
+    // by transform, so the first transform to finish would satisfy it while 
the rest are still
+    // running. The caller says which transforms it expects, and all of them 
have to be there.
+    HopGuiPage.waitFor(driver, EXECUTION_TIMEOUT)
+        .until(
+            d -> {
+              List<Map<String, String>> metrics = results.metrics();
+              return metrics.size() >= transforms.length
+                  && metrics.stream().allMatch(row -> 
"Finished".equals(row.get("Status")))
+                  && Stream.of(transforms).allMatch(name -> 
results.metricsOf(name) != null);
+            });
+    return results;
+  }
 }
diff --git 
a/web-tests/src/test/java/org/apache/hop/web/it/pages/PreviewDataDialog.java 
b/web-tests/src/test/java/org/apache/hop/web/it/pages/PreviewDataDialog.java
new file mode 100644
index 0000000000..8d01d33727
--- /dev/null
+++ b/web-tests/src/test/java/org/apache/hop/web/it/pages/PreviewDataDialog.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.pages;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.openqa.selenium.JavascriptExecutor;
+import org.openqa.selenium.WebDriver;
+
+/**
+ * "Examine preview data": the rows a transform produced when it was previewed.
+ *
+ * <p>This is the only place in Hop Web where a test can see actual data 
rather than a count of it,
+ * which makes it the strongest thing the suite can assert. A preview is also 
a whole pipeline
+ * execution of its own, run through a different path from the Run button, so 
it is worth having
+ * even where the counts alone would do.
+ */
+public class PreviewDataDialog {
+
+  /** The header line the dialog puts above the grid, for example "... add 
constants (1 rows)". */
+  private static final Pattern ROW_COUNT = Pattern.compile("\\((\\d+) 
rows?\\)");
+
+  /**
+   * The cells of every row in the grid, row by row.
+   *
+   * <p>Rows lay out as alternating spacer and cell elements, as everywhere 
else in a RAP table, so
+   * only the odd children are cells. Rows that are entirely empty are the 
grid's own filler and are
+   * left out.
+   */
+  private static final String GRID_ROWS =
+      "const shells=[...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=shells[shells.length-1];"
+          + "if(!top)return [];"
+          + "return [...top.querySelectorAll('div')]"
+          + ".filter(d=>d.children.length>=4&&d.children.length%2===0"
+          + "&&[...d.children].every(c=>c.children.length===0))"
+          + 
".map(r=>[...r.children].filter((c,n)=>n%2===1).map(c=>c.textContent.trim()))"
+          + ".filter(cells=>cells.some(c=>c.length>0));";
+
+  private final WebDriver driver;
+  private final HopGuiPage hopGui;
+
+  PreviewDataDialog(WebDriver driver, HopGuiPage hopGui) {
+    this.driver = driver;
+    this.hopGui = hopGui;
+  }
+
+  /** The rows of the grid, each as its list of cell values. */
+  public List<List<String>> rows() {
+    @SuppressWarnings("unchecked")
+    List<List<String>> rows =
+        (List<List<String>>) ((JavascriptExecutor) 
driver).executeScript(GRID_ROWS);
+    return rows;
+  }
+
+  /**
+   * How many rows the dialog says it is showing.
+   *
+   * <p>Read from its own heading rather than counted off the grid: the grid 
is virtual and only
+   * paints what fits on screen, so counting would report the size of the 
window.
+   */
+  public int rowCount() {
+    Matcher matcher = ROW_COUNT.matcher(hopGui.topDialogText());
+    if (!matcher.find()) {
+      throw new AssertionError("The preview dialog does not say how many rows 
it has");
+    }
+    return Integer.parseInt(matcher.group(1));
+  }
+
+  public void close() {
+    hopGui.clickButton("Close");
+    hopGui.awaitNoDialog();
+  }
+}
diff --git a/web-tests/src/test/resources/transforms.csv 
b/web-tests/src/test/resources/transforms.csv
index f521c7a580..c030c12d93 100644
--- a/web-tests/src/test/resources/transforms.csv
+++ b/web-tests/src/test/resources/transforms.csv
@@ -19,6 +19,14 @@
 # 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.
+#
+# Two entries a regeneration has to get right, because a generator reading 
annotations
+# alone gets them wrong:
+#  - a transform whose meta class is @Deprecated is registered under its name 
plus
+#    " (deprecated)", which is what the palette and the canvas call it.
+#  - the list must hold names, not plugin ids. 'Fake' is the id of the 
transform named
+#    'Fake data'; it was in here as an entry of its own and passed only 
because the
+#    fake.svg icon draws the word "Fake", which the old tests read as a 
transform name.
 (Experimental) Beam Hive catalog input
 Abort
 Add a checksum
@@ -99,7 +107,6 @@ Execute row SQL script
 Execute SQL script
 Execute unit tests
 Execution information
-Fake
 Fake data
 File exists
 File metadata
@@ -115,7 +122,7 @@ Get files from result
 Get files rows count
 Get JDBC metadata
 Get Neo4j logging info
-Get records from stream
+Get records from stream (deprecated)
 Get rows from result
 Get server status
 Get subfolder names

Reply via email to