diff --git a/.mvn/.gitkeep b/.mvn/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/junit6/pom.xml b/junit6/pom.xml
index 1718e55a..37c26a1c 100644
--- a/junit6/pom.xml
+++ b/junit6/pom.xml
@@ -153,6 +153,22 @@
testCompile
+
+
+
+
+ com.vaadin
+ browserless-test-locator-processor
+ ${project.version}
+
+
+
+ -Alocator.entrypoint.fqn=com.example.locator.AppLocators
+
+
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..5540430a
--- /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..8b4d3308
--- /dev/null
+++ b/junit6/src/test/java/com/example/locator/PersonFormLocator.java
@@ -0,0 +1,50 @@
+/*
+ * 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/GeneratedAggregatorsTest.java b/junit6/src/test/java/com/vaadin/browserless/GeneratedAggregatorsTest.java
new file mode 100644
index 00000000..53263ed3
--- /dev/null
+++ b/junit6/src/test/java/com/vaadin/browserless/GeneratedAggregatorsTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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 java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Reflection assertions pinning structural invariants of the locator
+ * processor's output:
+ *
+ *
{@code GeneratedLocators} is core-only — no commercial entries leak in,
+ * so loading it does not require commercial Vaadin classes on the classpath.
+ *
{@code GeneratedCommercialLocators} carries the commercial entries.
+ *
{@code CommercialLocators} unions both via interface inheritance.
+ *
The end-user-style aggregator emitted by junit6's test-compile
+ * ({@code com.example.locator.AppLocators}) is scoped to this module's own
+ * {@code @Tests}-annotated testers and does not regenerate shared.jar's.
+ *
+ */
+class GeneratedAggregatorsTest {
+
+ @Test
+ void defaultAggregatorDoesNotContainCommercialEntries() throws Exception {
+ Class> agg = Class
+ .forName("com.vaadin.browserless.locator.GeneratedLocators");
+ Set methods = methodNames(agg.getDeclaredMethods());
+ assertTrue(methods.contains("findButton"),
+ "core aggregator should expose findButton, was: " + methods);
+ assertFalse(methods.contains("findChart"),
+ "core aggregator must not expose findChart: " + methods);
+ }
+
+ @Test
+ void commercialAggregatorContainsChartAndNotCoreEntries() throws Exception {
+ Class> agg = Class.forName(
+ "com.vaadin.browserless.locator.GeneratedCommercialLocators");
+ Set methods = methodNames(agg.getDeclaredMethods());
+ assertTrue(methods.contains("findChart"),
+ "commercial aggregator should expose findChart, was: "
+ + methods);
+ assertFalse(methods.contains("findButton"),
+ "commercial aggregator should not duplicate core entries: "
+ + methods);
+ }
+
+ @Test
+ void commercialLocatorsMixinUnionsCoreAndCommercial() throws Exception {
+ Class> mixin = Class
+ .forName("com.vaadin.browserless.locator.CommercialLocators");
+ // getMethods() walks inherited interfaces; getDeclaredMethods()
+ // would only show what's declared on CommercialLocators itself.
+ Set methods = methodNames(mixin.getMethods());
+ assertTrue(methods.contains("findButton"),
+ "CommercialLocators should surface core findButton, was: "
+ + methods);
+ assertTrue(methods.contains("findChart"),
+ "CommercialLocators should surface commercial findChart, was: "
+ + methods);
+ }
+
+ @Test
+ void downstreamAggregatorEmittedAtConfiguredFqn() throws Exception {
+ // junit6's test-compile wires the processor with
+ // -Alocator.entrypoint.fqn=com.example.locator.AppLocators
+ // and the processor only scans junit6's own @Tests-annotated test
+ // sources, not shared's testers (which are pre-compiled in
+ // shared.jar). This pins both behaviours.
+ Class> downstream = Class.forName("com.example.locator.AppLocators");
+ assertTrue(downstream.isInterface(),
+ "downstream aggregator should be an interface");
+
+ Set methods = methodNames(downstream.getDeclaredMethods());
+ assertTrue(methods.contains("findTestComponent"),
+ "downstream aggregator should include local TestComponent, was: "
+ + methods);
+ assertTrue(methods.contains("findTestComponentForConcreteTester"),
+ "downstream aggregator should include local TestComponentForConcreteTester, was: "
+ + methods);
+ assertFalse(methods.contains("findButton"),
+ "downstream aggregator should not regenerate framework entries: "
+ + methods);
+ assertFalse(methods.contains("findChart"),
+ "downstream aggregator should not regenerate framework entries: "
+ + methods);
+ }
+
+ private static Set methodNames(Method[] methods) {
+ return Arrays.stream(methods).map(Method::getName)
+ .collect(Collectors.toSet());
+ }
+}
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..09fc6233
--- /dev/null
+++ b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java
@@ -0,0 +1,496 @@
+/*
+ * 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 java.util.List;
+import java.util.NoSuchElementException;
+
+import com.example.locator.LocatorDemoView;
+import com.example.locator.LocatorDemoView.Person;
+import com.example.locator.PersonFormLocator;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import com.vaadin.browserless.internal.Routes;
+import com.vaadin.browserless.locator.Locator;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.grid.Grid;
+import com.vaadin.flow.component.html.Span;
+
+/**
+ * Demonstrates the {@code find*} locator API. The {@code Button}, {@code
+ * TextField}, {@code Grid} locator classes used here are emitted at build time
+ * by the {@code LocatorProcessor} annotation processor from the existing
+ * {@code @Tests}-annotated tester classes.
+ *
+ * Although {@code TextFieldTester} is declared as {@code }, the processor
+ * pins {@code V} per {@code @Tests} target by walking the target's supertype
+ * parameterization, so {@code findTextField()}, {@code findEmailField()}, etc.
+ * are witness-free while {@code findBigDecimalField()} carries
+ * {@code BigDecimal} automatically. The remaining witnessed entry points
+ * ({@code findGrid(Class)}, {@code findComboBox(Class)}) are testers
+ * whose value type isn't pinned at the target level.
+ */
+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.findTextField().withId("name").setValue("World");
+ window.findButton().withCaption("Save").click();
+
+ // getText() is inherited via SpanTester -> HtmlClickContainer ->
+ // HtmlContainerTester. The processor walks the supertype chain, so
+ // inherited tester methods become locator delegates too.
+ Assertions.assertEquals("Saved: World",
+ window.findSpan().withId("echo").getText());
+ }
+ }
+
+ @Test
+ void buttonByCaption_multipleButtons_filterPicksRightOne() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ window.findTextField().withId("name").setValue("X");
+ window.findButton().withCaption("Clear").click();
+
+ Assertions.assertEquals("", window.findTextField().withId("name")
+ .component().getValue());
+ }
+ }
+
+ @Test
+ void textField_setValue_thenRead_roundTrips() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ window.findTextField().withId("name").setValue("hello");
+ Assertions.assertEquals("hello", window.findTextField()
+ .withId("name").component().getValue());
+ }
+ }
+
+ @Test
+ void grid_typedRowAccessor() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ Person first = window.findGrid(Person.class).getRow(0);
+
+ Assertions.assertEquals("Alice", first.name());
+ Assertions.assertEquals(3, window.findGrid(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.findButton().withCaption("Save");
+ window.findTextField().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.findTextField().withId("name").setValue("second");
+ save.invalidate().click();
+
+ Assertions.assertEquals("Saved: second",
+ window.findSpan().withId("echo").getComponent().getText());
+ }
+ }
+
+ @Test
+ void customLocator_viaGetSupplier_composesBuiltins() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ window.find(PersonFormLocator::new).fillIn("Ada",
+ "ada@example.com");
+ window.find(PersonFormLocator::new).submit();
+
+ Assertions.assertEquals("Submitted: Ada ",
+ window.findSpan().withId("echo").getComponent().getText());
+ }
+ }
+
+ @Test
+ void filterChain_expandedSurface() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // withAttribute — every component with an id has the "id"
+ // attribute set on its element.
+ Assertions.assertTrue(window.findTextField()
+ .withAttribute("id", "name").exists());
+
+ // withCondition — typed predicate against the matched type.
+ window.findButton().withCondition(b -> "Save".equals(b.getText()))
+ .click();
+ Assertions.assertEquals("Saved: ",
+ window.findSpan().withId("echo").component().getText());
+ }
+ }
+
+ @Test
+ void filterChain_escapeHatch_unaryOperator() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // Use the escape hatch to compose a ComponentQuery-only filter
+ // (withPropertyValue is not exposed on Locator directly).
+ window.findButton()
+ .with(q -> q.withPropertyValue(Button::getText, "Clear"))
+ .click();
+
+ // Save button was untouched; Clear emptied the name field.
+ Assertions.assertEquals("", window.findTextField().withId("name")
+ .component().getValue());
+ }
+ }
+
+ @Test
+ void filterChain_escapeHatch_returnsDifferentQuery() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ window.findTextField().withId("name").setValue("Ada");
+
+ // UnaryOperator returns a fresh query — its contract is `T
+ // apply(T)`,
+ // so the locator must adopt the returned instance rather than
+ // discarding it. The fresh query has no caption filter; only the
+ // Save button matches the original Span query state we ignore by
+ // returning a new query targeting Save.
+ window.findButton().with(
+ q -> new ComponentQuery<>(Button.class).withCaption("Save"))
+ .click();
+
+ Assertions.assertEquals("Saved: Ada",
+ window.find(Span.class).withId("echo").single().getText());
+ }
+ }
+
+ @Test
+ void exists_truePathAndFalsePath() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ Assertions.assertTrue(
+ window.findTextField().withId("name").exists(),
+ "filter chain matching a real component returns true");
+ Assertions.assertFalse(
+ window.findTextField().withId("does-not-exist").exists(),
+ "filter chain matching nothing returns false");
+ }
+ }
+
+ @Test
+ void components_returnsAllMatchesAndKeepsLocatorReusable() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ var buttons = window.findButton();
+ // Save, Clear, plus PersonForm's Submit.
+ Assertions.assertEquals(3, buttons.components().size());
+
+ // components() bypasses the single-match cache, so the same
+ // instance can still resolve a specific pick afterwards.
+ Assertions.assertEquals("Save",
+ buttons.atIndex(1).component().getText());
+ }
+ }
+
+ @Test
+ void inside_componentOverload_scopesToDescendants() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ LocatorDemoView.PersonForm form = window
+ .find(LocatorDemoView.PersonForm.class).single();
+
+ // Globally there are 3 buttons in the view; scoping to the
+ // form's descendants narrows the match down to the single
+ // Submit button inside the composite.
+ Assertions.assertEquals(3, window.findButton().components().size());
+ Assertions.assertEquals(1,
+ window.findButton().inside(form).components().size());
+ }
+ }
+
+ @Test
+ void inside_locatorOverload_evaluatesParentLazily() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ class CountingFormLocator extends
+ Locator {
+ int calls = 0;
+
+ CountingFormLocator() {
+ super(LocatorDemoView.PersonForm.class);
+ }
+
+ @Override
+ public LocatorDemoView.PersonForm component() {
+ calls++;
+ return super.component();
+ }
+ }
+ var formLoc = new CountingFormLocator();
+
+ // inside(Locator) must not resolve the parent yet.
+ var childLoc = window.findButton().inside(formLoc);
+ Assertions.assertEquals(0, formLoc.calls,
+ "inside(Locator) must defer parent resolution");
+
+ // First child action resolves the parent exactly once.
+ Assertions.assertEquals(1, childLoc.components().size());
+ Assertions.assertEquals(1, formLoc.calls);
+
+ // invalidate() on the parent propagates: the next child action
+ // re-asks the parent for its component.
+ formLoc.invalidate();
+ Assertions.assertEquals(1, childLoc.components().size());
+ Assertions.assertEquals(2, formLoc.calls);
+ }
+ }
+
+ @Test
+ void inside_locatorOverload_rejectsSelfReference() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ var loc = window.findButton();
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> loc.inside(loc));
+ }
+ }
+
+ @Test
+ void use_seedsLocatorWithComponent_actionWorks() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ LocatorDemoView.PersonForm form = window
+ .find(LocatorDemoView.PersonForm.class).single();
+
+ // Caller holds direct references to the composite's children —
+ // use(...) skips the filter chain and seeds the locator with
+ // each instance.
+ window.use(form.nameField).setValue("Ada");
+ window.use(form.emailField).setValue("ada@example.com");
+ window.use(form.submit).click();
+
+ Assertions.assertEquals("Submitted: Ada ",
+ window.findSpan().withId("echo").getText());
+ }
+ }
+
+ @Test
+ void use_componentAndExistsReturnSeededInstance() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ LocatorDemoView.PersonForm form = window
+ .find(LocatorDemoView.PersonForm.class).single();
+
+ var loc = window.use(form.submit);
+ Assertions.assertSame(form.submit, loc.component());
+ Assertions.assertEquals(List.of(form.submit), loc.components());
+ Assertions.assertTrue(loc.exists());
+ // invalidate() rewinds the cache; re-resolution still matches
+ // the same instance because the identity predicate is sticky.
+ Assertions.assertSame(form.submit, loc.invalidate().component());
+ }
+ }
+
+ @Test
+ void use_additionalFilterCanExcludeSeededComponent() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ LocatorDemoView.PersonForm form = window
+ .find(LocatorDemoView.PersonForm.class).single();
+
+ // form.submit's id is "pf-submit"; an extra withId("nope")
+ // filter composes on top of the identity predicate and excludes
+ // the only matching component.
+ var loc = window.use(form.submit).withId("nope");
+ Assertions.assertFalse(loc.exists(),
+ "incompatible filter must zero out the seeded match");
+ Assertions.assertThrows(NoSuchElementException.class,
+ loc::component);
+ }
+ }
+
+ @Test
+ void use_genericTarget_carriesTypeArg() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // The demo view has a single Grid. Get a typed reference
+ // via the typed find entry, then exercise use(Grid) to bind
+ // the locator to that instance.
+ Grid grid = window.findGrid(Person.class).getComponent();
+ Person first = window.use(grid).getRow(0);
+ Assertions.assertEquals("Alice", first.name());
+ }
+ }
+
+ @Test
+ void filterChain_escapeHatch_nullReturnThrows() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // Returning null from the operator is a contract violation —
+ // fail loudly rather than silently dropping the operator's
+ // intent.
+ IllegalStateException ex = Assertions.assertThrows(
+ IllegalStateException.class,
+ () -> window.findButton().with(q -> null));
+ Assertions.assertTrue(ex.getMessage().contains("non-null"),
+ "message should explain the contract: " + ex.getMessage());
+ }
+ }
+
+ @Test
+ void atIndex_picksNthMatch() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // The demo view has multiple buttons (Save, Clear, plus Submit
+ // inside the PersonForm composite). atIndex is 1-based, so
+ // atIndex(2) targets Clear.
+ window.findTextField().withId("name").setValue("X");
+ window.findButton().atIndex(2).click();
+ Assertions.assertEquals("", window.findTextField().withId("name")
+ .component().getValue());
+ }
+ }
+
+ @Test
+ void atIndex_stickyAcrossFilterSteps_butClearedByInvalidate() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // The demo view has three buttons total: Save (1), Clear (2),
+ // PersonForm's Submit (3). atIndex(2) picks Clear.
+ var btn = window.findButton().atIndex(2);
+ Assertions.assertEquals("Clear", btn.component().getText());
+
+ // atIndex is part of the filter chain — narrowing further
+ // does NOT drop the pick. Once the chain is narrowed to a
+ // single match, atIndex(2) on a single-match query throws.
+ btn.withCaption("Clear");
+ Assertions.assertThrows(IndexOutOfBoundsException.class,
+ btn::component,
+ "pickIndex stays sticky across filter steps");
+
+ // invalidate() is the explicit rewind hatch: clears the
+ // cached resolution AND pickIndex. After it, resolution
+ // falls back to single-match, which succeeds.
+ Assertions.assertEquals("Clear",
+ btn.invalidate().component().getText());
+ }
+ }
+
+ @Test
+ void atIndex_zeroOrNegativeThrows() {
+ // No app context needed — the validation runs on the locator itself
+ // before any resolution attempt.
+ IllegalArgumentException zero = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new com.vaadin.flow.component.button.ButtonLocator()
+ .atIndex(0));
+ Assertions.assertTrue(zero.getMessage().contains("greater than zero"),
+ "message should explain the contract: " + zero.getMessage());
+
+ IllegalArgumentException negative = Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> new com.vaadin.flow.component.button.ButtonLocator()
+ .atIndex(-1));
+ Assertions.assertTrue(
+ negative.getMessage().contains("greater than zero"),
+ "message should explain the contract: "
+ + negative.getMessage());
+ }
+
+ @Test
+ void findSupplier_nullReturnThrows() {
+ try (var app = BrowserlessApplicationContext.create(routes())) {
+ var window = app.newUser().newWindow();
+ window.navigate(LocatorDemoView.class);
+
+ // Same contract as Locator.with: a null Locator from the factory
+ // is a contract violation that should surface immediately.
+ IllegalStateException ex = Assertions.assertThrows(
+ IllegalStateException.class, () -> window.find(() -> null));
+ Assertions.assertTrue(ex.getMessage().contains("non-null"),
+ "message should explain the contract: " + ex.getMessage());
+ }
+ }
+
+ @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.findTextField().withId("name").setValue("alice-value");
+ bob.findTextField().withId("name").setValue("bob-value");
+
+ // Each window's locator targets its own UI; values do not leak.
+ Assertions.assertEquals("alice-value", alice.findTextField()
+ .withId("name").component().getValue());
+ Assertions.assertEquals("bob-value",
+ bob.findTextField().withId("name").component().getValue());
+ }
+ }
+}
diff --git a/locator-processor/pom.xml b/locator-processor/pom.xml
new file mode 100644
index 00000000..b0b76dd3
--- /dev/null
+++ b/locator-processor/pom.xml
@@ -0,0 +1,36 @@
+
+
+ 4.0.0
+
+ com.vaadin
+ browserless-test
+ 1.1-SNAPSHOT
+
+ browserless-test-locator-processor
+ jar
+ Vaadin Browserless Test Locator Processor
+
+
+
+ true
+ true
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ none
+
+
+
+
+
diff --git a/locator-processor/src/main/java/com/vaadin/browserless/locator/processor/LocatorProcessor.java b/locator-processor/src/main/java/com/vaadin/browserless/locator/processor/LocatorProcessor.java
new file mode 100644
index 00000000..0dd372cf
--- /dev/null
+++ b/locator-processor/src/main/java/com/vaadin/browserless/locator/processor/LocatorProcessor.java
@@ -0,0 +1,1177 @@
+/*
+ * 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.processor;
+
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.annotation.processing.SupportedOptions;
+import javax.annotation.processing.SupportedSourceVersion;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ElementKind;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.TypeParameterElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.ExecutableType;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.type.TypeVariable;
+import javax.lang.model.util.SimpleTypeVisitor14;
+import javax.lang.model.util.Types;
+import javax.tools.Diagnostic;
+import javax.tools.JavaFileObject;
+
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+/**
+ * Annotation processor that walks {@code @Tests}-annotated
+ * {@code ComponentTester} subclasses in the compilation unit and emits a
+ * sibling {@code *Locator} class for each, plus one or more entry-point
+ * interfaces exposing a typed {@code find()} default method per
+ * locator (one interface for core entries and an optional separate one for
+ * entries whose target lives in a configured commercial package).
+ *
+ *
+ * Generated source uses fully-qualified type names everywhere to avoid the
+ * complexity of import management. The output compiles cleanly, just verbosely.
+ *
+ *
+ * Internal build tool. This artifact is not published; it is
+ * consumed only by other modules in this repository. The processor options are
+ * an internal contract — break them freely if a refactor benefits, no
+ * deprecation cycle is owed to end users.
+ */
+@SupportedAnnotationTypes("com.vaadin.browserless.Tests")
+@SupportedOptions({ "locator.commercial.packages", "locator.entrypoint.fqn",
+ "locator.commercial.entrypoint.fqn" })
+@SupportedSourceVersion(SourceVersion.RELEASE_21)
+public class LocatorProcessor extends AbstractProcessor {
+
+ private static final String TESTS_FQN = "com.vaadin.browserless.Tests";
+ private static final String COMPONENT_TESTER_FQN = "com.vaadin.browserless.ComponentTester";
+ private static final String LOCATOR_FQN = "com.vaadin.browserless.locator.Locator";
+ private static final String CLICKABLE_FQN = "com.vaadin.browserless.Clickable";
+ private static final String OPT_COMMERCIAL_PACKAGES = "locator.commercial.packages";
+ private static final String OPT_ENTRYPOINT_FQN = "locator.entrypoint.fqn";
+ private static final String OPT_COMMERCIAL_ENTRYPOINT_FQN = "locator.commercial.entrypoint.fqn";
+ private static final String DEFAULT_ENTRYPOINT_FQN = "com.vaadin.browserless.locator.GeneratedLocators";
+ private static final String DEFAULT_COMMERCIAL_ENTRYPOINT_FQN = "com.vaadin.browserless.locator.GeneratedCommercialLocators";
+ private static final List DEFAULT_COMMERCIAL_PACKAGES = List
+ .of("com.vaadin.flow.component.charts");
+
+ /**
+ * Public methods that we never delegate from the locator. These belong to
+ * the {@code ComponentTester} base machinery (the locator provides its own
+ * resolution + usability surface) or to the locator's own filter chain.
+ *
+ * {@code click}, {@code middleClick} and {@code rightClick} are
+ * not skipped: a tester override is delegated like any other
+ * method, and when no tester in the chain declares them the locator picks
+ * them up from its own {@code implements Clickable}. The supertype walk
+ * below stops at {@code ComponentTester}, so {@code Clickable}'s
+ * interface-level defaults are never harvested as delegates.
+ */
+ private static final Set METHOD_SKIP_LIST = Set.of("getComponent",
+ "isUsable", "setModal", "find", "ensureComponentIsUsable");
+
+ /** Collected entries used to emit {@code GeneratedLocators}. */
+ private final List entries = new ArrayList<>();
+ private boolean wroteEntryInterface = false;
+
+ @Override
+ public boolean process(Set extends TypeElement> annotations,
+ RoundEnvironment roundEnv) {
+ if (roundEnv.processingOver()) {
+ return false;
+ }
+
+ TypeElement testsAnno = processingEnv.getElementUtils()
+ .getTypeElement(TESTS_FQN);
+ if (testsAnno == null) {
+ return false;
+ }
+
+ for (Element e : roundEnv.getElementsAnnotatedWith(testsAnno)) {
+ if (e.getKind() != ElementKind.CLASS) {
+ continue;
+ }
+ TypeElement tester = (TypeElement) e;
+ try {
+ processTester(tester);
+ } catch (Exception ex) {
+ note(Diagnostic.Kind.WARNING, "Locator generation skipped for "
+ + tester.getQualifiedName() + ": " + ex.getMessage());
+ }
+ }
+
+ // Emit the entry-point interface once we have collected entries. Doing
+ // it in the first non-final round (rather than at processingOver)
+ // ensures the generated file is compiled in this build.
+ if (!entries.isEmpty() && !wroteEntryInterface) {
+ writeEntryPointInterface();
+ wroteEntryInterface = true;
+ }
+ return false;
+ }
+
+ /**
+ * Inspect a tester element and emit one locator per {@code @Tests} target
+ * (value or fqn). For each target, the locator's tester type variables
+ * (past the first) are pinned to concrete types when the target's supertype
+ * parameterization fixes them — this turns e.g.
+ * {@code getTextField(Class)} into a clean {@code getTextField()} for
+ * {@code TextField} (V=String) while still requiring a witness for
+ * {@code Grid} (V free per use).
+ */
+ private void processTester(TypeElement tester) {
+ if (!extendsComponentTester(tester)) {
+ return;
+ }
+ if (tester.getModifiers().contains(Modifier.ABSTRACT)) {
+ return;
+ }
+
+ List extraTypeParams = tester.getTypeParameters()
+ .stream().skip(1).collect(Collectors.toList());
+
+ List targets = readTestsTargets(tester);
+ if (targets.isEmpty()) {
+ // Mirror the runtime tester-scan, which ignores testers without
+ // an explicit @Tests target.
+ note(Diagnostic.Kind.NOTE, "No @Tests target for "
+ + tester.getQualifiedName() + "; skipping.");
+ return;
+ }
+
+ for (TypeElement target : targets) {
+ Entry entry = generateLocatorForTarget(tester, target,
+ extraTypeParams);
+ if (entry != null) {
+ entries.add(entry);
+ }
+ }
+ }
+
+ /**
+ * Generate one locator class targeting {@code target}. Extras that get
+ * pinned by walking {@code target}'s supertypes for the tester's bound head
+ * class are removed from the locator's type parameter list and substituted
+ * in method signatures.
+ */
+ private Entry generateLocatorForTarget(TypeElement tester,
+ TypeElement target, List extraTypeParams) {
+ String testerSimple = tester.getSimpleName().toString();
+ String testerPkg = processingEnv.getElementUtils().getPackageOf(tester)
+ .getQualifiedName().toString();
+ if (testerPkg.isEmpty()) {
+ // Filer.createSourceFile cannot place a class in the default
+ // package and the generated source would emit a malformed
+ // "package ;" declaration anyway. Surface this clearly instead
+ // of letting it disappear into the catch below.
+ note(Diagnostic.Kind.ERROR,
+ "Cannot generate locator for tester '" + testerSimple
+ + "': testers in the default (unnamed) package are"
+ + " not supported. Move " + testerSimple
+ + " into a named package.");
+ return null;
+ }
+
+ String targetPkg = processingEnv.getElementUtils().getPackageOf(target)
+ .getQualifiedName().toString();
+ String targetSimple = target.getSimpleName().toString();
+ String locatorSimple = targetSimple + "Locator";
+
+ Map pinned = pinExtras(tester, target,
+ extraTypeParams);
+
+ // Locator's own type parameter list = extras that were not pinned.
+ List freeExtras = extraTypeParams.stream().filter(
+ tp -> !pinned.containsKey(tp.getSimpleName().toString()))
+ .collect(Collectors.toList());
+
+ // Component type expression for `Locator`. The target may
+ // have its own type parameters; substitute them positionally with the
+ // locator's free extras (matches the GridTester / ComboBoxTester
+ // pattern where the tester's extra Y maps onto the target's T).
+ String componentTypeExpr = renderTargetTypeExpr(target, freeExtras);
+
+ String locatorTypeParamDecl = renderTypeParamDecl(freeExtras);
+ String locatorTypeParamUse = renderTypeParamUse(freeExtras);
+ String selfType = locatorSimple + locatorTypeParamUse;
+
+ // Substitution map for pinned tester type variables. The first tester
+ // variable (the component) maps to the target's parameterized form so
+ // the tester is constructed with concrete type arguments.
+ Map subst = new HashMap<>();
+ if (!tester.getTypeParameters().isEmpty()) {
+ subst.put(tester.getTypeParameters().getFirst().getSimpleName()
+ .toString(), componentTypeExpr);
+ }
+ for (Map.Entry e : pinned.entrySet()) {
+ subst.put(e.getKey(), typeExpr(e.getValue()));
+ }
+
+ // Explicit tester type arguments: concrete value for each tester type
+ // parameter. Built from `subst`.
+ String testerTypeArgs = "<" + tester.getTypeParameters().stream()
+ .map(tp -> subst.getOrDefault(tp.getSimpleName().toString(),
+ tp.getSimpleName().toString()))
+ .collect(Collectors.joining(", ")) + ">";
+ String testerCtor = testerPkg + "." + testerSimple
+ + (tester.getTypeParameters().isEmpty() ? "" : testerTypeArgs);
+
+ // Method delegates: walk the supertype chain so methods declared on
+ // intermediate base testers (e.g. HtmlContainerTester.getText()) show
+ // up on the locator too. Leaf overrides win on signature collision.
+ // Stops at ComponentTester so its base-machinery members (and the
+ // Clickable interface defaults it inherits) are not delegated.
+ StringBuilder methodSrc = new StringBuilder();
+ Types types = processingEnv.getTypeUtils();
+ TypeElement componentTesterEl = processingEnv.getElementUtils()
+ .getTypeElement(COMPONENT_TESTER_FQN);
+ DeclaredType testerType = (DeclaredType) tester.asType();
+ LinkedHashMap inherited = new LinkedHashMap<>();
+ collectDelegateMethods(tester, componentTesterEl, inherited);
+ for (ExecutableElement m : inherited.values()) {
+ ExecutableType resolved = (ExecutableType) types
+ .asMemberOf(testerType, m);
+ methodSrc.append(renderDelegate(m, resolved, testerCtor, subst));
+ }
+
+ // Constructor: takes Class witnesses only for the free extras. The
+ // raw-cast in renderSuperArg fires only when the target has its own
+ // type parameters; that's the single line that needs the unchecked
+ // /rawtypes suppression. Scope the annotation to the constructor so
+ // genuine warnings elsewhere in the generated class still surface.
+ String ctor;
+ String superArg = renderSuperArg(target, freeExtras);
+ boolean needsRawCast = !target.getTypeParameters().isEmpty();
+ String ctorAnno = needsRawCast
+ ? " @SuppressWarnings({\"unchecked\", \"rawtypes\"})\n"
+ : "";
+ if (freeExtras.isEmpty()) {
+ ctor = ctorAnno + " public " + locatorSimple + "() {\n"
+ + " super(" + superArg + ");\n" + " }\n";
+ } else {
+ String params = freeExtras.stream()
+ .map(tp -> "java.lang.Class<" + tp.getSimpleName() + "> "
+ + decap(tp.getSimpleName().toString()) + "Type")
+ .collect(Collectors.joining(", "));
+ ctor = ctorAnno + " public " + locatorSimple + "(" + params
+ + ") {\n" + " super(" + superArg + ");\n"
+ + " }\n";
+ }
+ // Seeded-query constructor: takes a direct component reference.
+ // Shares the raw-cast workaround with the no-component constructor
+ // because the first super-arg is the same Class expression.
+ String useCtor = ctorAnno + " public " + locatorSimple + "("
+ + componentTypeExpr + " component) {\n super(" + superArg
+ + ", component);\n }\n";
+
+ String fqn = testerPkg + "." + locatorSimple;
+ try {
+ JavaFileObject jfo = processingEnv.getFiler().createSourceFile(fqn,
+ tester);
+ try (Writer w = jfo.openWriter();
+ PrintWriter out = new PrintWriter(w)) {
+ out.println(
+ "/* Generated by LocatorProcessor. Do not edit. */");
+ out.println("package " + testerPkg + ";");
+ out.println();
+ out.print(renderClassJavadoc(target, tester));
+ out.println("@javax.annotation.processing.Generated(\""
+ + LocatorProcessor.class.getName() + "\")");
+ out.println("public class " + locatorSimple
+ + locatorTypeParamDecl + " extends " + LOCATOR_FQN + "<"
+ + componentTypeExpr + ", " + selfType + "> implements "
+ + CLICKABLE_FQN + "<" + componentTypeExpr + "> {");
+ out.println();
+ out.println(ctor);
+ out.println(useCtor);
+ out.println(" @Override");
+ out.println(" public " + componentTypeExpr
+ + " getComponent() { return component(); }");
+ out.println();
+ out.println(
+ " @Override public void ensureComponentIsUsable() {");
+ out.println(" new " + testerCtor + "(component())"
+ + ".ensureComponentIsUsable();");
+ out.println(" }");
+ out.println();
+ out.print(methodSrc);
+ out.println("}");
+ }
+ } catch (Exception ioe) {
+ note(Diagnostic.Kind.ERROR,
+ "Failed to write " + fqn + ": " + ioe.getMessage());
+ return null;
+ }
+
+ String entryMethodName = "find" + targetSimple;
+ boolean targetIsPublic = target.getModifiers()
+ .contains(Modifier.PUBLIC);
+ return new Entry(testerPkg, targetPkg, locatorSimple,
+ locatorTypeParamDecl, locatorTypeParamUse, freeExtras,
+ entryMethodName, componentTypeExpr, targetIsPublic);
+ }
+
+ /**
+ * Read the {@code @Tests} annotation on the tester, returning the targets
+ * listed in {@code value()} together with classes resolved from
+ * {@code fqn()}. Both forms are supported because Vaadin testers use a mix.
+ */
+ @SuppressWarnings("unchecked")
+ private List readTestsTargets(TypeElement tester) {
+ List result = new ArrayList<>();
+ for (AnnotationMirror am : tester.getAnnotationMirrors()) {
+ if (!am.getAnnotationType().toString().equals(TESTS_FQN)) {
+ continue;
+ }
+ for (Map.Entry extends ExecutableElement, ? extends AnnotationValue> entry : am
+ .getElementValues().entrySet()) {
+ String name = entry.getKey().getSimpleName().toString();
+ if (name.equals("value")) {
+ List extends AnnotationValue> list = (List extends AnnotationValue>) entry
+ .getValue().getValue();
+ for (AnnotationValue v : list) {
+ TypeMirror tm = (TypeMirror) v.getValue();
+ if (tm.getKind() == TypeKind.DECLARED) {
+ result.add((TypeElement) ((DeclaredType) tm)
+ .asElement());
+ }
+ }
+ } else if (name.equals("fqn")) {
+ List extends AnnotationValue> list = (List extends AnnotationValue>) entry
+ .getValue().getValue();
+ for (AnnotationValue v : list) {
+ String fqn = (String) v.getValue();
+ TypeElement te = processingEnv.getElementUtils()
+ .getTypeElement(fqn);
+ if (te != null) {
+ result.add(te);
+ }
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * For each tester extra (the type variables past the first), walk the
+ * target's supertype chain to find the tester's bound head class (e.g.
+ * {@code TextFieldBase} or {@code Grid}). The position of the extra in the
+ * tester's bound determines which type argument on the target's
+ * parameterization to pin against.
+ */
+ private Map pinExtras(TypeElement tester,
+ TypeElement target, List extraTypeParams) {
+ Map pinned = new HashMap<>();
+ if (extraTypeParams.isEmpty() || tester.getTypeParameters().isEmpty()) {
+ return pinned;
+ }
+ TypeMirror firstBound = tester.getTypeParameters().getFirst()
+ .getBounds().getFirst();
+ if (firstBound.getKind() != TypeKind.DECLARED) {
+ return pinned;
+ }
+ DeclaredType firstBoundDt = (DeclaredType) firstBound;
+ TypeMirror boundHeadErasure = processingEnv.getTypeUtils()
+ .erasure(firstBoundDt);
+
+ // Position of each extra in the tester's bound type args.
+ Map extraPositions = new HashMap<>();
+ List extends TypeMirror> boundArgs = firstBoundDt.getTypeArguments();
+ for (int i = 0; i < boundArgs.size(); i++) {
+ TypeMirror arg = boundArgs.get(i);
+ if (arg.getKind() == TypeKind.TYPEVAR) {
+ String name = ((TypeVariable) arg).asElement().getSimpleName()
+ .toString();
+ for (TypeParameterElement tp : extraTypeParams) {
+ if (tp.getSimpleName().contentEquals(name)) {
+ extraPositions.put(name, i);
+ }
+ }
+ }
+ }
+
+ // Find the target's parameterization of the bound head class.
+ TypeMirror targetAsHead = findInstanceOf(target.asType(),
+ boundHeadErasure);
+ if (targetAsHead == null
+ || targetAsHead.getKind() != TypeKind.DECLARED) {
+ return pinned;
+ }
+ DeclaredType targetAsHeadDt = (DeclaredType) targetAsHead;
+ List extends TypeMirror> targetArgs = targetAsHeadDt
+ .getTypeArguments();
+
+ for (TypeParameterElement extra : extraTypeParams) {
+ String name = extra.getSimpleName().toString();
+ Integer pos = extraPositions.get(name);
+ if (pos == null || pos >= targetArgs.size()) {
+ continue;
+ }
+ TypeMirror actual = targetArgs.get(pos);
+ if (actual.getKind() == TypeKind.TYPEVAR) {
+ continue; // not concrete — leave free
+ }
+ if (actual.getKind() == TypeKind.DECLARED
+ || actual.getKind() == TypeKind.ARRAY) {
+ pinned.put(name, actual);
+ }
+ }
+ return pinned;
+ }
+
+ private TypeMirror findInstanceOf(TypeMirror tm, TypeMirror targetErasure) {
+ if (tm == null || tm.getKind() != TypeKind.DECLARED) {
+ return null;
+ }
+ if (processingEnv.getTypeUtils().isSameType(
+ processingEnv.getTypeUtils().erasure(tm), targetErasure)) {
+ return tm;
+ }
+ for (TypeMirror sup : processingEnv.getTypeUtils()
+ .directSupertypes(tm)) {
+ TypeMirror result = findInstanceOf(sup, targetErasure);
+ if (result != null) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Render the target as a fully-qualified type expression. If the target
+ * declares type parameters of its own, substitute them positionally with
+ * the locator's free extras (which is the right thing for {@code Grid}
+ * and {@code ComboBox} where the tester forwards its row/value type).
+ */
+ private String renderTargetTypeExpr(TypeElement target,
+ List freeExtras) {
+ String fqn = target.getQualifiedName().toString();
+ List extends TypeParameterElement> targetTps = target
+ .getTypeParameters();
+ if (targetTps.isEmpty()) {
+ return fqn;
+ }
+ StringBuilder sb = new StringBuilder(fqn);
+ sb.append('<');
+ for (int i = 0; i < targetTps.size(); i++) {
+ if (i > 0) {
+ sb.append(", ");
+ }
+ if (i < freeExtras.size()) {
+ sb.append(freeExtras.get(i).getSimpleName());
+ } else {
+ sb.append('?');
+ }
+ }
+ sb.append('>');
+ return sb.toString();
+ }
+
+ private boolean extendsComponentTester(TypeElement tester) {
+ TypeElement componentTester = processingEnv.getElementUtils()
+ .getTypeElement(COMPONENT_TESTER_FQN);
+ if (componentTester == null) {
+ return false;
+ }
+ TypeMirror componentTesterErasure = processingEnv.getTypeUtils()
+ .erasure(componentTester.asType());
+ TypeMirror sup = tester.getSuperclass();
+ while (sup.getKind() == TypeKind.DECLARED) {
+ if (processingEnv.getTypeUtils().isSameType(
+ processingEnv.getTypeUtils().erasure(sup),
+ componentTesterErasure)) {
+ return true;
+ }
+ sup = ((TypeElement) ((DeclaredType) sup).asElement())
+ .getSuperclass();
+ }
+ return false;
+ }
+
+ /**
+ * Walk the tester's superclass chain (stopping at {@code ComponentTester}
+ * and {@code Object}), collecting methods eligible for delegation. Leaf
+ * classes are visited first, so an inherited method whose erased signature
+ * matches a leaf override is skipped — the leaf's version wins.
+ */
+ private void collectDelegateMethods(TypeElement type,
+ TypeElement componentTesterEl,
+ LinkedHashMap collected) {
+ if (type == null
+ || type.getQualifiedName().contentEquals("java.lang.Object")) {
+ return;
+ }
+ Types types = processingEnv.getTypeUtils();
+ if (componentTesterEl != null
+ && types.isSameType(types.erasure(type.asType()),
+ types.erasure(componentTesterEl.asType()))) {
+ return;
+ }
+ for (Element member : type.getEnclosedElements()) {
+ if (member.getKind() != ElementKind.METHOD) {
+ continue;
+ }
+ ExecutableElement m = (ExecutableElement) member;
+ if (!m.getModifiers().contains(Modifier.PUBLIC)) {
+ continue;
+ }
+ if (m.getModifiers().contains(Modifier.STATIC)) {
+ continue;
+ }
+ if (METHOD_SKIP_LIST.contains(m.getSimpleName().toString())) {
+ continue;
+ }
+ collected.putIfAbsent(erasedSignatureKey(m), m);
+ }
+ TypeMirror sup = type.getSuperclass();
+ if (sup.getKind() == TypeKind.DECLARED) {
+ collectDelegateMethods(
+ (TypeElement) ((DeclaredType) sup).asElement(),
+ componentTesterEl, collected);
+ }
+ }
+
+ private String erasedSignatureKey(ExecutableElement m) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(m.getSimpleName()).append('(');
+ List extends VariableElement> params = m.getParameters();
+ for (int i = 0; i < params.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(processingEnv.getTypeUtils()
+ .erasure(params.get(i).asType()));
+ }
+ sb.append(')');
+ return sb.toString();
+ }
+
+ private String renderDelegate(ExecutableElement m, ExecutableType resolved,
+ String testerCtor, Map subst) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(renderJavadoc(m));
+ // Method type parameters
+ if (!m.getTypeParameters().isEmpty()) {
+ sb.append(" public <");
+ sb.append(m.getTypeParameters().stream()
+ .map(tp -> renderTypeParamWithSubst(tp, subst))
+ .collect(Collectors.joining(", ")));
+ sb.append("> ");
+ } else {
+ sb.append(" public ");
+ }
+ sb.append(typeExpr(resolved.getReturnType(), subst)).append(' ')
+ .append(m.getSimpleName()).append('(');
+ // Parameters: take names from the element, types from the resolved
+ // ExecutableType so type variables inherited from intermediate base
+ // testers are rebound through the leaf tester's declaration.
+ List extends VariableElement> params = m.getParameters();
+ List extends TypeMirror> resolvedParams = resolved
+ .getParameterTypes();
+ StringBuilder paramNames = new StringBuilder();
+ for (int i = 0; i < params.size(); i++) {
+ VariableElement p = params.get(i);
+ TypeMirror pt = resolvedParams.get(i);
+ String pType = m.isVarArgs() && i == params.size() - 1
+ ? varargTypeExpr(pt, subst)
+ : typeExpr(pt, subst);
+ if (i > 0) {
+ sb.append(", ");
+ paramNames.append(", ");
+ }
+ sb.append("final ").append(pType).append(' ')
+ .append(p.getSimpleName());
+ paramNames.append(p.getSimpleName());
+ }
+ sb.append(')');
+ // Throws clause
+ if (!resolved.getThrownTypes().isEmpty()) {
+ sb.append(" throws ");
+ sb.append(resolved.getThrownTypes().stream()
+ .map(t -> typeExpr(t, subst))
+ .collect(Collectors.joining(", ")));
+ }
+ sb.append(" {\n");
+ // Body
+ sb.append(" ");
+ if (resolved.getReturnType().getKind() != TypeKind.VOID) {
+ sb.append("return ");
+ }
+ sb.append("new ").append(testerCtor).append("(component()).");
+ if (!m.getTypeParameters().isEmpty()) {
+ sb.append('<');
+ sb.append(m.getTypeParameters().stream()
+ .map(tp -> tp.getSimpleName().toString())
+ .collect(Collectors.joining(", ")));
+ sb.append('>');
+ }
+ sb.append(m.getSimpleName()).append('(').append(paramNames)
+ .append(");\n");
+ sb.append(" }\n\n");
+ return sb.toString();
+ }
+
+ private String renderClassJavadoc(TypeElement target, TypeElement tester) {
+ String targetFqn = target.getQualifiedName().toString();
+ String testerFqn = tester.getQualifiedName().toString();
+ StringBuilder sb = new StringBuilder();
+ sb.append("/**\n");
+ sb.append(" * Generated locator for {@link ").append(targetFqn)
+ .append("}, derived from\n");
+ sb.append(" * {@link ").append(testerFqn)
+ .append("}. Filter steps are inherited from\n");
+ sb.append(" * {@link ").append(LOCATOR_FQN)
+ .append("}; action methods delegate to a fresh tester\n");
+ sb.append(
+ " * around the resolved component, so behavioral changes belong on the\n");
+ sb.append(" * tester, not here.\n");
+ sb.append(" */\n");
+ return sb.toString();
+ }
+
+ private String renderJavadoc(ExecutableElement m) {
+ TypeElement declaring = (TypeElement) m.getEnclosingElement();
+ String linkRef = buildLinkRef(m, declaring);
+ String doc = processingEnv.getElementUtils().getDocComment(m);
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(" /**\n");
+ if (doc != null && !doc.isBlank()) {
+ // getDocComment strips the leading "*" markers but keeps a single
+ // leading space on each line; drop it before re-emitting.
+ String[] raw = doc.stripTrailing().split("\\R", -1);
+ String[] lines = new String[raw.length];
+ int firstTag = raw.length;
+ for (int i = 0; i < raw.length; i++) {
+ lines[i] = raw[i].startsWith(" ") ? raw[i].substring(1)
+ : raw[i];
+ if (firstTag == raw.length && lines[i].startsWith("@")) {
+ firstTag = i;
+ }
+ }
+ int descEnd = firstTag;
+ while (descEnd > 0 && lines[descEnd - 1].isEmpty()) {
+ descEnd--;
+ }
+ for (int i = 0; i < descEnd; i++) {
+ appendDocLine(sb, lines[i]);
+ }
+ if (descEnd > 0) {
+ sb.append(" *\n");
+ }
+ sb.append(" * Javadoc copied from {@link ").append(linkRef)
+ .append("}.\n");
+ if (firstTag < raw.length) {
+ sb.append(" *\n");
+ for (int i = firstTag; i < raw.length; i++) {
+ appendDocLine(sb, lines[i]);
+ }
+ }
+ } else {
+ sb.append(" * Delegates to {@link ").append(linkRef)
+ .append("}.\n");
+ }
+ sb.append(" */\n");
+ return sb.toString();
+ }
+
+ private void appendDocLine(StringBuilder sb, String line) {
+ if (line.isEmpty()) {
+ sb.append(" *\n");
+ } else {
+ sb.append(" * ").append(line).append('\n');
+ }
+ }
+
+ private String buildLinkRef(ExecutableElement m, TypeElement tester) {
+ String testerFqn = tester.getQualifiedName().toString();
+ String params = m
+ .getParameters().stream().map(p -> processingEnv.getTypeUtils()
+ .erasure(p.asType()).toString())
+ .collect(Collectors.joining(","));
+ return testerFqn + "#" + m.getSimpleName() + "(" + params + ")";
+ }
+
+ private String renderTypeParamWithSubst(TypeParameterElement tp,
+ Map subst) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(tp.getSimpleName());
+ List extends TypeMirror> bounds = tp.getBounds();
+ if (!bounds.isEmpty()
+ && !bounds.getFirst().toString().equals("java.lang.Object")) {
+ sb.append(" extends ");
+ sb.append(bounds.stream().map(t -> typeExpr(t, subst))
+ .collect(Collectors.joining(" & ")));
+ }
+ return sb.toString();
+ }
+
+ private String renderTypeParam(TypeParameterElement tp) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(tp.getSimpleName());
+ List extends TypeMirror> bounds = tp.getBounds();
+ if (!bounds.isEmpty()
+ && !bounds.getFirst().toString().equals("java.lang.Object")) {
+ sb.append(" extends ");
+ sb.append(bounds.stream().map(this::typeExpr)
+ .collect(Collectors.joining(" & ")));
+ }
+ return sb.toString();
+ }
+
+ private String renderTypeParamDecl(List tps) {
+ if (tps.isEmpty()) {
+ return "";
+ }
+ return "<" + tps.stream().map(this::renderTypeParam)
+ .collect(Collectors.joining(", ")) + ">";
+ }
+
+ private String renderTypeParamUse(List tps) {
+ if (tps.isEmpty()) {
+ return "";
+ }
+ return "<" + tps.stream().map(t -> t.getSimpleName().toString())
+ .collect(Collectors.joining(", ")) + ">";
+ }
+
+ private String diamond(List extends TypeParameterElement> tps) {
+ return tps.isEmpty() ? "" : "<>";
+ }
+
+ /**
+ * Produces the argument passed to {@code super(...)} when constructing a
+ * locator. For non-generic targets this is just {@code Target.class}; for
+ * targets that have type parameters not all pinned by the locator, we use a
+ * raw class literal cast (the cast is compile-time only).
+ */
+ private String renderSuperArg(TypeElement target,
+ List freeExtras) {
+ String fqn = target.getQualifiedName().toString();
+ if (target.getTypeParameters().isEmpty() && freeExtras.isEmpty()) {
+ return fqn + ".class";
+ }
+ if (target.getTypeParameters().isEmpty()) {
+ // Target itself is non-generic but we are still parameterizing the
+ // locator with free extras. Plain class literal still works.
+ return fqn + ".class";
+ }
+ return "(java.lang.Class) " + fqn + ".class";
+ }
+
+ /**
+ * Render a type mirror, substituting any tester-private type variables with
+ * their pinned concrete types via {@code subst}.
+ */
+ private String typeExpr(TypeMirror tm, Map subst) {
+ if (subst == null || subst.isEmpty()) {
+ return tm.toString();
+ }
+ return new SubstitutingTypeRenderer(subst).visit(tm);
+ }
+
+ private String typeExpr(TypeMirror tm) {
+ return tm.toString();
+ }
+
+ private String varargTypeExpr(TypeMirror tm, Map subst) {
+ String s = typeExpr(tm, subst);
+ if (s.endsWith("[]")) {
+ return s.substring(0, s.length() - 2) + "...";
+ }
+ return s;
+ }
+
+ /**
+ * Type renderer that substitutes tester-private type variables with their
+ * pinned concrete types. Used so a method like {@code setValue(V)} on the
+ * tester becomes {@code setValue(String)} on a {@code TextField} locator.
+ */
+ private static final class SubstitutingTypeRenderer
+ extends SimpleTypeVisitor14 {
+
+ private final Map subst;
+
+ SubstitutingTypeRenderer(Map subst) {
+ this.subst = subst;
+ }
+
+ @Override
+ public String visitTypeVariable(TypeVariable t, Void unused) {
+ String name = t.asElement().getSimpleName().toString();
+ return subst.getOrDefault(name, name);
+ }
+
+ @Override
+ public String visitDeclared(DeclaredType t, Void unused) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(((TypeElement) t.asElement()).getQualifiedName());
+ if (!t.getTypeArguments().isEmpty()) {
+ sb.append('<');
+ sb.append(t.getTypeArguments().stream()
+ .map(arg -> arg.accept(this, null))
+ .collect(Collectors.joining(", ")));
+ sb.append('>');
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public String visitArray(javax.lang.model.type.ArrayType t, Void v) {
+ return t.getComponentType().accept(this, null) + "[]";
+ }
+
+ @Override
+ public String visitWildcard(javax.lang.model.type.WildcardType t,
+ Void v) {
+ StringBuilder sb = new StringBuilder("?");
+ if (t.getExtendsBound() != null) {
+ sb.append(" extends ")
+ .append(t.getExtendsBound().accept(this, null));
+ }
+ if (t.getSuperBound() != null) {
+ sb.append(" super ")
+ .append(t.getSuperBound().accept(this, null));
+ }
+ return sb.toString();
+ }
+
+ @Override
+ protected String defaultAction(TypeMirror t, Void v) {
+ return t.toString();
+ }
+ }
+
+ private void writeEntryPointInterface() {
+ List commercialPrefixes = readCommercialPrefixes();
+ List core = new ArrayList<>();
+ List commercial = new ArrayList<>();
+ for (Entry e : entries) {
+ if (isCommercial(e, commercialPrefixes)) {
+ commercial.add(e);
+ } else {
+ core.add(e);
+ }
+ }
+ if (!core.isEmpty()) {
+ writeEntryPointIfConfigured(OPT_ENTRYPOINT_FQN,
+ DEFAULT_ENTRYPOINT_FQN, core);
+ }
+ if (!commercial.isEmpty()) {
+ writeEntryPointIfConfigured(OPT_COMMERCIAL_ENTRYPOINT_FQN,
+ DEFAULT_COMMERCIAL_ENTRYPOINT_FQN, commercial);
+ }
+ }
+
+ /**
+ * Resolve the entry-point FQN from a processor option, falling back to the
+ * framework default. When the option is unset AND the default FQN already
+ * exists on the classpath, the interface is not written — this is the
+ * "end-user build pulling in shared.jar" scenario, where overwriting the
+ * framework interface would lose all the upstream entry methods. We emit a
+ * clear warning so the user knows to set the option.
+ */
+ private void writeEntryPointIfConfigured(String optionKey,
+ String defaultFqn, List entriesToWrite) {
+ String configured = processingEnv.getOptions().get(optionKey);
+ String fqn = configured != null && !configured.isBlank() ? configured
+ : defaultFqn;
+ boolean usingDefault = configured == null || configured.isBlank();
+ if (usingDefault && processingEnv.getElementUtils()
+ .getTypeElement(fqn) != null) {
+ note(Diagnostic.Kind.WARNING,
+ fqn + " is already on the classpath; skipping generation."
+ + " To emit a project-specific entry-point"
+ + " interface, set -A" + optionKey
+ + "=.");
+ return;
+ }
+ int lastDot = fqn.lastIndexOf('.');
+ if (lastDot < 0) {
+ note(Diagnostic.Kind.ERROR,
+ "Entry-point FQN must be qualified: " + fqn);
+ return;
+ }
+ writeInterface(fqn.substring(0, lastDot), fqn.substring(lastDot + 1),
+ entriesToWrite);
+ }
+
+ private List readCommercialPrefixes() {
+ String opt = processingEnv.getOptions().get(OPT_COMMERCIAL_PACKAGES);
+ if (opt == null || opt.isBlank()) {
+ return DEFAULT_COMMERCIAL_PACKAGES;
+ }
+ return Arrays.stream(opt.split(",")).map(String::trim)
+ .filter(s -> !s.isEmpty()).collect(Collectors.toList());
+ }
+
+ private boolean isCommercial(Entry e, List prefixes) {
+ // "Commercial" is a property of the target component (Chart lives in
+ // a commercial module), not of the tester that wraps it. Match
+ // against the target's package so a user-written commercial tester
+ // located in their own package is still routed correctly.
+ return prefixes.stream().anyMatch(
+ p -> e.targetPkg.equals(p) || e.targetPkg.startsWith(p + "."));
+ }
+
+ private void writeInterface(String pkg, String simpleName,
+ List interfaceEntries) {
+ String fqn = pkg + "." + simpleName;
+ try {
+ JavaFileObject jfo = processingEnv.getFiler().createSourceFile(fqn);
+ try (Writer w = jfo.openWriter();
+ PrintWriter out = new PrintWriter(w)) {
+ out.println(
+ "/* Generated by LocatorProcessor. Do not edit. */");
+ out.println("package " + pkg + ";");
+ out.println();
+ out.println("/**");
+ out.println(
+ " * Generated mixin: per registered {@code ComponentTester} it exposes a typed");
+ out.println(
+ " * {@code find()} entry that opens a fresh query, and a companion");
+ out.println(
+ " * {@code use( component)} entry that seeds a locator with a direct");
+ out.println(
+ " * reference to an already-resolved component. Both return the same");
+ out.println(
+ " * {@code *Locator} type, so chaining further filter steps and action methods");
+ out.println(" * works identically.");
+ out.println(" *");
+ out.println(
+ " *
Not consumed directly — extend it from your locator context mixin");
+ out.println(
+ " * (the one that implements {@link #activateLocatorContext()}).");
+ out.println(" */");
+ out.println("@javax.annotation.processing.Generated(\""
+ + LocatorProcessor.class.getName() + "\")");
+ out.println("public interface " + simpleName + " {");
+ out.println();
+ out.println(" /**");
+ out.println(
+ " * Hook for context-bound implementations to install Vaadin thread-locals");
+ out.println(" * before a locator is built.");
+ out.println(" */");
+ out.println(" void activateLocatorContext();");
+ out.println();
+ // Detect collisions on the bare entry-method name. The name
+ // is derived from the target component's simple name, so a
+ // clash means either two @Tests targets share a simple name
+ // (across packages) or two testers target the same component
+ // — both of which the processor treats as a real problem in
+ // the tester set. Keying on the name alone (rather than name
+ // + witness arity) also catches the case where two testers
+ // for the same target differ in free type-parameter arity:
+ // their find() overloads would coexist, but their use(X)
+ // companions erase to the same signature and would fail to
+ // compile. Surface it as an ERROR — silently dropping the
+ // duplicate hides a real problem; one of them has to give.
+ TreeMap unique = new TreeMap<>();
+ LinkedHashMap seenMethods = new LinkedHashMap<>();
+ for (Entry e : interfaceEntries) {
+ String key = e.entryMethodName;
+ Entry prior = seenMethods.putIfAbsent(key, e);
+ if (prior == null) {
+ unique.put(e.pkg + "." + e.locatorSimple, e);
+ } else {
+ note(Diagnostic.Kind.ERROR,
+ "Entry-method collision: '" + e.entryMethodName
+ + "()' is generated for both '"
+ + prior.pkg + "." + prior.locatorSimple
+ + "' and '" + e.pkg + "."
+ + e.locatorSimple
+ + "'. The entry method is derived from"
+ + " the target component's simple name,"
+ + " so two @Tests targets sharing a"
+ + " simple name — or two testers"
+ + " covering the same target —"
+ + " produce this clash. Both the"
+ + " find() factory and the companion"
+ + " use(X) factory are affected. Rename"
+ + " one of the targets/testers or"
+ + " update the processor's naming"
+ + " scheme; the colliding entry is"
+ + " dropped from " + fqn + ".");
+ }
+ }
+ for (Entry e : unique.values()) {
+ String locatorFqn = e.pkg + "." + e.locatorSimple;
+ String declTp = e.locatorTypeParamDecl;
+ String useTp = e.locatorTypeParamUse;
+ String retType = locatorFqn + useTp;
+ String params = e.extraTypeParams.stream()
+ .map(tp -> "java.lang.Class<" + tp.getSimpleName()
+ + "> "
+ + decap(tp.getSimpleName().toString())
+ + "Type")
+ .collect(Collectors.joining(", "));
+ String passArgs = e.extraTypeParams.stream().map(
+ tp -> decap(tp.getSimpleName().toString()) + "Type")
+ .collect(Collectors.joining(", "));
+ out.print(renderEntryJavadoc(e));
+ out.println(" default " + declTp
+ + (declTp.isEmpty() ? "" : " ") + retType + " "
+ + e.entryMethodName + "(" + params + ") {");
+ out.println(" activateLocatorContext();");
+ out.println(" return new " + locatorFqn
+ + diamond(e.extraTypeParams) + "(" + passArgs
+ + ");");
+ out.println(" }");
+ out.println();
+ // Companion: seed the locator with a direct component
+ // reference instead of a fresh query. Skipped when the
+ // target type is not public — a public default method
+ // can't expose a non-public parameter type from another
+ // package, and the entry-point interface lives in
+ // com.vaadin.browserless.locator regardless of where
+ // the target sits.
+ if (e.targetIsPublic) {
+ out.print(renderUseEntryJavadoc(e));
+ out.println(" default " + declTp
+ + (declTp.isEmpty() ? "" : " ") + retType
+ + " use(" + e.componentTypeExpr
+ + " component) {");
+ out.println(" activateLocatorContext();");
+ out.println(" return new " + locatorFqn
+ + diamond(e.extraTypeParams) + "(component);");
+ out.println(" }");
+ out.println();
+ }
+ }
+ out.println("}");
+ }
+ } catch (Exception ex) {
+ note(Diagnostic.Kind.ERROR,
+ "Failed to write " + fqn + ": " + ex.getMessage());
+ }
+ }
+
+ private String renderEntryJavadoc(Entry e) {
+ String locatorFqn = e.pkg + "." + e.locatorSimple;
+ String componentSimple = e.locatorSimple.endsWith("Locator")
+ ? e.locatorSimple.substring(0,
+ e.locatorSimple.length() - "Locator".length())
+ : e.locatorSimple;
+ String componentFqn = e.targetPkg + "." + componentSimple;
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(" /**\n");
+ sb.append(" * Returns a locator for {@link ").append(componentFqn)
+ .append("} components.\n");
+ if (!e.extraTypeParams.isEmpty()) {
+ sb.append(" *\n");
+ for (TypeParameterElement tp : e.extraTypeParams) {
+ sb.append(" * @param <").append(tp.getSimpleName())
+ .append(">\n");
+ sb.append(
+ " * type parameter forwarded to the locator\n");
+ }
+ for (TypeParameterElement tp : e.extraTypeParams) {
+ String name = decap(tp.getSimpleName().toString()) + "Type";
+ sb.append(" * @param ").append(name).append('\n');
+ sb.append(" * {@link Class} witness for {@code ")
+ .append(tp.getSimpleName()).append("}\n");
+ }
+ }
+ sb.append(" *\n");
+ sb.append(" * @return a new {@link ").append(locatorFqn)
+ .append("}\n");
+ sb.append(" */\n");
+ return sb.toString();
+ }
+
+ private String renderUseEntryJavadoc(Entry e) {
+ String locatorFqn = e.pkg + "." + e.locatorSimple;
+ String componentSimple = e.locatorSimple.endsWith("Locator")
+ ? e.locatorSimple.substring(0,
+ e.locatorSimple.length() - "Locator".length())
+ : e.locatorSimple;
+ String componentFqn = e.targetPkg + "." + componentSimple;
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(" /**\n");
+ sb.append(" * Returns a locator seeded with the given {@link ")
+ .append(componentFqn).append("} instance.\n");
+ sb.append(
+ " * Additional filter steps compose on top of the identity predicate;\n");
+ sb.append(" * use {@link #").append(e.entryMethodName).append("(");
+ // Match the find* parameter signature so the link resolves cleanly.
+ boolean firstParam = true;
+ for (TypeParameterElement tp : e.extraTypeParams) {
+ if (!firstParam) {
+ sb.append(", ");
+ }
+ sb.append("java.lang.Class");
+ firstParam = false;
+ }
+ sb.append(")} when you want a query without an initial constraint.\n");
+ if (!e.extraTypeParams.isEmpty()) {
+ sb.append(" *\n");
+ for (TypeParameterElement tp : e.extraTypeParams) {
+ sb.append(" * @param <").append(tp.getSimpleName())
+ .append(">\n");
+ sb.append(
+ " * type parameter carried by {@code component}\n");
+ }
+ }
+ sb.append(" *\n");
+ sb.append(
+ " * @param component the component to seed the locator with; must not be {@code null}\n");
+ sb.append(" * @return a new {@link ").append(locatorFqn)
+ .append("} pre-bound to {@code component}\n");
+ sb.append(" */\n");
+ return sb.toString();
+ }
+
+ private void note(Diagnostic.Kind kind, String msg) {
+ processingEnv.getMessager().printMessage(kind,
+ "[LocatorProcessor] " + msg);
+ }
+
+ private static String decap(String s) {
+ if (s.isEmpty()) {
+ return s;
+ }
+ return Character.toLowerCase(s.charAt(0)) + s.substring(1);
+ }
+
+ private record Entry(String pkg, String targetPkg, String locatorSimple,
+ String locatorTypeParamDecl, String locatorTypeParamUse,
+ List extraTypeParams, String entryMethodName,
+ String componentTypeExpr, boolean targetIsPublic) {
+ }
+}
diff --git a/locator-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/locator-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
new file mode 100644
index 00000000..14c3ad48
--- /dev/null
+++ b/locator-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
@@ -0,0 +1 @@
+com.vaadin.browserless.locator.processor.LocatorProcessor
diff --git a/pom.xml b/pom.xml
index ce011e28..abcf4566 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@
+ locator-processorsharedjunit6spring
diff --git a/shared/pom.xml b/shared/pom.xml
index 106da5eb..de969b75 100644
--- a/shared/pom.xml
+++ b/shared/pom.xml
@@ -139,6 +139,15 @@
compile
+
+
+
+ com.vaadin
+ browserless-test-locator-processor
+ ${project.version}
+
+
+ java-test-compile
@@ -176,6 +185,18 @@
+
+ add-generated-locator-sources
+ generate-sources
+
+ add-source
+
+
+
+ ${project.build.directory}/generated-sources/annotations
+
+
+
diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java
index fb3dd688..fab3d0d0 100644
--- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java
+++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java
@@ -21,6 +21,7 @@
import com.vaadin.browserless.internal.MockPage;
import com.vaadin.browserless.internal.MockVaadin;
+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 +59,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 +431,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/CommercialLocators.java b/shared/src/main/java/com/vaadin/browserless/locator/CommercialLocators.java
new file mode 100644
index 00000000..2571f161
--- /dev/null
+++ b/shared/src/main/java/com/vaadin/browserless/locator/CommercialLocators.java
@@ -0,0 +1,40 @@
+/*
+ * 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;
+
+/**
+ * Mixin offering typed locator entry points for commercial Vaadin components
+ * (Charts, etc.).
+ *
+ * Kept separate from {@link Locators} so that core-only consumers do not pull
+ * commercial classes onto the compilation classpath — mirroring the existing
+ * {@code TesterWrappers} / {@code CommercialTesterWrappers} split.
+ *
+ * Most entries come from {@link GeneratedCommercialLocators}, which is emitted
+ * by the locator annotation processor. Mix this into your own context subclass
+ * or test class when you depend on commercial Vaadin components.
+ */
+public interface CommercialLocators
+ extends Locators, GeneratedCommercialLocators {
+
+ // Locators provides a default no-op; GeneratedCommercialLocators
+ // re-declares the method as abstract. Java requires an explicit override
+ // to resolve the conflict; we forward to the Locators default.
+ @Override
+ default void activateLocatorContext() {
+ Locators.super.activateLocatorContext();
+ }
+}
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..9844ac08
--- /dev/null
+++ b/shared/src/main/java/com/vaadin/browserless/locator/Locator.java
@@ -0,0 +1,387 @@
+/*
+ * 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.Objects;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+
+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. Every filter method on this class clears the resolution cache before
+ * mutating the underlying query, so the next action re-resolves and callers
+ * never have to call {@link #invalidate()} between fluent steps. Filter steps
+ * keep the locator's {@link #atIndex(int)} pick sticky — it is part of the
+ * filter chain — so a single locator instance can be reused across an
+ * asynchronous boundary (e.g. {@code roundTrip()}) without holding on to a
+ * stale component reference. {@link #invalidate()} is the explicit rewind hatch
+ * and additionally clears the pick.
+ *
+ * Filters that this class does not expose directly (for example
+ * {@link ComponentQuery#withPropertyValue} or
+ * {@link ComponentQuery#withResultsSize}) are reachable through the
+ * {@link #with(UnaryOperator)} escape hatch, which lets callers compose any
+ * filter the underlying {@link ComponentQuery} supports without subclassing.
+ *
+ * Construction modes. The default constructor
+ * ({@link #Locator(Class)}) seeds an empty query that searches the active UI.
+ * Tests that already hold a direct reference to the component they want to act
+ * on can instead use the seeded-query constructor
+ * ({@link #Locator(Class, Component)}), which pre-filters the query with an
+ * identity predicate. Both modes share the same filter/resolution machinery —
+ * additional filters compose on top of the identity predicate, and a filter
+ * that excludes the seeded component just makes {@link #exists()} return
+ * {@code false} and {@link #component()} throw. Custom locator subclasses can
+ * opt in by declaring a second constructor that forwards to
+ * {@code super(Class, component)}.
+ *
+ * @param
+ * the component type
+ * @param
+ * the concrete locator subtype, used for fluent chaining
+ */
+public abstract class Locator> {
+
+ private ComponentQuery query;
+ private C resolved;
+ private int pickIndex;
+ private Locator, ?> parentLocator;
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Creates a locator seeded with a direct reference to the component to
+ * match. The query is pre-filtered with an identity predicate so the only
+ * resolution is the given instance; additional filter steps compose on top
+ * of it.
+ *
+ * @param componentType
+ * the component type to match
+ * @param component
+ * the component instance to seed the query with; must not be
+ * {@code null}
+ */
+ protected Locator(Class componentType, C component) {
+ Objects.requireNonNull(component, "component");
+ this.query = new ComponentQuery<>(componentType)
+ .withCondition(c -> c == component);
+ }
+
+ /** Requires the matched component to have the given id. */
+ public SELF withId(String id) {
+ resetCache();
+ query.withId(id);
+ return self();
+ }
+
+ /**
+ * Requires the matched component to have a caption equal to the given text.
+ */
+ public SELF withCaption(String caption) {
+ resetCache();
+ query.withCaption(caption);
+ return self();
+ }
+
+ /**
+ * Requires the matched component to have a caption containing the given
+ * text.
+ */
+ public SELF withCaptionContaining(String text) {
+ resetCache();
+ query.withCaptionContaining(text);
+ return self();
+ }
+
+ /** Requires the text content of the component to equal the given text. */
+ public SELF withText(String text) {
+ resetCache();
+ query.withText(text);
+ return self();
+ }
+
+ /** Requires the text content of the component to contain the given text. */
+ public SELF withTextContaining(String text) {
+ resetCache();
+ query.withTextContaining(text);
+ return self();
+ }
+
+ /** Requires the matched component to have all the given CSS class names. */
+ public SELF withClassName(String... className) {
+ resetCache();
+ query.withClassName(className);
+ return self();
+ }
+
+ /**
+ * Requires the matched component to have none of the given CSS class names.
+ */
+ public SELF withoutClassName(String... className) {
+ resetCache();
+ query.withoutClassName(className);
+ return self();
+ }
+
+ /** Requires the matched component to have the given theme set. */
+ public SELF withTheme(String theme) {
+ resetCache();
+ query.withTheme(theme);
+ return self();
+ }
+
+ /** Requires the matched component to not have the given theme set. */
+ public SELF withoutTheme(String theme) {
+ resetCache();
+ query.withoutTheme(theme);
+ return self();
+ }
+
+ /** Requires the matched component to have the given attribute set. */
+ public SELF withAttribute(String attribute) {
+ resetCache();
+ query.withAttribute(attribute);
+ return self();
+ }
+
+ /**
+ * Requires the matched component to have the given attribute with the
+ * expected value.
+ */
+ public SELF withAttribute(String attribute, String value) {
+ resetCache();
+ query.withAttribute(attribute, value);
+ return self();
+ }
+
+ /** Requires the matched component not to have the given attribute. */
+ public SELF withoutAttribute(String attribute) {
+ resetCache();
+ query.withoutAttribute(attribute);
+ return self();
+ }
+
+ /**
+ * Requires the matched component not to have the given attribute value (or
+ * not to have the attribute at all).
+ */
+ public SELF withoutAttribute(String attribute, String value) {
+ resetCache();
+ query.withoutAttribute(attribute, value);
+ return self();
+ }
+
+ /**
+ * Requires the matched component to implement {@code HasValue} and to have
+ * the given value. Has no effect when {@code expectedValue} is
+ * {@code null}.
+ */
+ public SELF withValue(V expectedValue) {
+ resetCache();
+ query.withValue(expectedValue);
+ return self();
+ }
+
+ /** Requires the matched component to satisfy the given predicate. */
+ public SELF withCondition(Predicate condition) {
+ resetCache();
+ query.withCondition(condition);
+ return self();
+ }
+
+ /**
+ * Escape hatch for filters not directly exposed on Locator. Applies the
+ * given operator to the underlying {@link ComponentQuery}, letting users
+ * compose any filter the query supports without subclassing.
+ *
+ *
+ *
+ * Honors the {@link UnaryOperator} contract: whatever the operator returns
+ * becomes the locator's new underlying query. {@code
+ * ComponentQuery}'s built-in filter methods all return {@code this}, so a
+ * fluent chain just re-installs the same instance; an operator that builds
+ * and returns a fresh query replaces the prior one wholesale.
+ *
+ * @throws IllegalStateException
+ * if the operator returns {@code null} instead of a
+ * {@code ComponentQuery} — the operator is expected either to
+ * mutate and return the same instance, or to build and return a
+ * fresh one.
+ */
+ public SELF with(UnaryOperator> op) {
+ resetCache();
+ ComponentQuery next = op.apply(query);
+ if (next == null) {
+ throw new IllegalStateException(
+ "Locator.with operator must return a non-null"
+ + " ComponentQuery (typically by chaining filter"
+ + " calls that return the same instance, or by"
+ + " constructing and returning a fresh query).");
+ }
+ this.query = next;
+ return self();
+ }
+
+ /**
+ * Scopes the search to descendants of the given component. Replaces any
+ * lazy parent previously installed by {@link #inside(Locator)} with a fixed
+ * reference.
+ */
+ public SELF inside(Component parent) {
+ resetCache();
+ this.parentLocator = null;
+ query.from(parent);
+ return self();
+ }
+
+ /**
+ * Scopes the search to descendants of the component matched by the given
+ * locator.
+ *
+ * The parent is resolved lazily, at child-resolution time: each
+ * call to {@link #component()}, {@link #components()}, or {@link #exists()}
+ * first invokes {@code parent.component()} and installs the result as this
+ * locator's search context. A later {@link #invalidate()} on {@code parent}
+ * therefore propagates — the next child action re-resolves both. Calling
+ * {@link #inside(Component)} afterwards replaces this lazy parent with a
+ * fixed reference; calling {@code inside(Locator)} again replaces the lazy
+ * parent.
+ *
+ * @throws NullPointerException
+ * if {@code parent} is {@code null}
+ * @throws IllegalArgumentException
+ * if {@code parent} is this locator itself — a self-reference
+ * would recurse indefinitely under lazy resolution
+ */
+ public SELF inside(Locator, ?> parent) {
+ Objects.requireNonNull(parent, "parent");
+ if (parent == this) {
+ throw new IllegalArgumentException(
+ "A locator cannot scope itself inside itself");
+ }
+ resetCache();
+ this.parentLocator = parent;
+ return self();
+ }
+
+ /**
+ * Picks the n-th match (1-based) when the filter chain yields multiple
+ * matches. Without this, the default expectation is exactly one match.
+ *
+ * @throws IllegalArgumentException
+ * if {@code index} is zero or negative — mirrors
+ * {@link ComponentQuery#atIndex(int)}'s own contract, so the
+ * violation is reported at the locator's filter step rather
+ * than masked into a "single match" resolution at action time.
+ */
+ public SELF atIndex(int index) {
+ if (index <= 0) {
+ throw new IllegalArgumentException(
+ "Index must be greater than zero, but was " + index);
+ }
+ resetCache();
+ 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) {
+ prepareQueryContext();
+ 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() {
+ prepareQueryContext();
+ return query.all();
+ }
+
+ /**
+ * Returns {@code true} if the filter chain matches at least one component.
+ */
+ public boolean exists() {
+ prepareQueryContext();
+ return query.exists();
+ }
+
+ /**
+ * Rewinds picker state: discards any cached resolution and clears the
+ * {@link #atIndex(int)} pick. Filter methods on this class call a private
+ * cache-only reset internally, so they keep the locator's
+ * {@code atIndex(n)} sticky as part of the filter chain. {@code
+ * invalidate()} is the explicit "rewind" hatch: after a UI change that
+ * replaces or detaches the resolved component, calling it forces the next
+ * action to re-resolve, and also drops the pick so the next resolution
+ * defaults back to "single match expected" until the caller re-applies
+ * {@link #atIndex(int)}.
+ */
+ public SELF invalidate() {
+ resetCache();
+ pickIndex = 0;
+ return self();
+ }
+
+ private void resetCache() {
+ resolved = null;
+ }
+
+ private void prepareQueryContext() {
+ if (parentLocator != null) {
+ query.from(parentLocator.component());
+ }
+ }
+
+ @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..b3ec4cbf
--- /dev/null
+++ b/shared/src/main/java/com/vaadin/browserless/locator/Locators.java
@@ -0,0 +1,61 @@
+/*
+ * 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;
+
+/**
+ * Mixin offering typed entry points for the {@code find*} tester API.
+ *
+ * Most entry points come from {@link GeneratedLocators}, which is emitted by
+ * the locator annotation processor at build time. This interface adds the
+ * generic {@link #find(Supplier)} for user-defined locators and the
+ * {@link #activateLocatorContext()} hook that context-bound implementations
+ * (e.g. {@code BrowserlessUIContext}) override.
+ */
+public interface Locators extends GeneratedLocators {
+
+ /**
+ * Hook for context-bound implementations to install Vaadin thread-locals
+ * before a locator is built. Default is a no-op.
+ */
+ @Override
+ default void activateLocatorContext() {
+ }
+
+ /**
+ * 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.
+ *
+ *