Alternative Locator API that returns "testing" API instead of raw components#65
Conversation
|
/format |
This comment has been minimized.
This comment has been minimized.
|
Without digging too much in the code, overall the feature looks a great improvement for browserless testing.
I'll go deeper into the processor implementation later on |
|
I'm not 100% sure if it is good to use the same verb for the component queries and now for the "locators", thus I went for the get verb. Also aligns to Playwright terminology. Changed anyways to find. Not a big issue if we end up deprecating the old component queries altogether (might make sense) and we can test how different find methods now feel in actual projects with snapshot build and revert if we end up regretting too much. Other but the last poin tackled with "some" help from Kalle. Making this a non-draft as it looks so promising. |
|
/format |
This comment has been minimized.
This comment has been minimized.
|
/format |
This comment has been minimized.
This comment has been minimized.
Per mcollovati's review on PR #65: Locator.with(...) discarded the UnaryOperator's return value, silently dropping any operator that built and returned a fresh ComponentQuery instead of mutating the original in place. UnaryOperator<T>'s contract is `T apply(T)` — the caller may legitimately return a different instance, and we have to adopt it. The existing test masked the bug because ComponentQuery's built-in filter methods all return `this`. Added a regression test that builds and returns a fresh ComponentQuery from the operator and asserts the locator picks it up; this would fail under the old behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/format |
This comment has been minimized.
This comment has been minimized.
|
Some thoughts from me: The example requires a View as a starting point. If I want to develop a complex reusable component (e.g. a person form I want to use several times in my app or a shared component if I develop a framework for multiple applications) and I want to test this component independently from the view it will be finally integrated into, I need to provide a dummy view to navigate to. Is this intended as a final decision? If yes, this should be mentioned in the official Vaadin documentation, at least. I like the idea of the window.find(DeliveryChooserLocator::new)
.enterShippingAddress("MyName", "MyStreet","MyTown")
.selectInvoiceAddressSameAsShippingAddress()
.next();that encapsulates the internals of locating individual components and fields. Those locators might be a bit of boiler plate but they are useful to encapsulate internals of the view: In your example the test for the view needs to know the concrete id of the Even if the components are direct children to the view: The test and the production code are coupled via @Route("locator-demo")
public class LocatorDemoView extends VerticalLayout {
static final String SAVE_BUTTON_ID = "save";
}that can be refered to in tests? This would make the coupling more explicit, I think. Would you agree or do you think differently? Following this idea further something like @Route("locator-demo")
public class LocatorDemoView extends VerticalLayout {
final Button save = new Button("Save");
}
class LocatorApiTest {
@Test
void test() {
window.navigate(LocatorDemoView.class).find(view -> view.save).click();
}
}came into my mind. But I'm not quite happy with this approach. It doesn't work for all usecases (dynamically added children etc. that can't be represented as fields in the view or it's children) and maybe couples test and implementation too much. Thus using "lots of locators" seems the best compromise to me: There are no (child) component ids scattered in the tests and the locators provide a good layer of indirection. Hope me random ideas and thoughts make some sense to you and might be of some help. |
|
Hi @StefanPenndorf, very good feedback! We need to work also with the docs to make things clearer. The PersonFormLocator in the PR tests is indeed kind of a replica of "page object pattern", commonly used among Selenium/Playwright/TestBench, but adapted now for the browserless-test module user. That's the only way to keep larger tests maintainable, like having classes is on the UI code side on bigger projects. I try to avoid adding ids. Same for Playwright tests. Usually there is some identifying feature of the component that can be used. In Playwright tests my most common selector is https://playwright.dev/java/docs/api/class-page#page-get-by-label. The counterpart in browserless-test is bit confusingly "withCaption". We should add a more specific withLabel as well for discoverability, #42, because I didn't find it 🤣. I created #69 to help testing individual components 👍 Add your commnets there if you have some other insights. In my own projects I tend to keep low barrier to create sub modules for components. Even if they would be only used in the app itself and in a single place, this often helps to create cleaner code. At least it feels more natural to create tests and it is then a tiny step to separate it to a standalone project if one things some other project (or community in general) could benefit of it. Here is one recent example: https://github.com/mstahv/lahtokaavio/tree/main The visual-lane-planner ther is probably never split out from here, but I have
Then in the actual application module I can concentrate covering the basic use cases only. |
|
var view = window.navigate(LocatorDemoView.class)
window.use(view.save).click(); |
Explores a tester API where the entry point is a typed verb (getButton, getTextField, getGrid(Class<V>)) instead of find(Class) + test(component). A locator is both a query (filter chain) and a tester (action methods); resolution is lazy and cacheable, with invalidate() to rewind after a UI change. Custom locators plug in via window.get(MyLocator::new) and compose built-ins through inside(this). Wired onto BrowserlessUIContext only — the prototype is intentionally scoped to the context-style entry point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New locator-processor module reads @Tests-annotated ComponentTester
subclasses during shared's compilation and emits:
- a <X>Locator class per tester, delegating each public action method
to a transient tester instance and forwarding tester type parameters
(so Grid<V>, ComboBox<V> etc. stay typed);
- a single GeneratedLocators interface exposing a typed
get<ComponentName>() entry per locator.
Locators interface extends GeneratedLocators and adds the supplier-based
get(Supplier<L>) for user-defined locators. The three hand-written
locators (Button, TextField, Grid) are removed — they are now generated.
Covers 63 testers; values referencing tester-private type vars
(e.g. T extends AbstractNumberField<T,V>) fall back to the erased
component type so generation never produces invalid code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Helps IntelliJ pick up the locator processor output as a real source root on reimport, so the IDE compilation doesn't complain about missing *Locator classes even when the processor itself isn't run by the IDE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Processor now emits one locator per @tests value/fqn rather than one locator per tester. For each target the processor walks the target's supertype parameterization of the tester's bound head class and pins each tester type variable whose position resolves to a concrete type. The win is ergonomic for TextField-style testers that handle multiple component subclasses with differing value types: TextFieldTester<T extends TextFieldBase<T,V>, V> @tests({ TextField, PasswordField, EmailField, BigDecimalField}) now generates four locators — TextFieldLocator (V=String), PasswordFieldLocator (V=String), EmailFieldLocator (V=String), BigDecimalFieldLocator (V=BigDecimal) — each with a witness-free entry point on GeneratedLocators. Testers whose value type is not pinned at the target level (Grid<V>, ComboBox<V>) still take a Class<V> witness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The processor's skip list blanket-excluded click/middleClick/rightClick on the assumption they're inherited from Clickable. But several testers override these with custom behavior — RadioButtonTester.click() flips the checked state, SideNavTester.click() expands a nav item — and those overrides were being silently dropped. Since we only iterate methods declared directly on the tester, simply removing the click* names from the skip list makes the right thing happen: declared overrides become delegates, undeclared ones still fall through to Clickable's default via the locator's `implements Clickable`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per mcollovati's review: getChart() reads like "you already have a Chart", while findChart() matches the lazy search-on-resolve semantics and is consistent with the existing find(Class) query method. Also sidesteps confusion with view-side getXxx accessors that follow JavaBean conventions. Renames the generated GeneratedLocators methods, plus the supplier escape hatch from get(Supplier) to find(Supplier). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the filter methods that were missing on Locator — withAttribute (2 overloads), withoutAttribute (2), withTheme, withoutTheme, withoutClassName, withValue — plus a generic with(UnaryOperator<ComponentQuery<C>>) escape hatch for filters that aren't exposed directly (e.g. withPropertyValue) so users don't need to subclass to compose unusual queries. Per mcollovati: "the Locator only exposes a subset of ComponentQuery filter methods. We should perhaps expand it a bit or add a generic with." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locator.with and Locators.find now spell out the null-return contract in their @throws clauses so the contract is visible at design time (IDE hover, Javadoc) rather than only at runtime via the exception message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parent pom resolves the spotless license header via
\${maven.multiModuleProjectDirectory}/eclipse/apache2-license-header.txt.
Without a .mvn/ marker in the repo, Maven sets that property to the
caller's working directory — running `mvn spotless:apply` from outside
the project tree resolves the header path to e.g.
/Users/mstahv/eclipse/apache2-license-header.txt and fails.
An empty .mvn/ folder is the standard Maven marker that anchors the
property to the project root regardless of invocation cwd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure reformatting from spotless:apply — @throws tag style and one argument-list reflow. No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Marco Collovati <marco@vaadin.com>
Four reflection assertions guard the locator processor's output shape: GeneratedLocators stays free of commercial entries, GeneratedCommercial- Locators carries them, CommercialLocators unions both, and the downstream AppLocators aggregator emitted from junit6's test-compile is scoped to local testers only.
Each generated *Locator method now carries the Javadoc from the
corresponding tester method, followed by a link back to it. Methods
whose tester has no Javadoc get a minimal "Delegates to {@link ...}"
note instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both GeneratedLocators and GeneratedCommercialLocators now carry a class-level Javadoc, a short doc on the activateLocatorContext() hook, and per-method Javadoc on each find<Component>() entry — including a @param/@return block for the Class<> witness variants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The processor iterated tester.getEnclosedElements(), picking up only
methods declared directly on the leaf tester. Methods inherited from
intermediate base testers (e.g. HtmlContainerTester.getText()) were
silently dropped, forcing callers into .getComponent().getText() and
defeating the locator's fluent surface for any tester with a non-
ComponentTester parent (Span, Hr, NativeLabel, NativeDetails, Anchor).
Walk the superclass chain instead, stopping at ComponentTester so its
base machinery and the Clickable interface defaults stay out of the
delegate set, and dedupe by erased signature with leaf-first traversal
so an override on the concrete tester wins. Render each delegate from
the ExecutableType returned by Types.asMemberOf so type variables on
intermediate base testers rebind through the leaf's parameterization.
Javadoc {@link} references now point at the declaring class.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously atIndex stored any int into pickIndex and component()'s "pickIndex > 0 ? atIndex : single" branch silently degraded 0 and negatives into a single-match resolution. That hid the contract violation until action time and only as a "not exactly one match" error, with no hint that the caller's index was the actual fault. Mirror ComponentQuery.atIndex's own check and throw IllegalArgumentException at the filter step, so the offending call is the one that fails. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior Javadoc said the parent is "resolved on demand", which read as if the child's lazy resolution would re-resolve the parent too. In fact inside(Locator) immediately calls parent.component() and captures the returned Component reference into the child's filter chain, so a later invalidate() on the parent does not propagate. Spell out the eager-resolution behavior and the workaround. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The only header on a generated *Locator was the non-Javadoc "Generated by LocatorProcessor" line, so IDE hover and javadoc/dokka output showed nothing about what the class is, where it came from, or why it should not be edited. Emit a real Javadoc block that links the target component and the source tester, names Locator as the source of filter steps, and states that behavioral overrides belong on the tester. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
writeInterface dedup'd by (entry-method name, type-witness arity) and silently dropped the second hit. That hid a real problem: the entry method name is derived from the target component's simple name, so a collision means two @tests targets share a simple name and only one of them ends up reachable through the generated entry-point. Emit Diagnostic.Kind.ERROR naming both colliding locator FQNs and the target interface, so either the tester set or the processor's naming scheme gets fixed. The colliding entry is still dropped from the generated source so the file remains compilable, but the ERROR fails the build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two gaps in the class Javadoc that forced readers into the source: - "Calling any filter method after resolution invalidates the cache" described the symptom; it didn't say filter methods always call invalidate() so callers never have to. - The class doc described filter and action methods but didn't say how to reach ComponentQuery filters this class doesn't expose (withPropertyValue, withResultsSize, ...). Point at with(UnaryOperator) and name those two as examples. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These three public methods on Locator had no direct assertions; only the action-method paths exercised them transitively. Add focused tests: - exists() returns true for a matching id and false when nothing matches - components() returns all matches and does not commit the locator to a single-match resolution (a follow-up atIndex pick on the same instance still works) - inside(Component) — distinct from inside(Locator) — narrows a global match set down to the descendants of the passed component Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tests that already hold a direct component reference — a public field
on a composite, a row from a Grid, a child returned by a custom helper
— had to round-trip through the filter chain to wrap it in a locator.
Per registered tester, the processor now emits a companion default
alongside findFoo() that seeds the locator with the given instance:
window.use(form.submit).click();
Person first = window.use(grid).getRow(0);
Filters compose on top of the seed as usual, so there is no separate
"bound" mode to reason about.
808d60f to
b8e72c0
Compare
The processor previously derived a single target from the tester's first type-parameter bound when neither @Tests.value nor @Tests.fqn was set. The runtime tester-scan ignores such testers, so the processor was emitting locators the runtime considered non-existent. Mirror the runtime behavior and skip them with a NOTE diagnostic instead. Drops the now-unused resolveComponentType / findComponentTesterArg / erasePrivateTypeVars helpers (-105 lines). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Filter methods kept atIndex sticky as part of the filter chain, but there was no way to clear it once set, so a locator that had picked the N-th match could be left in a state where a narrower filter chain made component() throw with no escape. Filter methods now go through a private cache-only reset; invalidate() is the explicit rewind hatch and additionally drops pickIndex.
|
@mstahv |
A tester in the default (unnamed) package previously made the processor build an FQN like ".FooLocator", which Filer.createSourceFile rejects. The failure was swallowed by the surrounding catch and surfaced as a generic "Locator generation skipped" warning, leaving no clue why. Check the package up front and emit an ERROR diagnostic naming the tester and explaining the constraint.
The entry-method collision check keyed on (name, witness arity), so two testers targeting the same component with different free-type-parameter arities slipped through: their findX() overloads coexisted, but their use(X) companions erased to the same signature and would have made the generated interface fail to compile. Drop the arity discriminator and key on the entry-method name alone. Same-named entries now ERROR regardless of arity, since the processor is internal and one tester per target is the intended convention.
|
I haven't thought about that. Maybe it should be, but I guess it doesn't matter that much (or if we need to change it in upcoming version). The inside(Component) version is kind of eager anyway 🤷♂️ Your call. |
Previously, inside(Locator) eagerly called parent.component() at the fluent-chain call site and captured the resolved reference into the child's filter chain. That made parent.invalidate() a no-op for the child — a documented limitation users had to work around by re-issuing the chain. inside(Locator) now stores the parent and resolves it just-in-time at the start of each component(), components(), or exists() call. Parent invalidation propagates transparently, so fluent compositions stay correct across UI changes without manual re-issue. Mixed overloads keep last-call-wins semantics: inside(Component) clears any pending lazy parent. Self-references (loc.inside(loc)) — which would recurse indefinitely under lazy resolution — are rejected with IllegalArgumentException. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Implemented |
|
👍 Verified the latest changes against two external projects where I have been using these. All fine there. |
The current API has an issue that its "queries" return the component instant. And most often the user interactions should be, instead of using the raw component API, simulated throught "testers". So essentially user of the library first queries the component and then does another special trick to convert that into "tester". As they also happen throught a same "gate", the code looks bit odd.
This is an experiment that takes a more inspiration from Playwright's API but keeps the well typed approach we currently have in the browserless-test. Check out the LocatorApiTest at this point and comment about the prototyped code end users would write after this change, the current implementation and API details probably have a lot to improve. The typed locators are here ATM written based on the current Testers.