Skip to content

Alternative Locator API that returns "testing" API instead of raw components#65

Merged
mcollovati merged 42 commits into
mainfrom
feat/get-tester-api
May 20, 2026
Merged

Alternative Locator API that returns "testing" API instead of raw components#65
mcollovati merged 42 commits into
mainfrom
feat/get-tester-api

Conversation

@mstahv
Copy link
Copy Markdown
Member

@mstahv mstahv commented May 15, 2026

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.

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 15, 2026

/format

@github-actions

This comment has been minimized.

@mcollovati
Copy link
Copy Markdown
Contributor

Without digging too much in the code, overall the feature looks a great improvement for browserless testing.
A couple of points that can be addressed in the PoC, before a proper code review:

  • The Locator only exposes a subset of ComponentQuery filter methods. We should perhaps expand it a bit (e.g. withAttribute(...)) or add a generic with(UnaryOperator<ComponentQuery<C>) to allow users more flexibility,
  • The processor could be helpful also in end user projects that define their own Testers. However, as it is, it has a couple of problems: it seems to scan everything instead of being limited to the compilation unit, regenerating the GeneratedLocators interface in the application. I think this can be solved by limiting the generation scope to the compilation unit and also generating an entry point per application/jar, so basically one GeneratedLocators per run in different packages (package name can be given as a processor parameter). Or make it so that GeneratedLocators is created only for the browserless shared module. But the single generated locators could anyway be used.
  • I'd rename getXXX methods to findXXX in the GeneratedLocators class; getChart() looks like you already have a chart component back, but this actually only starts the search process.
  • The method skip algorithms should probably be refined a bit; for example, a tester might not implement clickable but provide custom overrides for click methods.
  • Commercial components like Chart should not be added to the GeneratedLocators class, otherwise there will be compilation errors when the end project only uses Vaadin core components. This already happened with the TestWrappers interface that had to be split, moving Chart helpers in CommercialTesterWrappers
  • We should consider adding locators’ stuff also to BrowserlessTest and the JUnit extension (can probably be done in a separated PR)

I'll go deeper into the processor implementation later on

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 19, 2026

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.

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 19, 2026

/format

@mstahv mstahv marked this pull request as ready for review May 19, 2026 08:35
@github-actions

This comment has been minimized.

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 19, 2026

/format

@github-actions

This comment has been minimized.

Comment thread shared/src/main/java/com/vaadin/browserless/locator/Locator.java Outdated
mstahv added a commit that referenced this pull request May 19, 2026
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>
@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 19, 2026

/format

@github-actions

This comment has been minimized.

Comment thread shared/src/main/java/com/vaadin/browserless/locator/Locator.java Outdated
Comment thread junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java Outdated
@StefanPenndorf
Copy link
Copy Markdown

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 PersonFormLocator and maybe this should be explored in more depth. This allows for more business language style tests (and even BDD tests)

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 emailField of it's subcomponent PersonForm. In complex real world applications this will be different java files and maybe a deeper nesting of components. I don't like the idea that the test for the view knows the exact id of a component in a grand grand child of that view.

Even if the components are direct children to the view: The test and the production code are coupled via Strings (I refer to those ids). Does it make sense to propagate the use of constants like

@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.

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 20, 2026

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

  • a test view for that component only (where it is easy to work with or manually test specific uses cases)
  • browserless-test based tests
  • Playwright based E2E tests

Then in the actual application module I can concentrate covering the basic use cases only.

@mcollovati
Copy link
Copy Markdown
Contributor

window.navigate(LocatorDemoView.class).find(view -> view.save).click() cannot be achieved since the result of navigate is the view instance.
What we can do instead without changing the core API is

var view = window.navigate(LocatorDemoView.class)
window.use(view.save).click();

mstahv and others added 8 commits May 20, 2026 09:19
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>
mstahv and others added 16 commits May 20, 2026 09:21
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.
@mcollovati mcollovati force-pushed the feat/get-tester-api branch from 808d60f to b8e72c0 Compare May 20, 2026 07:22
mcollovati and others added 3 commits May 20, 2026 09:25
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.
@mcollovati
Copy link
Copy Markdown
Contributor

@mstahv inside(Locator) eagearly resolved the component. Is this the intended behavior, or should we lazily evaluate the locator?

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.
@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 20, 2026

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>
@mcollovati
Copy link
Copy Markdown
Contributor

Implemented inside(Locator) to lazyly compute parent component

@mstahv
Copy link
Copy Markdown
Member Author

mstahv commented May 20, 2026

👍 Verified the latest changes against two external projects where I have been using these. All fine there.

@mcollovati mcollovati merged commit b49b128 into main May 20, 2026
7 checks passed
@mcollovati mcollovati deleted the feat/get-tester-api branch May 20, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants