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: + * + * + */ +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 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 entry : am + .getElementValues().entrySet()) { + String name = entry.getKey().getSimpleName().toString(); + if (name.equals("value")) { + List list = (List) 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 list = (List) 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 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 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 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 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 params = m.getParameters(); + List 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 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 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 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-processor shared junit6 spring 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. + * + *

+     * findButton().with(q -> q.withPropertyValue(Button::getText, "Save"))
+     *         .click();
+     * 
+ * + * 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. + * + *

+     * window.find(CheckoutFormLocator::new).withId("checkout").submit();
+     * 
+ * + * @throws IllegalStateException + * if the factory returns {@code null} instead of a fresh + * locator instance. + */ + default > L find(Supplier factory) { + activateLocatorContext(); + L locator = factory.get(); + if (locator == null) { + throw new IllegalStateException( + "Locators.find factory must return a non-null Locator."); + } + return locator; + } +}