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", "</>" - 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