From 51f84b5abe3fa689228843bd73f48038b9cd554f Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Thu, 21 May 2026 11:27:42 +0000 Subject: [PATCH 01/10] feat: add withLabel and withAriaLabel filters (closes #42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Common Playwright pattern — selecting form fields by their visible label or buttons by their assistive-tech label — wasn't expressible directly in the locator/query API. withCaption() exists but is intentionally broader (it matches label, text, or placeholder depending on component) and that ambiguity can surprise users. Adds four new filters at both the ComponentQuery and Locator layers: - withLabel(text) — exact match on the "label" property - withLabelContaining(text) — substring match on the "label" property - withAriaLabel(text) — exact match on the "aria-label" attribute - withAriaLabelContaining(text) — substring match on "aria-label" Backed by new predicates in ElementConditions (hasLabel, labelContains, hasAriaLabel, ariaLabelContains) so the same checks are reusable from withCondition. Unit-tested at the query layer and integration-tested at the locator layer; LocatorDemoView grows an aria-label on Clear and the inner PersonForm uses distinct labels so withLabel matches resolve to single elements. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/example/locator/LocatorDemoView.java | 5 +- .../browserless/ComponentQueryTest.java | 91 +++++++++++++++++++ .../vaadin/browserless/LocatorApiTest.java | 43 +++++++++ .../vaadin/browserless/ComponentQuery.java | 56 ++++++++++++ .../vaadin/browserless/ElementConditions.java | 61 +++++++++++++ .../vaadin/browserless/locator/Locator.java | 41 +++++++++ 6 files changed, 295 insertions(+), 2 deletions(-) diff --git a/junit6/src/test/java/com/example/locator/LocatorDemoView.java b/junit6/src/test/java/com/example/locator/LocatorDemoView.java index 5540430a..635eb33d 100644 --- a/junit6/src/test/java/com/example/locator/LocatorDemoView.java +++ b/junit6/src/test/java/com/example/locator/LocatorDemoView.java @@ -43,6 +43,7 @@ public LocatorDemoView() { save.setId("save"); Button clear = new Button("Clear", e -> name.setValue("")); + clear.setAriaLabel("Reset form"); Grid people = new Grid<>(Person.class); people.setItems(List.of(new Person("Alice", 30), new Person("Bob", 25), @@ -62,8 +63,8 @@ public LocatorDemoView() { */ public static class PersonForm extends Composite { - public final TextField nameField = new TextField("Name"); - public final TextField emailField = new TextField("Email"); + public final TextField nameField = new TextField("Full name"); + public final TextField emailField = new TextField("Email address"); public final Button submit; public PersonForm(Span echo) { diff --git a/junit6/src/test/java/com/vaadin/browserless/ComponentQueryTest.java b/junit6/src/test/java/com/vaadin/browserless/ComponentQueryTest.java index e486b317..5e9d2f1f 100644 --- a/junit6/src/test/java/com/vaadin/browserless/ComponentQueryTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/ComponentQueryTest.java @@ -617,6 +617,97 @@ void withCaptionContaining_null_throws() { () -> query.withCaptionContaining(null)); } + @Test + void withLabel_exactMatch_getsCorrectComponent() { + TestComponent labelled = new TestComponent(); + labelled.getElement().setProperty("label", "Full name"); + + TestComponent other = new TestComponent(); + other.getElement().setProperty("label", "Email"); + + TestComponent noLabel = new TestComponent(); + + UI.getCurrent().getElement().appendChild(labelled.getElement(), + other.getElement(), noLabel.getElement()); + + Assertions.assertSame(labelled, find(TestComponent.class) + .withLabel("Full name").single()); + Assertions.assertSame(other, + find(TestComponent.class).withLabel("Email").single()); + Assertions.assertTrue( + find(TestComponent.class).withLabel("Missing").all().isEmpty()); + } + + @Test + void withLabelContaining_substringMatch() { + TestComponent fullName = new TestComponent(); + fullName.getElement().setProperty("label", "Full name"); + + TestComponent firstName = new TestComponent(); + firstName.getElement().setProperty("label", "First name"); + + TestComponent noLabel = new TestComponent(); + + UI.getCurrent().getElement().appendChild(fullName.getElement(), + firstName.getElement(), noLabel.getElement()); + + Assertions.assertIterableEquals(List.of(fullName, firstName), + find(TestComponent.class).withLabelContaining("name").all()); + } + + @Test + void withAriaLabel_exactMatch_getsCorrectComponent() { + TestComponent reset = new TestComponent(); + reset.getElement().setAttribute("aria-label", "Reset form"); + + TestComponent other = new TestComponent(); + other.getElement().setAttribute("aria-label", "Submit form"); + + TestComponent noAria = new TestComponent(); + + UI.getCurrent().getElement().appendChild(reset.getElement(), + other.getElement(), noAria.getElement()); + + Assertions.assertSame(reset, find(TestComponent.class) + .withAriaLabel("Reset form").single()); + Assertions.assertSame(other, find(TestComponent.class) + .withAriaLabel("Submit form").single()); + Assertions.assertTrue(find(TestComponent.class) + .withAriaLabel("Missing").all().isEmpty()); + } + + @Test + void withAriaLabelContaining_substringMatch() { + TestComponent reset = new TestComponent(); + reset.getElement().setAttribute("aria-label", "Reset form"); + + TestComponent submit = new TestComponent(); + submit.getElement().setAttribute("aria-label", "Submit form"); + + TestComponent noAria = new TestComponent(); + + UI.getCurrent().getElement().appendChild(reset.getElement(), + submit.getElement(), noAria.getElement()); + + Assertions.assertIterableEquals(List.of(reset, submit), + find(TestComponent.class).withAriaLabelContaining("form") + .all()); + } + + @Test + void withLabel_null_throws() { + ComponentQuery query = find(TestComponent.class); + Assertions.assertThrows(NullPointerException.class, + () -> query.withLabel(null)); + } + + @Test + void withAriaLabel_null_throws() { + ComponentQuery query = find(TestComponent.class); + Assertions.assertThrows(NullPointerException.class, + () -> query.withAriaLabel(null)); + } + @Test void withText_exactMatch_getsCorrectComponent() { TextComponent span1 = new TextComponent("sample text"); diff --git a/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java index 09fc6233..67dba814 100644 --- a/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java @@ -143,6 +143,49 @@ void customLocator_viaGetSupplier_composesBuiltins() { } } + @Test + void filterChain_withLabel_selectsField() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + // Outer TextField has label "Name"; inner PersonForm has + // "Full name" / "Email address" — labels are unique at the top + // level so withLabel resolves to exactly one match. + window.findTextField().withLabel("Name").setValue("via label"); + Assertions.assertEquals("via label", window.findTextField() + .withId("name").component().getValue()); + + // Substring match against the inner form's label. + window.findTextField().withLabelContaining("Full") + .setValue("inner"); + Assertions.assertEquals("inner", window.findTextField() + .withId("pf-name").component().getValue()); + } + } + + @Test + void filterChain_withAriaLabel_selectsButton() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + window.findTextField().withId("name").setValue("X"); + + // The Clear button identifies itself to screen readers via + // aria-label="Reset form". + window.findButton().withAriaLabel("Reset form").click(); + Assertions.assertEquals("", window.findTextField().withId("name") + .component().getValue()); + + // Substring match against the same attribute. + window.findTextField().withId("name").setValue("X"); + window.findButton().withAriaLabelContaining("Reset").click(); + Assertions.assertEquals("", window.findTextField().withId("name") + .component().getValue()); + } + } + @Test void filterChain_expandedSurface() { try (var app = BrowserlessApplicationContext.create(routes())) { diff --git a/shared/src/main/java/com/vaadin/browserless/ComponentQuery.java b/shared/src/main/java/com/vaadin/browserless/ComponentQuery.java index d01460bd..111c0abc 100644 --- a/shared/src/main/java/com/vaadin/browserless/ComponentQuery.java +++ b/shared/src/main/java/com/vaadin/browserless/ComponentQuery.java @@ -243,6 +243,62 @@ public ComponentQuery withCaptionContaining(String text) { return this; } + /** + * Requires the component's {@code label} property to be exactly the given + * value. Use this for form fields (TextField, ComboBox, etc.) where the + * end user identifies a field by its label. + * + * @param label + * the expected label, not {@literal null} + * @return this element query instance for chaining + * @see com.vaadin.flow.component.HasLabel#getLabel() + */ + public ComponentQuery withLabel(String label) { + locatorSpec.predicates.add(ElementConditions.hasLabel(label)); + return this; + } + + /** + * Requires the component's {@code label} property to contain the given + * text. + * + * @param text + * substring to find in the label, not {@literal null} + * @return this element query instance for chaining + */ + public ComponentQuery withLabelContaining(String text) { + locatorSpec.predicates.add(ElementConditions.labelContains(text)); + return this; + } + + /** + * Requires the component's {@code aria-label} attribute to be exactly the + * given value. Useful for components like {@code Button} that don't carry + * a visible label property but identify themselves to assistive + * technology via {@code aria-label}. + * + * @param ariaLabel + * the expected aria-label, not {@literal null} + * @return this element query instance for chaining + */ + public ComponentQuery withAriaLabel(String ariaLabel) { + locatorSpec.predicates.add(ElementConditions.hasAriaLabel(ariaLabel)); + return this; + } + + /** + * Requires the component's {@code aria-label} attribute to contain the + * given text. + * + * @param text + * substring to find in the aria-label, not {@literal null} + * @return this element query instance for chaining + */ + public ComponentQuery withAriaLabelContaining(String text) { + locatorSpec.predicates.add(ElementConditions.ariaLabelContains(text)); + return this; + } + /** * Requires the text content of the component to be equal to the given text * diff --git a/shared/src/main/java/com/vaadin/browserless/ElementConditions.java b/shared/src/main/java/com/vaadin/browserless/ElementConditions.java index 6beb9a3f..68fe87d9 100644 --- a/shared/src/main/java/com/vaadin/browserless/ElementConditions.java +++ b/shared/src/main/java/com/vaadin/browserless/ElementConditions.java @@ -196,6 +196,67 @@ public static Predicate hasNotAttribute( .equals(component.getElement().getAttribute(attribute), value); } + /** + * Checks if the component has its {@code label} property set to exactly + * the given value. The {@code label} property is what + * {@link com.vaadin.flow.component.HasLabel#getLabel()} reads. + * + * @param label + * the expected label, not {@literal null} + */ + public static Predicate hasLabel(String label) { + Objects.requireNonNull(label, "label must not be null"); + return component -> label + .equals(component.getElement().getProperty("label")); + } + + /** + * Checks if the component's {@code label} property contains the given + * text. Comparison is case-sensitive. + * + * @param text + * substring to find in the label, not {@literal null} + */ + public static Predicate labelContains(String text) { + Objects.requireNonNull(text, "text must not be null"); + return component -> { + String label = component.getElement().getProperty("label"); + return label != null && label.contains(text); + }; + } + + /** + * Checks if the component has its {@code aria-label} attribute set to + * exactly the given value. Useful for components like {@code Button} that + * don't expose a {@code label} property but identify themselves to + * assistive technology via {@code aria-label}. + * + * @param ariaLabel + * the expected aria-label, not {@literal null} + */ + public static Predicate hasAriaLabel( + String ariaLabel) { + Objects.requireNonNull(ariaLabel, "ariaLabel must not be null"); + return component -> ariaLabel + .equals(component.getElement().getAttribute("aria-label")); + } + + /** + * Checks if the component's {@code aria-label} attribute contains the + * given text. Comparison is case-sensitive. + * + * @param text + * substring to find in the aria-label, not {@literal null} + */ + public static Predicate ariaLabelContains( + String text) { + Objects.requireNonNull(text, "text must not be null"); + return component -> { + String label = component.getElement().getAttribute("aria-label"); + return label != null && label.contains(text); + }; + } + private static class TextContainsPredicate implements Predicate { diff --git a/shared/src/main/java/com/vaadin/browserless/locator/Locator.java b/shared/src/main/java/com/vaadin/browserless/locator/Locator.java index 9844ac08..a6843c24 100644 --- a/shared/src/main/java/com/vaadin/browserless/locator/Locator.java +++ b/shared/src/main/java/com/vaadin/browserless/locator/Locator.java @@ -124,6 +124,47 @@ public SELF withCaptionContaining(String text) { return self(); } + /** + * Requires the matched component's {@code label} property to be exactly + * the given value. Use this for form fields where the end user + * identifies a field by its label. + */ + public SELF withLabel(String label) { + resetCache(); + query.withLabel(label); + return self(); + } + + /** + * Requires the matched component's {@code label} property to contain the + * given text. + */ + public SELF withLabelContaining(String text) { + resetCache(); + query.withLabelContaining(text); + return self(); + } + + /** + * Requires the matched component's {@code aria-label} attribute to be + * exactly the given value. + */ + public SELF withAriaLabel(String ariaLabel) { + resetCache(); + query.withAriaLabel(ariaLabel); + return self(); + } + + /** + * Requires the matched component's {@code aria-label} attribute to + * contain the given text. + */ + public SELF withAriaLabelContaining(String text) { + resetCache(); + query.withAriaLabelContaining(text); + return self(); + } + /** Requires the text content of the component to equal the given text. */ public SELF withText(String text) { resetCache(); From 9556e3d5369c3788a7f92552a741795e60c601af Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Thu, 21 May 2026 12:17:39 +0000 Subject: [PATCH 02/10] feat: withLabel matches separate