From 2bf799aa5bb3b69d9c3a52240be554902992c2f0 Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Fri, 15 May 2026 11:59:28 +0000 Subject: [PATCH 01/42] feat: prototype get* locator API (Button, TextField, Grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explores a tester API where the entry point is a typed verb (getButton, getTextField, getGrid(Class)) instead of find(Class) + test(component). A locator is both a query (filter chain) and a tester (action methods); resolution is lazy and cacheable, with invalidate() to rewind after a UI change. Custom locators plug in via window.get(MyLocator::new) and compose built-ins through inside(this). Wired onto BrowserlessUIContext only — the prototype is intentionally scoped to the context-style entry point. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/example/locator/LocatorDemoView.java | 79 ++++++++ .../example/locator/PersonFormLocator.java | 49 +++++ .../browserless/BrowserlessBaseClassTest.java | 1 + .../vaadin/browserless/LocatorApiTest.java | 155 ++++++++++++++++ .../browserless/BrowserlessUIContext.java | 10 +- .../vaadin/browserless/locator/Locator.java | 174 ++++++++++++++++++ .../vaadin/browserless/locator/Locators.java | 91 +++++++++ .../flow/component/button/ButtonLocator.java | 47 +++++ .../flow/component/grid/GridLocator.java | 64 +++++++ .../component/textfield/TextFieldLocator.java | 50 +++++ 10 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 junit6/src/test/java/com/example/locator/LocatorDemoView.java create mode 100644 junit6/src/test/java/com/example/locator/PersonFormLocator.java create mode 100644 junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java create mode 100644 shared/src/main/java/com/vaadin/browserless/locator/Locator.java create mode 100644 shared/src/main/java/com/vaadin/browserless/locator/Locators.java create mode 100644 shared/src/main/java/com/vaadin/flow/component/button/ButtonLocator.java create mode 100644 shared/src/main/java/com/vaadin/flow/component/grid/GridLocator.java create mode 100644 shared/src/main/java/com/vaadin/flow/component/textfield/TextFieldLocator.java diff --git a/junit6/src/test/java/com/example/locator/LocatorDemoView.java b/junit6/src/test/java/com/example/locator/LocatorDemoView.java new file mode 100644 index 00000000..dae0bae2 --- /dev/null +++ b/junit6/src/test/java/com/example/locator/LocatorDemoView.java @@ -0,0 +1,79 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.example.locator; + +import java.util.List; + +import com.vaadin.flow.component.Composite; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; +import com.vaadin.flow.router.Route; + +@Route("locator-demo") +public class LocatorDemoView extends VerticalLayout { + + public record Person(String name, int age) { + } + + public LocatorDemoView() { + TextField name = new TextField("Name"); + name.setId("name"); + + Span echo = new Span(""); + echo.setId("echo"); + + Button save = new Button("Save", + e -> echo.setText("Saved: " + name.getValue())); + save.setId("save"); + + Button clear = new Button("Clear", e -> name.setValue("")); + + Grid people = new Grid<>(Person.class); + people.setItems(List.of(new Person("Alice", 30), + new Person("Bob", 25), new Person("Carol", 40))); + people.addItemClickListener( + event -> echo.setText("Clicked: " + event.getItem().name())); + + PersonForm personForm = new PersonForm(echo); + personForm.setId("person-form"); + + add(name, save, clear, echo, people, personForm); + } + + /** + * Demo composite mirroring an app-defined widget — exercises the + * custom-locator extension point. + */ + public static class PersonForm extends Composite { + + public final TextField nameField = new TextField("Name"); + public final TextField emailField = new TextField("Email"); + public final Button submit; + + public PersonForm(Span echo) { + nameField.setId("pf-name"); + emailField.setId("pf-email"); + submit = new Button("Submit", e -> echo.setText("Submitted: " + + nameField.getValue() + " <" + emailField.getValue() + + ">")); + submit.setId("pf-submit"); + getContent().add(nameField, emailField, submit); + } + } +} diff --git a/junit6/src/test/java/com/example/locator/PersonFormLocator.java b/junit6/src/test/java/com/example/locator/PersonFormLocator.java new file mode 100644 index 00000000..b9da543c --- /dev/null +++ b/junit6/src/test/java/com/example/locator/PersonFormLocator.java @@ -0,0 +1,49 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.example.locator; + +import com.example.locator.LocatorDemoView.PersonForm; +import com.vaadin.browserless.locator.Locator; +import com.vaadin.flow.component.button.ButtonLocator; +import com.vaadin.flow.component.textfield.TextFieldLocator; + +/** + * App-side locator for a composite. Demonstrates two reuse patterns: + * + *
    + *
  • Subclass {@link Locator} with the recursive self-type so filter steps + * stay chainable.
  • + *
  • Compose built-in locators (TextField, Button) and scope them with + * {@code inside(this)} so sub-queries only see descendants of the resolved + * composite.
  • + *
+ */ +public class PersonFormLocator extends Locator { + + public PersonFormLocator() { + super(PersonForm.class); + } + + public PersonFormLocator fillIn(String name, String email) { + new TextFieldLocator().withId("pf-name").inside(this).setValue(name); + new TextFieldLocator().withId("pf-email").inside(this).setValue(email); + return this; + } + + public void submit() { + new ButtonLocator().withId("pf-submit").inside(this).click(); + } +} diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessBaseClassTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessBaseClassTest.java index e4a20b91..0f7e1571 100644 --- a/junit6/src/test/java/com/vaadin/browserless/BrowserlessBaseClassTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessBaseClassTest.java @@ -83,6 +83,7 @@ void extendingBaseClass_runTest_routesAreDiscovered() { allViews.add(com.example.multiuser.SharedCounterView.class); allViews.add(com.example.multiuser.ExternalNavigationView.class); allViews.add(com.example.multiuser.SimpleView.class); + allViews.add(com.example.locator.LocatorDemoView.class); Assertions.assertEquals(allViews.size(), routes.size()); Assertions.assertTrue(routes.containsAll(allViews)); } diff --git a/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java new file mode 100644 index 00000000..46248b77 --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.vaadin.browserless; + +import com.example.locator.LocatorDemoView; +import com.example.locator.LocatorDemoView.Person; +import com.example.locator.PersonFormLocator; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonLocator; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.vaadin.browserless.internal.Routes; +import com.vaadin.flow.component.html.Span; + +/** + * Demonstrates the prototype {@code get*} locator API: no {@code Class.class} + * tokens, no {@code test(...)} wrapping step, one fluent chain from find to + * action. + *

+ * Uses the context-style entry point ({@link BrowserlessApplicationContext}) + * rather than a test base class. + */ +class LocatorApiTest { + + private static Routes routes() { + return new Routes() + .autoDiscoverViews(LocatorDemoView.class.getPackageName()); + } + + @Test + void buttonByCaption_click_firesListener() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + window.getTextField().withId("name").setValue("World"); + window.getButton().withCaption("Save").click(); + + Assertions.assertEquals("Saved: World", window + .find(Span.class).withId("echo").single().getText()); + } + } + + @Test + void buttonByCaption_multipleButtons_filterPicksRightOne() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + window.getTextField().withId("name").setValue("X"); + window.getButton().withCaption("Clear").click(); + ButtonLocator buttonLocator = window.getButton().withCaption("Clear"); + Button component = buttonLocator.getComponent(); + ButtonLocator buttonLocator2 = new ButtonLocator(); + ButtonLocator buttonLocator3 = buttonLocator2.withCaption("Save"); + + Assertions.assertEquals("", + window.getTextField().withId("name").getValue()); + } + } + + @Test + void textField_setValue_thenRead_roundTrips() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + window.getTextField().withId("name").setValue("hello"); + Assertions.assertEquals("hello", + window.getTextField().withId("name").getValue()); + } + } + + @Test + void grid_typedRowAccessor() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + Person first = window.getGrid(Person.class).getRow(0); + + Assertions.assertEquals("Alice", first.name()); + Assertions.assertEquals(3, window.getGrid(Person.class).size()); + } + } + + @Test + void singleLocator_reusedAfterUiChange_reresolves() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + var save = window.getButton().withCaption("Save"); + window.getTextField().withId("name").setValue("first"); + save.click(); + + // Resolution caches across calls in one chain. After a UI change + // that could replace the component, invalidate() rewinds the + // cache. + window.getTextField().withId("name").setValue("second"); + save.invalidate().click(); + + Assertions.assertEquals("Saved: second", window + .find(Span.class).withId("echo").single().getText()); + } + } + + @Test + void customLocator_viaGetSupplier_composesBuiltins() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var window = app.newUser().newWindow(); + window.navigate(LocatorDemoView.class); + + window.get(PersonFormLocator::new) + .fillIn("Ada", "ada@example.com"); + window.get(PersonFormLocator::new).submit(); + + Assertions.assertEquals("Submitted: Ada ", + window.find(Span.class).withId("echo").single().getText()); + } + } + + @Test + void multiUser_locatorsRespectActiveWindow() { + try (var app = BrowserlessApplicationContext.create(routes())) { + var alice = app.newUser().newWindow(); + var bob = app.newUser().newWindow(); + alice.navigate(LocatorDemoView.class); + bob.navigate(LocatorDemoView.class); + + alice.getTextField().withId("name").setValue("alice-value"); + bob.getTextField().withId("name").setValue("bob-value"); + + // Each window's locator targets its own UI; values do not leak. + Assertions.assertEquals("alice-value", + alice.getTextField().withId("name").getValue()); + Assertions.assertEquals("bob-value", + bob.getTextField().withId("name").getValue()); + } + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java index fb3dd688..f71a9a85 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java @@ -21,6 +21,8 @@ import com.vaadin.browserless.internal.MockPage; import com.vaadin.browserless.internal.MockVaadin; +import com.vaadin.browserless.internal.ShortcutsKt; +import com.vaadin.browserless.locator.Locators; import com.vaadin.flow.component.Component; import com.vaadin.flow.component.HasElement; import com.vaadin.flow.component.Key; @@ -58,7 +60,8 @@ * @see BrowserlessUserContext#newWindow() * @see BrowserlessApplicationContext */ -public class BrowserlessUIContext implements TesterWrappers, AutoCloseable { +public class BrowserlessUIContext + implements TesterWrappers, Locators, AutoCloseable { private static final ThreadLocal activeContext = new ThreadLocal<>(); @@ -429,6 +432,11 @@ public Map> getOpenedWindows() { return Map.of(); } + @Override + public void activateLocatorContext() { + activate(); + } + /** * Returns the UI managed by this context. * diff --git a/shared/src/main/java/com/vaadin/browserless/locator/Locator.java b/shared/src/main/java/com/vaadin/browserless/locator/Locator.java new file mode 100644 index 00000000..e77c023e --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/locator/Locator.java @@ -0,0 +1,174 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.vaadin.browserless.locator; + +import java.util.List; +import java.util.function.Predicate; + +import com.vaadin.browserless.ComponentQuery; +import com.vaadin.flow.component.Component; + +/** + * Prototype base class for the {@code get*} tester API. A locator is a fluent + * combination of a {@link ComponentQuery} filter chain and a tester: the + * subclass exposes both the filter methods inherited from this class and the + * action methods specific to the component type. + *

+ * Resolution is deferred to the first action call ({@link #component()}) and + * cached. Calling any filter method after resolution invalidates the cache so + * the next action re-resolves. This means a single locator instance can be + * reused across an asynchronous boundary (e.g. {@code roundTrip()}) without + * holding on to a stale component reference. + * + * @param + * the component type + * @param + * the concrete locator subtype, used for fluent chaining + */ +public abstract class Locator> { + + private final ComponentQuery query; + private C resolved; + private int pickIndex; + + /** + * Creates a locator that searches for components of the given type from the + * current {@code UI} root. + * + * @param componentType + * the component type to match + */ + protected Locator(Class componentType) { + this.query = new ComponentQuery<>(componentType); + } + + /** Requires the matched component to have the given id. */ + public SELF withId(String id) { + invalidate(); + query.withId(id); + return self(); + } + + /** Requires the matched component to have a caption equal to the given text. */ + public SELF withCaption(String caption) { + invalidate(); + query.withCaption(caption); + return self(); + } + + /** Requires the matched component to have a caption containing the given text. */ + public SELF withCaptionContaining(String text) { + invalidate(); + query.withCaptionContaining(text); + return self(); + } + + /** Requires the text content of the component to equal the given text. */ + public SELF withText(String text) { + invalidate(); + query.withText(text); + return self(); + } + + /** Requires the text content of the component to contain the given text. */ + public SELF withTextContaining(String text) { + invalidate(); + query.withTextContaining(text); + return self(); + } + + /** Requires the matched component to have all the given CSS class names. */ + public SELF withClassName(String... className) { + invalidate(); + query.withClassName(className); + return self(); + } + + /** Requires the matched component to satisfy the given predicate. */ + public SELF withCondition(Predicate condition) { + invalidate(); + query.withCondition(condition); + return self(); + } + + /** Scopes the search to descendants of the given component. */ + public SELF inside(Component parent) { + invalidate(); + query.from(parent); + return self(); + } + + /** + * Scopes the search to descendants of the component matched by the given + * locator. The parent locator is resolved on demand. + */ + public SELF inside(Locator parent) { + return inside(parent.component()); + } + + /** + * Picks the n-th match (1-based) when the filter chain yields multiple + * matches. Without this, the default expectation is exactly one match. + */ + public SELF atIndex(int index) { + invalidate(); + this.pickIndex = index; + return self(); + } + + /** + * Resolves the locator to a single matching component, caching the result. + * Subclasses call this from action methods (e.g. {@code click}). + * + * @return the matched component + * @throws java.util.NoSuchElementException + * if no component matches or more than one matches (and no + * {@link #atIndex(int)} was provided) + */ + public C component() { + if (resolved == null) { + resolved = pickIndex > 0 ? query.atIndex(pickIndex) : query.single(); + } + return resolved; + } + + /** + * Returns all matching components, bypassing the cache. Useful for + * assertions on counts without committing to a single match. + */ + public List components() { + return query.all(); + } + + /** Returns {@code true} if the filter chain matches at least one component. */ + public boolean exists() { + return query.exists(); + } + + /** + * Discards any cached resolution. Call after a UI change that may have + * replaced or detached the previously resolved component. + */ + public SELF invalidate() { + resolved = null; + return self(); + } + + @SuppressWarnings("unchecked") + protected SELF self() { + return (SELF) this; + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/locator/Locators.java b/shared/src/main/java/com/vaadin/browserless/locator/Locators.java new file mode 100644 index 00000000..1506d199 --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/locator/Locators.java @@ -0,0 +1,91 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.vaadin.browserless.locator; + +import java.util.function.Supplier; + +import com.vaadin.flow.component.button.ButtonLocator; +import com.vaadin.flow.component.grid.GridLocator; +import com.vaadin.flow.component.textfield.TextFieldLocator; + +/** + * Prototype mixin offering typed entry points for the {@code get*} tester API. + *

+ * Mixed into test base classes and context objects so that tests can write + * {@code getButton().withCaption("Save").click()} without naming a + * {@code Class.class} token or wrapping a component instance with + * {@code test(...)}. + *

+ * The prototype exposes a small set of component types (Button, TextField, + * Grid). The end state would auto-generate an entry method per registered + * tester via an annotation processor. + */ +public interface Locators { + + /** + * Hook for context-bound implementations (e.g. + * {@code BrowserlessUIContext}) to install Vaadin thread-locals before a + * locator is built. The default is a no-op so plain test base classes work + * out of the box. + */ + default void activateLocatorContext() { + } + + /** Locator for a {@link com.vaadin.flow.component.button.Button}. */ + default ButtonLocator getButton() { + activateLocatorContext(); + return new ButtonLocator(); + } + + /** Locator for a {@link com.vaadin.flow.component.textfield.TextField}. */ + default TextFieldLocator getTextField() { + activateLocatorContext(); + return new TextFieldLocator(); + } + + /** + * Locator for a {@link com.vaadin.flow.component.grid.Grid} carrying items + * of the given value type. + * + * @param valueType + * the item type of the grid; serves as a type witness so the + * returned locator can expose typed row accessors + */ + default GridLocator getGrid(Class valueType) { + activateLocatorContext(); + return new GridLocator<>(valueType); + } + + /** + * Generic entry point for user-defined locators. Activates the locator + * context (so thread-locals and the security snapshot are restored on a + * window switch) and invokes the supplied factory. + * + *

+     * window.get(CheckoutFormLocator::new).withId("checkout").submit();
+     * 
+ * + * @param factory + * constructor reference (or any supplier) for the user locator + * @param + * the user locator type + * @return a fresh locator instance + */ + default > L get(Supplier factory) { + activateLocatorContext(); + return factory.get(); + } +} diff --git a/shared/src/main/java/com/vaadin/flow/component/button/ButtonLocator.java b/shared/src/main/java/com/vaadin/flow/component/button/ButtonLocator.java new file mode 100644 index 00000000..9b7e9cb5 --- /dev/null +++ b/shared/src/main/java/com/vaadin/flow/component/button/ButtonLocator.java @@ -0,0 +1,47 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed 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 com.vaadin.flow.component.button; + +import com.vaadin.browserless.Clickable; +import com.vaadin.browserless.locator.Locator; + +/** + * Locator/tester for {@link Button}. Combines the filter chain inherited from + * {@link Locator} with the click actions inherited from {@link Clickable}, so a + * full find-and-act sequence is one fluent chain: + * + *
+ * getButton().withCaption("Save").click();
+ * 
+ */ +public class ButtonLocator extends Locator + implements Clickable