testbench icon indicating copy to clipboard operation
testbench copied to clipboard

Support for ElementQuery.caption()

Open mvysny opened this issue 9 months ago • 9 comments

I need to look up a component by a caption. Currently the only way is to look up all components and then go through each of them and compare their caption with the expected one.

The problem is that there's no such notion as caption in Vaadin 23, not in the same sense as in Vaadin 8. The components generally fall into three categories:

  1. Don't have a label
  2. Have a label (Elements implementing the TestBench HasLabel interface) - mostly fields
  3. Has a text (ButtonElement)

Two solutions would work for me:

  1. ElementQuery.caption() emulating Vaadin 8 caption, OR
  2. ElementQuery.text() matching component text against the expected one (this one would be used for ButtonElements), and ElementQuery.label() matching label against the expected one (this one would work for all HasLabels).

mvysny avatar Mar 11 '25 08:03 mvysny

The case with fields when there is only a textual label is the simplest. One can use "label" property then. This is trivial.

        TextFieldElement nameField = $(TextFieldElement.class).all().stream()
                .filter(elem -> elem.getPropertyString("label").equals("Name"))
                .findFirst().get();

But there is also technical possibility in Flow to replace the label with a component appended in "label" slot. In that case label property can be null or empty and something different is needed to find the element, as you would need to element which has a child element with possibly a textual content you are looking for.

TatuLund avatar Mar 11 '25 09:03 TatuLund

TextFieldElement nameField = $(TextFieldElement.class).all().stream()
                .filter(elem -> elem.getPropertyString("label").equals("Name"))

This creates n+1 requests to the browser; depending on the number of text fields on the page this could be very slow.

mvysny avatar Mar 11 '25 12:03 mvysny

Then my recommendation is to add id to components in the Java code and use id selector in TestBench, that will use css selector with attribute and it will be faster.

TatuLund avatar Mar 11 '25 13:03 TatuLund

Regarding finding a button by text content there are two alternatives

Finding it using plain Selenium xpath:

        WebElement buttonElement = getDriver().findElement(By.xpath("//vaadin-button[text()='Caption']"));

Or finding buttons and picking one having right text content

        ButtonElement vaadinButton = $(ButtonElement.class).all().stream().filter(b -> b.getText().equals("Caption")).findFirst()
                .orElseThrow(() -> new NotFoundException("Button not found"));

The difference is the result type

TatuLund avatar Mar 12 '25 07:03 TatuLund

Is this solved by https://github.com/vaadin/testbench/pull/1784 in v24?

alvarezguille avatar Mar 13 '25 19:03 alvarezguille

@alvarezguille , yes. Implementation logic in Vaadin 24 is similar to my workaround example above.

TatuLund avatar Mar 14 '25 06:03 TatuLund

Yup, #1784 technically solves the issue.

Unfortunately, #1784 filters the components in Java, which will cause n+1 requests to the browser, and therefore this will be inherently slow.

It would be preferable to filter the components in the browser, in JS, thus making just one request to the browser.

mvysny avatar Mar 14 '25 09:03 mvysny

Thank you @TatuLund , this solution looks very interesting: WebElement buttonElement = getDriver().findElement(By.xpath("//vaadin-button[text()='Caption']"));. Let me test whether it works for buttons with icons as well.

EDIT: this code works well, from within TestBenchTestCase:

final List<WebElement> buttons = findElements(By.xpath("//vaadin-button[text()='" + caption + "']"));
final TestBenchElement button = (TestBenchElement) buttons.get(0);
final ButtonElement be = button.wrap(clazz);

mvysny avatar Mar 14 '25 09:03 mvysny

Workaround:

public class TestBenchUtils {
    public static String getCaption(TestBenchElement element) {
        if (element instanceof HasLabel) {
            String caption = ((HasLabel) element).getText();
            if (caption.isEmpty()) {
                // check whether the component is placed in FormLayout - if it is, it may take the caption from FormItemElement.
                final TestBenchElement parent = getParent(element);
                if (parent != null && parent.getTagName().equals("vaadin-form-item")) {
                    final FormItemElement formItemElement = parent.wrap(FormItemElement.class);
                    caption = formItemElement.getLabel().getText();
                }
            }
            return caption;
        } else if (element instanceof ButtonElement) {
            return ((ButtonElement) element).getText();
        } else if (element instanceof DialogElement) {
            return ((DialogElement) element).getPropertyString("headerTitle");
        }
        return "";
    }

    private static String getTagName(Class<?> elementClass) {
        Element annotation = elementClass.getAnnotation(Element.class);
        if (annotation == null) {
            return "*";
        }
        return annotation.value();
    }

    public static <T extends TestBenchElement> List<T> findByProperty(HasElementQuery context, Class<T> elementClass, String propertyName, String propertyValue) {
        // workaround for https://github.com/vaadin/testbench/issues/1881
        final String tagName = getTagName(elementClass);
        final TestBenchElement element;
        final String searchScope;
        if (context instanceof TestBenchElement) {
            element = (TestBenchElement) context;
            searchScope = "arguments[0]";
        } else {
            element = null;
            searchScope = "document";
        }
        final List<TestBenchElement> matchingElements = (List<TestBenchElement>) ((HasTestBenchCommandExecutor) context).getCommandExecutor().
                executeScript("var propertyName = arguments[2]; var propertyValue = arguments[3]; var tagName = arguments[1]; return Array.from(" + searchScope + ".querySelectorAll(tagName)).filter(node => node[propertyName] == propertyValue)", element, tagName, propertyName, propertyValue);
        return matchingElements.stream().map(e -> e.wrap(elementClass)).toList();
    }

    public static <T extends TestBenchElement> List<T> caption(HasElementQuery context, Class<T> elementClass, String caption) {
        if (ButtonElement.class.isAssignableFrom(elementClass)) {
            // this is fast enough
            final List<WebElement> buttons = context.getContext().findElements(By.xpath("//vaadin-button[text()='" + caption + "']"));
            // this is much slower, but might be more accurate.
//            final List<T> buttons = $(clazz).all().stream().filter(it -> it.getText().equals(caption)).toList();
            final List<T> result = buttons.stream().map(it -> ((TestBenchElement)it).wrap(elementClass)).toList();
            return result;

        }
        // first, try a quick search, maybe we'll get lucky.
        final List<T> matchingElements = findByProperty(context, elementClass, "label", caption);
        if (!matchingElements.isEmpty()) {
            return matchingElements;
        }

        // @todo VAADIN-FIXME this will be very slow. We need a custom query which filters components in the browser.
        // filed upstream: https://github.com/vaadin/testbench/issues/1883
        // no luck, fall back to the slow way.
        final List<T> all = context.$(elementClass).all();
        return all.stream()
                .filter(it -> caption.equals(getCaption(it)))
                .collect(Collectors.toList());
    }
}

mvysny avatar Mar 25 '25 07:03 mvysny