diff --git a/README.md b/README.md index 3003bed..4198023 100644 --- a/README.md +++ b/README.md @@ -194,11 +194,11 @@ For tests that need to drive multiple users — or multiple browser windows for the same user — against a single application, Browserless Test exposes a layered context API that mirrors the Vaadin hierarchy: -| Context | Maps to | Created via | -|------------------------------------|----------------------------------|--------------------------------------------------------------------------------------| -| `BrowserlessApplicationContext` | shared `VaadinServletService` | `BrowserlessApplicationContext.create(routes)` (or a framework factory, see below) | -| `BrowserlessUserContext` | one `VaadinSession` (one user) | `app.newUser()` / `app.newUser(credentials)` / `app.newUser(username, roles...)` | -| `BrowserlessUIContext` | one `UI` (one browser window) | `user.newWindow()` | +| Context | Maps to | Created via | +|------------------------------------|----------------------------------|--------------------------------------------------------------------------------------------| +| `BrowserlessApplicationContext` | shared `VaadinServletService` | `BrowserlessApplicationContext.create(viewPackagesOrClasses)` (or a framework factory) | +| `BrowserlessUserContext` | one `VaadinSession` (one user) | `app.newUser()` / `app.newUser(credentials)` / `app.newUser(username, roles...)` | +| `BrowserlessUIContext` | one `UI` (one browser window) | `user.newWindow()` | `BrowserlessUIContext` exposes the same DSL as `BrowserlessTest` (`navigate`, `find`, `findInView`, `test`, `roundTrip`). Every DSL call automatically activates the @@ -216,7 +216,6 @@ fires destroy listeners, and clears Vaadin and security thread-locals. ```java import com.vaadin.browserless.BrowserlessApplicationContext; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Paragraph; import org.junit.jupiter.api.Test; @@ -226,9 +225,8 @@ class SharedCounterTest { @Test void twoUsersShareApplicationState() { - Routes routes = new Routes() - .autoDiscoverViews(SharedCounterView.class.getPackageName()); - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext + .create(SharedCounterView.class)) { var w1 = app.newUser().newWindow(); var w2 = app.newUser().newWindow(); @@ -250,10 +248,10 @@ class SharedCounterTest { ### Spring -`SpringBrowserlessApplicationContext.create(routes, springCtx)` wires the -application context to the Spring `ApplicationContext` and (when Spring -Security is on the classpath) installs a `SecurityContextHandler` so per-user -authentication is automatically isolated across windows. The +`SpringBrowserlessApplicationContext.create(springCtx, viewPackagesOrClasses)` +wires the application context to the Spring `ApplicationContext` and (when +Spring Security is on the classpath) installs a `SecurityContextHandler` so +per-user authentication is automatically isolated across windows. The `newUser(username, roles...)` shorthand mirrors `@WithMockUser`. ```java @@ -261,7 +259,6 @@ import com.testapp.security.LoginView; import com.testapp.security.ProtectedView; import com.vaadin.browserless.SecuredBrowserlessApplicationContext; import com.vaadin.browserless.SpringBrowserlessApplicationContext; -import com.vaadin.browserless.internal.Routes; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -280,10 +277,9 @@ class MultiUserSecurityTest { @Test void securityContextIsIsolatedPerUser() { - Routes routes = new Routes().autoDiscoverViews("com.testapp.security"); try (SecuredBrowserlessApplicationContext app = - SpringBrowserlessApplicationContext.createSecured(routes, - springCtx)) { + SpringBrowserlessApplicationContext.createSecured( + springCtx, "com.testapp.security")) { var adminWindow = app.newUser("john", "USER").newWindow(); var anonWindow = app.newUser().newWindow(); @@ -309,8 +305,8 @@ hand-built `Authentication` token. ### Quarkus -`QuarkusBrowserlessApplicationContext.create(routes)` resolves Quarkus beans -through CDI and installs a `SecurityContextHandler` backed by +`QuarkusBrowserlessApplicationContext.create(viewPackagesOrClasses)` resolves +Quarkus beans through CDI and installs a `SecurityContextHandler` backed by `CurrentIdentityAssociation`. The `newUser(username, roles...)` shorthand builds a matching `QuarkusSecurityIdentity`. @@ -318,7 +314,6 @@ builds a matching `QuarkusSecurityIdentity`. import com.testapp.security.LoginView; import com.testapp.security.ProtectedView; import com.vaadin.browserless.SecuredBrowserlessApplicationContext; -import com.vaadin.browserless.internal.Routes; import com.vaadin.browserless.quarkus.QuarkusBrowserlessApplicationContext; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.test.junit.QuarkusTest; @@ -330,9 +325,9 @@ class MultiUserSecurityTest { @Test void securityContextIsIsolatedPerUser() { - Routes routes = new Routes().autoDiscoverViews("com.testapp.security"); try (SecuredBrowserlessApplicationContext app = - QuarkusBrowserlessApplicationContext.createSecured(routes)) { + QuarkusBrowserlessApplicationContext + .createSecured("com.testapp.security")) { var adminWindow = app.newUser("john", "USER").newWindow(); var anonWindow = app.newUser().newWindow(); diff --git a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java index 1a8a6f9..0388f65 100644 --- a/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java +++ b/junit6/src/main/java/com/vaadin/browserless/AbstractBrowserlessExtension.java @@ -69,6 +69,11 @@ protected void addComponentTesterPackages(String... packages) { componentTesterPackages.addAll(Arrays.asList(packages)); } + protected void addComponentTesterPackages(Class... classes) { + Stream.of(classes).map(Class::getPackageName) + .forEach(componentTesterPackages::add); + } + // --- Lifecycle callbacks --- protected void doInit(Object testInstance, ExtensionContext ctx) { @@ -104,11 +109,8 @@ private void standaloneInit(Class testClass) { if (testerAnnotation != null) { testerPkgs.addAll(Arrays.asList(testerAnnotation.value())); } - for (String pkg : testerPkgs) { - if (BaseBrowserlessTest.scanned.add(pkg)) { - BaseBrowserlessTest.testers - .putAll(BaseBrowserlessTest.scanForTesters(pkg)); - } + if (!testerPkgs.isEmpty()) { + TesterRegistry.registerPackages(testerPkgs.toArray(String[]::new)); } // Resolve view packages from annotation and programmatic config @@ -125,7 +127,7 @@ private void standaloneInit(Class testClass) { } packages.removeIf(Objects::isNull); - Routes routes = BaseBrowserlessTest.discoverRoutes(packages); + Routes routes = RouteDiscovery.discover(packages); MockVaadin.setup(routes, MockedUI::new, services); signalsTestEnvironment = TestSignalEnvironment.register(); } diff --git a/junit6/src/main/java/com/vaadin/browserless/BrowserlessClassExtension.java b/junit6/src/main/java/com/vaadin/browserless/BrowserlessClassExtension.java index b57c69e..2b3c113 100644 --- a/junit6/src/main/java/com/vaadin/browserless/BrowserlessClassExtension.java +++ b/junit6/src/main/java/com/vaadin/browserless/BrowserlessClassExtension.java @@ -118,6 +118,20 @@ public BrowserlessClassExtension withComponentTesterPackages( return this; } + /** + * Adds the packages of the given classes to the set of packages to scan for + * {@link ComponentTester} implementations. + * + * @param classes + * classes whose packages should be scanned for testers + * @return this extension instance + */ + public BrowserlessClassExtension withComponentTesterPackages( + Class... classes) { + addComponentTesterPackages(classes); + return this; + } + @Override public void beforeAll(ExtensionContext ctx) { doInit(ctx.getTestInstance().orElse(null), ctx); diff --git a/junit6/src/main/java/com/vaadin/browserless/BrowserlessExtension.java b/junit6/src/main/java/com/vaadin/browserless/BrowserlessExtension.java index 7f98123..238388b 100644 --- a/junit6/src/main/java/com/vaadin/browserless/BrowserlessExtension.java +++ b/junit6/src/main/java/com/vaadin/browserless/BrowserlessExtension.java @@ -111,6 +111,20 @@ public BrowserlessExtension withComponentTesterPackages( return this; } + /** + * Adds the packages of the given classes to the set of packages to scan for + * {@link ComponentTester} implementations. + * + * @param classes + * classes whose packages should be scanned for testers + * @return this extension instance + */ + public BrowserlessExtension withComponentTesterPackages( + Class... classes) { + addComponentTesterPackages(classes); + return this; + } + @Override public void beforeEach(ExtensionContext ctx) { doInit(ctx.getTestInstance().orElse(null), ctx); diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessApplicationContextBuilderTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessApplicationContextBuilderTest.java new file mode 100644 index 0000000..57bb1db --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessApplicationContextBuilderTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.browserless; + +import com.example.base.signals.SignalsView; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.vaadin.browserless.builderfixtures.WidgetComponent; +import com.vaadin.browserless.builderfixtures.WidgetTester; + +/** + * Exercises the new {@link BrowserlessApplicationContext} factory and builder + * surface introduced to close + * issue #61: + * factory overloads that take view packages instead of an internal + * {@code Routes}, and a builder method that scans for custom + * {@link ComponentTester} implementations. + */ +class BrowserlessApplicationContextBuilderTest { + + @Test + void builderWithComponentTesterPackages_resolvesCustomTester() { + try (var app = BrowserlessApplicationContext + .create(b -> b.withViewPackages(SignalsView.class) + .withComponentTesterPackages(WidgetComponent.class))) { + var window = app.newUser().newWindow(); + ComponentTester tester = window.test(new WidgetComponent()); + Assertions.assertInstanceOf(WidgetTester.class, tester, + "Custom tester scanned via the builder should resolve"); + } + } + + @Test + void createWithViewPackageClass_opensContextWithoutRoutesArg() { + try (var app = BrowserlessApplicationContext + .create(SignalsView.class)) { + var window = app.newUser().newWindow(); + var view = window.navigate(SignalsView.class); + Assertions.assertNotNull(view); + } + } + + @Test + void createWithViewPackageString_opensContextWithoutRoutesArg() { + try (var app = BrowserlessApplicationContext + .create(SignalsView.class.getPackageName())) { + var window = app.newUser().newWindow(); + var view = window.navigate(SignalsView.class); + Assertions.assertNotNull(view); + } + } + + @Test + void createWithUnsecuredConfigurer_returnsUnsecuredContext() { + try (var app = BrowserlessApplicationContext + .create(b -> b.withViewPackages(SignalsView.class))) { + // Compile-time check on the variable type is enough; runtime + // assertion guards against a regression that hands back a + // subclass with security wiring. + Assertions.assertFalse( + app instanceof SecuredBrowserlessApplicationContext, + "Unsecured configurer must not return a secured context"); + var window = app.newUser().newWindow(); + Assertions.assertNotNull(window.navigate(SignalsView.class)); + } + } + + @Test + void createSecuredWithConfigurer_returnsSecuredContext() { + SecurityContextHandler handler = new RecordingHandler(); + + SecuredBrowserlessApplicationContext app = BrowserlessApplicationContext + .createSecured(b -> b.withViewPackages(SignalsView.class) + .withSecurityContextHandler(handler)); + try (app) { + // The variable type is the proof that the configurer returned + // a SecuredBuilder and the factory returned the matching + // secured context type. + Assertions.assertSame(handler, app.getSecurityContextHandler(), + "Configurer-installed handler must propagate to the" + + " built context"); + var window = app.newUser("alice").newWindow(); + Assertions.assertNotNull(window.navigate(SignalsView.class)); + } + } + + private static final class RecordingHandler + implements SecurityContextHandler { + @Override + public void setupAuthentication(String credentials) { + } + + @Override + public Object saveContext() { + return null; + } + + @Override + public void restoreContext(Object snapshot) { + } + + @Override + public void clearContext() { + } + + @Override + public String createCredentials(String username, String... roles) { + return username; + } + } +} diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessClosePathCleanupTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessClosePathCleanupTest.java index 1c82590..155a59f 100644 --- a/junit6/src/test/java/com/vaadin/browserless/BrowserlessClosePathCleanupTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessClosePathCleanupTest.java @@ -20,10 +20,8 @@ import com.example.multiuser.SimpleView; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.UI; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinResponse; @@ -36,17 +34,9 @@ */ class BrowserlessClosePathCleanupTest { - private Routes routes; - - @BeforeEach - void setUp() { - routes = new Routes() - .autoDiscoverViews(SimpleView.class.getPackageName()); - } - @Test void userClose_clearsVaadinRequestAndResponseThreadLocals() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var user = app.newUser(); user.newWindow(); @@ -67,7 +57,7 @@ void userClose_clearsVaadinRequestAndResponseThreadLocals() { @Test void userClose_drainsSessionAccessTasksScheduledByDestroyListeners() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var user = app.newUser(); user.newWindow(); @@ -86,7 +76,7 @@ void userClose_drainsSessionAccessTasksScheduledByDestroyListeners() { @Test void uiClose_clearsActiveContextWhenClosingTheActiveWindow() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var window = app.newUser().newWindow(); Assertions.assertSame(window, BrowserlessUIContext.getActive(), "Sanity check: newWindow activates the new window"); @@ -101,7 +91,7 @@ void uiClose_clearsActiveContextWhenClosingTheActiveWindow() { @Test void uiClose_clearsThreadLocalsWhenClosingTheActiveWindow() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var window = app.newUser().newWindow(); // Sanity: thread-locals reflect the active window before close Assertions.assertNotNull(UI.getCurrent(), @@ -137,8 +127,7 @@ void uiClose_clearsThreadLocalsWhenClosingTheActiveWindow() { @Test void uiClose_clearsSecurityContextWhenClosingTheActiveWindow() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var alice = app.newUser("alice"); var aliceWindow = alice.newWindow(); // Sanity: alice's snapshot is live on the thread @@ -157,7 +146,7 @@ void uiClose_clearsSecurityContextWhenClosingTheActiveWindow() { @Test void uiClose_clearsThreadLocalsWhenNoSurvivingActiveContext() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var user = app.newUser(); var window1 = user.newWindow(); var window2 = user.newWindow(); // activates window2 @@ -194,7 +183,7 @@ void uiClose_clearsThreadLocalsWhenNoSurvivingActiveContext() { @Test void uiClose_leavesActiveContextAloneWhenClosingANonActiveWindow() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var user = app.newUser(); var window1 = user.newWindow(); var window2 = user.newWindow(); @@ -225,7 +214,7 @@ void uiClose_leavesActiveContextAloneWhenClosingANonActiveWindow() { @Test void appClose_doesNotLeakActiveContextAcrossInvocations() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { app.newUser().newWindow(); // Sanity: window is active during the try block Assertions.assertNotNull(BrowserlessUIContext.getActive()); @@ -241,8 +230,7 @@ void appClose_doesNotLeakActiveContextAcrossInvocations() { @Test void uiClose_restoresUserSecurityContextForDetachListeners() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var alice = app.newUser("alice"); var aliceWindow = alice.newWindow(); @@ -275,7 +263,7 @@ void uiClose_restoresUserSecurityContextForDetachListeners() { @Test void uiClose_detachListenerSeesClosingUserRequest() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var alice = app.newUser(); var aliceWindow = alice.newWindow(); var aliceRequest = VaadinRequest.getCurrent(); @@ -299,8 +287,7 @@ void uiClose_detachListenerSeesClosingUserRequest() { @Test void uiClose_nonActiveCrossUserCloseLeavesThreadCoherentWithActiveContext() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var alice = app.newUser("alice"); var aliceWindow = alice.newWindow(); @@ -347,8 +334,7 @@ void uiClose_nonActiveCrossUserCloseLeavesThreadCoherentWithActiveContext() { @Test void userClose_clearsSecurityContext() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var user = app.newUser("alice"); user.newWindow(); @@ -367,8 +353,7 @@ void userClose_clearsSecurityContext() { @Test void userClose_destroyListenerSeesClosingUserSecurity() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var alice = app.newUser("alice"); alice.newWindow(); @@ -394,7 +379,7 @@ void userClose_destroyListenerSeesClosingUserSecurity() { @Test void uiClose_doesNotLeakLastNavigationIntoNewWindow() { - try (var app = BrowserlessApplicationContext.create(routes)) { + try (var app = BrowserlessApplicationContext.create(SimpleView.class)) { var user = app.newUser(); var w1 = user.newWindow(); w1.navigate(SimpleView.class); @@ -420,8 +405,7 @@ void uiClose_doesNotLeakLastNavigationIntoNewWindow() { @Test void userClose_leavesActiveUserCoherentOnTheThread() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = securedAppFor(handler)) { var alice = app.newUser("alice"); alice.newWindow(); @@ -454,4 +438,11 @@ void userClose_leavesActiveUserCoherentOnTheThread() { } } + private static SecuredBrowserlessApplicationContext securedAppFor( + CapturingSecurityHandler handler) { + return BrowserlessApplicationContext + .createSecured(b -> b.withViewPackages(SimpleView.class) + .withSecurityContextHandler(handler)); + } + } diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessNewUserTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessNewUserTest.java index 24bd283..5bc63ea 100644 --- a/junit6/src/test/java/com/vaadin/browserless/BrowserlessNewUserTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessNewUserTest.java @@ -19,30 +19,20 @@ import com.example.multiuser.SimpleView; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; - /** * Tests construction-time semantics of {@link BrowserlessUserContext}: order of * security setup vs. session-init listener firing. */ class BrowserlessNewUserTest { - private Routes routes; - - @BeforeEach - void setUp() { - routes = new Routes() - .autoDiscoverViews(SimpleView.class.getPackageName()); - } - @Test void newUser_sessionInitListenerSeesThisUsersSecurity() { var handler = new CapturingSecurityHandler(); - try (var app = BrowserlessApplicationContext.builder(routes) - .withSecurityContextHandler(handler).build()) { + try (var app = BrowserlessApplicationContext + .createSecured(b -> b.withViewPackages(SimpleView.class) + .withSecurityContextHandler(handler))) { var observed = new AtomicReference(); app.getService().addSessionInitListener( diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextClosedTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextClosedTest.java index 4b8cbbf..8051305 100644 --- a/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextClosedTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextClosedTest.java @@ -30,7 +30,6 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import com.vaadin.browserless.internal.Routes; import com.vaadin.browserless.internal.UIFactory; import com.vaadin.flow.component.html.Div; @@ -44,10 +43,8 @@ class BrowserlessUIContextClosedTest { @BeforeEach void setUp() { - Routes routes = new Routes() - .autoDiscoverViews(SimpleView.class.getPackageName()) - .autoDiscoverViews(SingleParam.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext.create(SimpleView.class, + SingleParam.class); } @AfterEach @@ -112,9 +109,8 @@ void newWindow_uiFactoryThrows_doesNotLeakActiveContext() { throw new IllegalStateException("simulated UI factory failure"); }; BrowserlessApplicationContext failingApp = BrowserlessApplicationContext - .builder(new Routes() - .autoDiscoverViews(SimpleView.class.getPackageName())) - .withUIFactory(throwingFactory).build(); + .create(b -> b.withViewPackages(SimpleView.class) + .withUIFactory(throwingFactory)); BrowserlessUIContext leakedActive; try { var failingUser = failingApp.newUser(); diff --git a/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextDSLTest.java b/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextDSLTest.java index a108a6a..37bab18 100644 --- a/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextDSLTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/BrowserlessUIContextDSLTest.java @@ -23,7 +23,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.Key; import com.vaadin.flow.component.KeyModifier; import com.vaadin.flow.component.html.NativeButtonTester; @@ -40,9 +39,7 @@ class BrowserlessUIContextDSLTest { @BeforeEach void setUp() { - Routes routes = new Routes() - .autoDiscoverViews(SignalsView.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext.create(SignalsView.class); window = app.newUser().newWindow(); } diff --git a/junit6/src/test/java/com/vaadin/browserless/ExternalNavigationTest.java b/junit6/src/test/java/com/vaadin/browserless/ExternalNavigationTest.java index 9112e88..1413c2f 100644 --- a/junit6/src/test/java/com/vaadin/browserless/ExternalNavigationTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/ExternalNavigationTest.java @@ -24,7 +24,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.button.Button; /** @@ -37,9 +36,8 @@ class ExternalNavigationTest { @BeforeEach void setUp() { - Routes routes = new Routes().autoDiscoverViews( - ExternalNavigationView.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext + .create(ExternalNavigationView.class); } @AfterEach diff --git a/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java index 09fc623..c06ad3e 100644 --- a/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/LocatorApiTest.java @@ -24,7 +24,6 @@ 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; @@ -46,14 +45,13 @@ */ class LocatorApiTest { - private static Routes routes() { - return new Routes() - .autoDiscoverViews(LocatorDemoView.class.getPackageName()); + private static BrowserlessApplicationContext createApplicationContext() { + return BrowserlessApplicationContext.create(LocatorDemoView.class); } @Test void buttonByCaption_click_firesListener() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -70,7 +68,7 @@ void buttonByCaption_click_firesListener() { @Test void buttonByCaption_multipleButtons_filterPicksRightOne() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -84,7 +82,7 @@ void buttonByCaption_multipleButtons_filterPicksRightOne() { @Test void textField_setValue_thenRead_roundTrips() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -96,7 +94,7 @@ void textField_setValue_thenRead_roundTrips() { @Test void grid_typedRowAccessor() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -109,7 +107,7 @@ void grid_typedRowAccessor() { @Test void singleLocator_reusedAfterUiChange_reresolves() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -130,7 +128,7 @@ void singleLocator_reusedAfterUiChange_reresolves() { @Test void customLocator_viaGetSupplier_composesBuiltins() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -145,7 +143,7 @@ void customLocator_viaGetSupplier_composesBuiltins() { @Test void filterChain_expandedSurface() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -164,7 +162,7 @@ void filterChain_expandedSurface() { @Test void filterChain_escapeHatch_unaryOperator() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -182,7 +180,7 @@ void filterChain_escapeHatch_unaryOperator() { @Test void filterChain_escapeHatch_returnsDifferentQuery() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -205,7 +203,7 @@ void filterChain_escapeHatch_returnsDifferentQuery() { @Test void exists_truePathAndFalsePath() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -220,7 +218,7 @@ void exists_truePathAndFalsePath() { @Test void components_returnsAllMatchesAndKeepsLocatorReusable() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -237,7 +235,7 @@ void components_returnsAllMatchesAndKeepsLocatorReusable() { @Test void inside_componentOverload_scopesToDescendants() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -255,7 +253,7 @@ void inside_componentOverload_scopesToDescendants() { @Test void inside_locatorOverload_evaluatesParentLazily() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -294,7 +292,7 @@ public LocatorDemoView.PersonForm component() { @Test void inside_locatorOverload_rejectsSelfReference() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -306,7 +304,7 @@ void inside_locatorOverload_rejectsSelfReference() { @Test void use_seedsLocatorWithComponent_actionWorks() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -327,7 +325,7 @@ void use_seedsLocatorWithComponent_actionWorks() { @Test void use_componentAndExistsReturnSeededInstance() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -346,7 +344,7 @@ void use_componentAndExistsReturnSeededInstance() { @Test void use_additionalFilterCanExcludeSeededComponent() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -366,7 +364,7 @@ void use_additionalFilterCanExcludeSeededComponent() { @Test void use_genericTarget_carriesTypeArg() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -381,7 +379,7 @@ void use_genericTarget_carriesTypeArg() { @Test void filterChain_escapeHatch_nullReturnThrows() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -398,7 +396,7 @@ void filterChain_escapeHatch_nullReturnThrows() { @Test void atIndex_picksNthMatch() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -414,7 +412,7 @@ void atIndex_picksNthMatch() { @Test void atIndex_stickyAcrossFilterSteps_butClearedByInvalidate() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -462,7 +460,7 @@ void atIndex_zeroOrNegativeThrows() { @Test void findSupplier_nullReturnThrows() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var window = app.newUser().newWindow(); window.navigate(LocatorDemoView.class); @@ -477,7 +475,7 @@ void findSupplier_nullReturnThrows() { @Test void multiUser_locatorsRespectActiveWindow() { - try (var app = BrowserlessApplicationContext.create(routes())) { + try (var app = createApplicationContext()) { var alice = app.newUser().newWindow(); var bob = app.newUser().newWindow(); alice.navigate(LocatorDemoView.class); diff --git a/junit6/src/test/java/com/vaadin/browserless/MultiUserTest.java b/junit6/src/test/java/com/vaadin/browserless/MultiUserTest.java index 3ff34da..216cf75 100644 --- a/junit6/src/test/java/com/vaadin/browserless/MultiUserTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/MultiUserTest.java @@ -21,7 +21,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Paragraph; @@ -36,9 +35,7 @@ class MultiUserTest { @BeforeEach void setUp() { SharedCounterView.counter.set(0); - Routes routes = new Routes() - .autoDiscoverViews(SharedCounterView.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext.create(SharedCounterView.class); } @AfterEach diff --git a/junit6/src/test/java/com/vaadin/browserless/MultiWindowTest.java b/junit6/src/test/java/com/vaadin/browserless/MultiWindowTest.java index a0c97a2..3fddd56 100644 --- a/junit6/src/test/java/com/vaadin/browserless/MultiWindowTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/MultiWindowTest.java @@ -22,7 +22,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Paragraph; @@ -37,9 +36,7 @@ class MultiWindowTest { @BeforeEach void setUp() { SharedCounterView.counter.set(0); - Routes routes = new Routes() - .autoDiscoverViews(SharedCounterView.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext.create(SharedCounterView.class); } @AfterEach diff --git a/junit6/src/test/java/com/vaadin/browserless/SignalsContextTest.java b/junit6/src/test/java/com/vaadin/browserless/SignalsContextTest.java index 38e4f95..c2f3cae 100644 --- a/junit6/src/test/java/com/vaadin/browserless/SignalsContextTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/SignalsContextTest.java @@ -26,7 +26,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; -import com.vaadin.browserless.internal.Routes; import com.vaadin.flow.signals.SignalEnvironment; /** @@ -43,9 +42,7 @@ class SignalsContextTest { @BeforeEach void setUp() { - Routes routes = new Routes() - .autoDiscoverViews(SignalsView.class.getPackageName()); - app = BrowserlessApplicationContext.create(routes); + app = BrowserlessApplicationContext.create(SignalsView.class); window = app.newUser().newWindow(); } @@ -173,8 +170,7 @@ void applicationContextClose_unregistersSignalEnvironment() // Re-opening a fresh context must re-install a working test // environment — proving the previous unregister fully released the // global registry. - app = BrowserlessApplicationContext.create(new Routes() - .autoDiscoverViews(SignalsView.class.getPackageName())); + app = BrowserlessApplicationContext.create(SignalsView.class); window = app.newUser().newWindow(); var view = window.navigate(SignalsView.class); CompletableFuture diff --git a/junit6/src/test/java/com/vaadin/browserless/TesterResolutionTest.java b/junit6/src/test/java/com/vaadin/browserless/TesterResolutionTest.java index d478f52..117273b 100644 --- a/junit6/src/test/java/com/vaadin/browserless/TesterResolutionTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/TesterResolutionTest.java @@ -56,13 +56,13 @@ public void wrapTestComponentForConcreteWrapper_returnsNonGenericTestWrap() { @Test void detectComponentType_resolvesComponentTypeThroughHierarchy() { Assertions.assertEquals(Component.class, - detectComponentType(ComponentTester.class)); + TesterRegistry.detectComponentType(ComponentTester.class)); Assertions.assertEquals(TestComponent.class, - detectComponentType(MyTester.class)); + TesterRegistry.detectComponentType(MyTester.class)); Assertions.assertEquals(MyTest.class, - detectComponentType(MyExtendedTester.class)); + TesterRegistry.detectComponentType(MyExtendedTester.class)); Assertions.assertEquals(TestComponentForConcreteTester.class, - detectComponentType(NonGenericTestTester.class)); + TesterRegistry.detectComponentType(NonGenericTestTester.class)); } public static class MyTest extends TestComponent { diff --git a/junit6/src/test/java/com/vaadin/browserless/TesterScanTest.java b/junit6/src/test/java/com/vaadin/browserless/TesterScanTest.java index ebdea1b..6126acf 100644 --- a/junit6/src/test/java/com/vaadin/browserless/TesterScanTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/TesterScanTest.java @@ -26,7 +26,7 @@ public class TesterScanTest { public void scanForTesters_testerForClassNotInClasspath_doNotThrowOnClassNotFoundException() { // Loads a dummy tester annotated with @Tests using an FQN to a // non-existing component class. - Assertions.assertDoesNotThrow(() -> BaseBrowserlessTest.scanForTesters( + Assertions.assertDoesNotThrow(() -> TesterRegistry.registerPackages( "com.vaadin.browserless.dontscan.classnotfound")); } @@ -35,7 +35,7 @@ public void scanForTesters_testerForClassNotInClasspath_doNotThrowNoClassDefFoun // Loads a dummy tester annotated with @Tests referencing a class in // another module with provided scope so the test itself is not able to // load the class. - Assertions.assertDoesNotThrow(() -> BaseBrowserlessTest.scanForTesters( + Assertions.assertDoesNotThrow(() -> TesterRegistry.registerPackages( "com.vaadin.browserless.dontscan.noclassdeffound")); } @@ -44,7 +44,7 @@ public void scanForTesters_testerForClassNotInClasspath_doNotThrowTypeNotPresent // Loads a dummy tester annotated with @Tests referencing a class in // another module with provided scope so the test itself is not able to // load the class. - Assertions.assertDoesNotThrow(() -> BaseBrowserlessTest.scanForTesters( + Assertions.assertDoesNotThrow(() -> TesterRegistry.registerPackages( "com.vaadin.browserless.dontscan.typenotpresent")); } diff --git a/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetComponent.java b/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetComponent.java new file mode 100644 index 0000000..d552114 --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetComponent.java @@ -0,0 +1,23 @@ +/* + * 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.builderfixtures; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; + +@Tag("div") +public class WidgetComponent extends Component { +} diff --git a/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetTester.java b/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetTester.java new file mode 100644 index 0000000..554fb2b --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/builderfixtures/WidgetTester.java @@ -0,0 +1,28 @@ +/* + * 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.builderfixtures; + +import com.vaadin.browserless.ComponentTester; +import com.vaadin.browserless.Tests; + +@Tests(WidgetComponent.class) +public class WidgetTester + extends ComponentTester { + + public WidgetTester(T component) { + super(component); + } +} diff --git a/quarkus/src/main/java/com/vaadin/browserless/quarkus/QuarkusBrowserlessApplicationContext.java b/quarkus/src/main/java/com/vaadin/browserless/quarkus/QuarkusBrowserlessApplicationContext.java index a6cafd5..079ca4c 100644 --- a/quarkus/src/main/java/com/vaadin/browserless/quarkus/QuarkusBrowserlessApplicationContext.java +++ b/quarkus/src/main/java/com/vaadin/browserless/quarkus/QuarkusBrowserlessApplicationContext.java @@ -17,12 +17,13 @@ import jakarta.enterprise.inject.spi.CDI; +import java.util.Objects; +import java.util.function.UnaryOperator; + import io.quarkus.security.identity.SecurityIdentity; import com.vaadin.browserless.BrowserlessApplicationContext; import com.vaadin.browserless.SecuredBrowserlessApplicationContext; -import com.vaadin.browserless.internal.Routes; -import com.vaadin.browserless.internal.UIFactory; import com.vaadin.browserless.mocks.MockedUI; import com.vaadin.browserless.quarkus.mocks.MockQuarkusServlet; @@ -33,18 +34,19 @@ * Wires a Quarkus-aware servlet and the {@link QuarkusTestLookupInitializer}. * Three entry points are provided: *
    - *
  • {@link #create(Routes)} — returns the unsecured - * {@link BrowserlessApplicationContext}.
  • - *
  • {@link #createSecured(Routes)} — returns the credential-typed - * {@link SecuredBrowserlessApplicationContext}; requires Quarkus Security on - * the classpath.
  • - *
  • {@link #builder(Routes)} — returns a pre-wired - * {@link BrowserlessApplicationContext.Builder} for full customization (e.g. - * plugging in a different security handler).
  • + *
  • {@link #create(UnaryOperator)} (and the view-package shortcuts) — returns + * the unsecured {@link BrowserlessApplicationContext}.
  • + *
  • {@link #createSecured(UnaryOperator)} (and the view-package shortcuts) — + * returns the credential-typed {@link SecuredBrowserlessApplicationContext}; + * requires Quarkus Security on the classpath.
  • + *
  • {@link #builder()} — returns a pre-wired + * {@link BrowserlessApplicationContext.Builder} for full customization (e.g. a + * custom {@code UIFactory} or a different security handler).
  • *
* *
- * var app = QuarkusBrowserlessApplicationContext.createSecured(routes);
+ * var app = QuarkusBrowserlessApplicationContext
+ *         .createSecured(ProtectedView.class);
  * var admin = app.newUser(securityIdentity);
  * var window = admin.newWindow();
  * window.navigate(ProtectedView.class);
@@ -62,97 +64,114 @@ private QuarkusBrowserlessApplicationContext() {
     /**
      * Creates a Quarkus-pre-wired builder. The builder has the Quarkus servlet
      * and the lookup initializer configured; callers can chain additional
-     * customizations before calling
+     * customizations (including a custom {@code UIFactory}) before calling
      * {@link BrowserlessApplicationContext.Builder#build()}.
      *
-     * @param routes
-     *            the discovered routes
      * @return a pre-wired builder
      */
-    public static BrowserlessApplicationContext.Builder builder(Routes routes) {
-        return builder(routes, () -> new MockedUI());
+    public static BrowserlessApplicationContext.Builder builder() {
+        return new BrowserlessApplicationContext.Builder()
+                .withServletFactory((r, uif) -> new MockQuarkusServlet(r,
+                        CDI.current().getBeanManager(), uif))
+                .withUIFactory(() -> new MockedUI())
+                .withLookupServices(QuarkusTestLookupInitializer.class);
     }
 
     /**
-     * Creates a Quarkus-pre-wired builder with a custom UI factory.
+     * Creates an unsecured Quarkus-integrated application context that scans
+     * the given packages for {@code @Route}-annotated views.
      *
-     * @param routes
-     *            the discovered routes
-     * @param uiFactory
-     *            the UI factory
-     * @return a pre-wired builder
+     * @param viewPackages
+     *            package names to scan for views; an empty array falls back to
+     *            a full classpath scan
+     * @return a new unsecured application context configured for Quarkus
      */
-    public static BrowserlessApplicationContext.Builder builder(Routes routes,
-            UIFactory uiFactory) {
-        return BrowserlessApplicationContext.builder(routes)
-                .withServletFactory((r, uif) -> new MockQuarkusServlet(r,
-                        CDI.current().getBeanManager(), uif))
-                .withUIFactory(uiFactory)
-                .withLookupServices(QuarkusTestLookupInitializer.class);
+    public static BrowserlessApplicationContext create(String... viewPackages) {
+        return create(b -> b.withViewPackages(viewPackages));
     }
 
     /**
-     * Creates an unsecured Quarkus-integrated application context.
+     * Creates an unsecured Quarkus-integrated application context that scans
+     * the packages of the given classes for {@code @Route}-annotated views.
      *
-     * @param routes
-     *            the discovered routes
+     * @param viewPackageClasses
+     *            classes whose packages should be scanned for views
      * @return a new unsecured application context configured for Quarkus
      */
-    public static BrowserlessApplicationContext create(Routes routes) {
-        return builder(routes).build();
+    public static BrowserlessApplicationContext create(
+            Class... viewPackageClasses) {
+        return create(b -> b.withViewPackages(viewPackageClasses));
     }
 
     /**
-     * Creates an unsecured Quarkus-integrated application context with a custom
-     * UI factory.
+     * Creates an unsecured Quarkus-integrated application context, applying the
+     * given configurer to the pre-wired builder before building it.
      *
-     * @param routes
-     *            the discovered routes
-     * @param uiFactory
-     *            the UI factory
+     * @param configurer
+     *            builder configurer; e.g. {@code b -> b.withViewPackages(...)}.
+     *            Pass {@link UnaryOperator#identity()} to keep defaults.
      * @return a new unsecured application context configured for Quarkus
      */
-    public static BrowserlessApplicationContext create(Routes routes,
-            UIFactory uiFactory) {
-        return builder(routes, uiFactory).build();
+    public static BrowserlessApplicationContext create(
+            UnaryOperator configurer) {
+        Objects.requireNonNull(configurer, "configurer must not be null");
+        return configurer.apply(builder()).build();
+    }
+
+    /**
+     * Creates a Quarkus-integrated application context with Quarkus Security
+     * wiring that scans the given packages for {@code @Route}-annotated views.
+     * Requires Quarkus Security on the classpath; throws otherwise.
+     *
+     * @param viewPackages
+     *            package names to scan for views
+     * @return a new secured application context configured for Quarkus Security
+     * @throws IllegalStateException
+     *             if Quarkus Security is not on the classpath
+     */
+    public static SecuredBrowserlessApplicationContext createSecured(
+            String... viewPackages) {
+        return createSecured(b -> b.withViewPackages(viewPackages));
     }
 
     /**
      * Creates a Quarkus-integrated application context with Quarkus Security
-     * wiring. Requires Quarkus Security on the classpath; throws otherwise.
+     * wiring that scans the packages of the given classes for
+     * {@code @Route}-annotated views. Requires Quarkus Security on the
+     * classpath; throws otherwise.
      *
-     * @param routes
-     *            the discovered routes
+     * @param viewPackageClasses
+     *            classes whose packages should be scanned for views
      * @return a new secured application context configured for Quarkus Security
      * @throws IllegalStateException
      *             if Quarkus Security is not on the classpath
      */
     public static SecuredBrowserlessApplicationContext createSecured(
-            Routes routes) {
-        return createSecured(routes, () -> new MockedUI());
+            Class... viewPackageClasses) {
+        return createSecured(b -> b.withViewPackages(viewPackageClasses));
     }
 
     /**
-     * Creates a secured Quarkus-integrated application context with a custom UI
-     * factory. Requires Quarkus Security on the classpath; throws otherwise.
+     * Creates a Quarkus-integrated application context with Quarkus Security
+     * wiring, applying the given configurer to the pre-wired builder. Requires
+     * Quarkus Security on the classpath; throws otherwise.
      *
-     * @param routes
-     *            the discovered routes
-     * @param uiFactory
-     *            the UI factory
+     * @param configurer
+     *            builder configurer
      * @return a new secured application context configured for Quarkus Security
      * @throws IllegalStateException
      *             if Quarkus Security is not on the classpath
      */
     public static SecuredBrowserlessApplicationContext createSecured(
-            Routes routes, UIFactory uiFactory) {
+            UnaryOperator configurer) {
+        Objects.requireNonNull(configurer, "configurer must not be null");
         if (!QuarkusSecuritySupport.isPresent()) {
             throw new IllegalStateException(
                     "QuarkusBrowserlessApplicationContext.createSecured(...)"
                             + " requires Quarkus Security on the classpath."
                             + " Use create(...) for unsecured contexts.");
         }
-        return builder(routes, uiFactory)
+        return configurer.apply(builder())
                 .withSecurityContextHandler(new QuarkusSecurityContextHandler())
                 .build();
     }
diff --git a/quarkus/src/test/java/com/vaadin/browserless/quarkus/MultiUserSecurityTest.java b/quarkus/src/test/java/com/vaadin/browserless/quarkus/MultiUserSecurityTest.java
index dbddfdb..c8775a2 100644
--- a/quarkus/src/test/java/com/vaadin/browserless/quarkus/MultiUserSecurityTest.java
+++ b/quarkus/src/test/java/com/vaadin/browserless/quarkus/MultiUserSecurityTest.java
@@ -30,7 +30,6 @@
 import org.junit.jupiter.api.Test;
 
 import com.vaadin.browserless.SecuredBrowserlessApplicationContext;
-import com.vaadin.browserless.internal.Routes;
 
 /**
  * Tests multi-user security context isolation with Quarkus Security. Verifies
@@ -45,8 +44,8 @@ class MultiUserSecurityTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = QuarkusBrowserlessApplicationContext.createSecured(routes);
+        app = QuarkusBrowserlessApplicationContext
+                .createSecured("com.testapp.security");
     }
 
     @AfterEach
diff --git a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusNewUserHelperTest.java b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusNewUserHelperTest.java
index 19e877c..6818b04 100644
--- a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusNewUserHelperTest.java
+++ b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusNewUserHelperTest.java
@@ -31,7 +31,6 @@
 import org.junit.jupiter.api.Test;
 
 import com.vaadin.browserless.SecuredBrowserlessApplicationContext;
-import com.vaadin.browserless.internal.Routes;
 
 /**
  * Tests the {@code newUser(username, roles...)} helper in
@@ -47,8 +46,8 @@ class QuarkusNewUserHelperTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = QuarkusBrowserlessApplicationContext.createSecured(routes);
+        app = QuarkusBrowserlessApplicationContext
+                .createSecured("com.testapp.security");
     }
 
     @AfterEach
diff --git a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecurityAbsentTest.java b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecurityAbsentTest.java
index f95408e..6f7b833 100644
--- a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecurityAbsentTest.java
+++ b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecurityAbsentTest.java
@@ -15,13 +15,14 @@
  */
 package com.vaadin.browserless.quarkus;
 
+import java.util.function.UnaryOperator;
+
 import io.quarkus.test.junit.QuarkusTest;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import com.vaadin.browserless.internal.MockRequestCustomizer;
-import com.vaadin.browserless.internal.Routes;
 import com.vaadin.flow.di.Lookup;
 import com.vaadin.flow.server.VaadinService;
 
@@ -43,8 +44,8 @@ void tearDown() {
     void newUser_withoutQuarkusSecurity_doesNotThrow() {
         QuarkusSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = QuarkusBrowserlessApplicationContext.create(routes)) {
+        try (var app = QuarkusBrowserlessApplicationContext
+                .create(UnaryOperator.identity())) {
             Assertions.assertDoesNotThrow(() -> {
                 var user = app.newUser();
                 user.newWindow();
@@ -56,8 +57,8 @@ void newUser_withoutQuarkusSecurity_doesNotThrow() {
     void create_withoutQuarkusSecurity_doesNotInstallRequestCustomizerLookup() {
         QuarkusSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = QuarkusBrowserlessApplicationContext.create(routes)) {
+        try (var app = QuarkusBrowserlessApplicationContext
+                .create(UnaryOperator.identity())) {
             // Trigger Lookup initialization via a window, then probe the
             // current VaadinService for the registered MockRequestCustomizer.
             app.newUser().newWindow();
@@ -77,8 +78,8 @@ void create_withoutQuarkusSecurity_doesNotInstallRequestCustomizerLookup() {
     void create_withQuarkusSecurity_installsRequestCustomizerLookup() {
         // Default detector: actual classpath probe (quarkus-security IS
         // present in the quarkus module's test classpath).
-        Routes routes = new Routes();
-        try (var app = QuarkusBrowserlessApplicationContext.create(routes)) {
+        try (var app = QuarkusBrowserlessApplicationContext
+                .create(UnaryOperator.identity())) {
             app.newUser().newWindow();
             Lookup lookup = VaadinService.getCurrent().getContext()
                     .getAttribute(Lookup.class);
@@ -93,10 +94,9 @@ void create_withQuarkusSecurity_installsRequestCustomizerLookup() {
     void createSecured_withoutQuarkusSecurity_throws() {
         QuarkusSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
         var ex = Assertions.assertThrows(IllegalStateException.class,
                 () -> QuarkusBrowserlessApplicationContext
-                        .createSecured(routes),
+                        .createSecured(UnaryOperator.identity()),
                 "createSecured(...) must reject calls when Quarkus Security is"
                         + " absent from the classpath");
         Assertions.assertTrue(ex.getMessage().contains("Quarkus Security"),
diff --git a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecuritySnapshotTest.java b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecuritySnapshotTest.java
index 0878907..a47de38 100644
--- a/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecuritySnapshotTest.java
+++ b/quarkus/src/test/java/com/vaadin/browserless/quarkus/QuarkusSecuritySnapshotTest.java
@@ -32,7 +32,6 @@
 import org.junit.jupiter.api.Test;
 
 import com.vaadin.browserless.SecuredBrowserlessApplicationContext;
-import com.vaadin.browserless.internal.Routes;
 
 /**
  * Tests that the Quarkus security identity snapshot is a defensive copy, not a
@@ -47,8 +46,8 @@ class QuarkusSecuritySnapshotTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = QuarkusBrowserlessApplicationContext.createSecured(routes);
+        app = QuarkusBrowserlessApplicationContext
+                .createSecured("com.testapp.security");
     }
 
     @AfterEach
diff --git a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java
index 9133ddc..d41536b 100644
--- a/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java
+++ b/shared/src/main/java/com/vaadin/browserless/BaseBrowserlessTest.java
@@ -15,32 +15,17 @@
  */
 package com.vaadin.browserless;
 
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.ParameterizedType;
-import java.lang.reflect.Type;
-import java.util.Arrays;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
-import com.googlecode.gentyref.GenericTypeReflector;
-import io.github.classgraph.ClassGraph;
-import io.github.classgraph.ClassInfoList;
-import io.github.classgraph.ScanResult;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import com.vaadin.browserless.internal.MockVaadin;
 import com.vaadin.browserless.internal.Routes;
-import com.vaadin.browserless.internal.UtilsKt;
 import com.vaadin.browserless.mocks.MockedUI;
 import com.vaadin.flow.component.Component;
 import com.vaadin.flow.component.HasElement;
@@ -66,82 +51,8 @@
  */
 public abstract class BaseBrowserlessTest {
 
-    private static final Logger LOGGER = LoggerFactory
-            .getLogger(BaseBrowserlessTest.class.getPackageName());
-    private static final ConcurrentHashMap routesCache = new ConcurrentHashMap<>();
-
-    protected static final Map, Class> testers = new HashMap<>();
-    protected static final Set scanned = new HashSet<>();
-
     private TestSignalEnvironment signalsTestEnvironment;
 
-    static {
-        testers.putAll(scanForTesters("com.vaadin.flow.component"));
-    }
-
-    // Protected for access by adapter subclass in legacy module
-    protected static Map, Class> scanForTesters(
-            String... packages) {
-        try (ScanResult scan = new ClassGraph().enableClassInfo()
-                .enableAnnotationInfo().acceptPackages(packages).scan(2)) {
-            ClassInfoList testerList = scan
-                    .getClassesWithAnnotation(Tests.class.getName());
-            Map, Class> testerMap = new HashMap<>();
-            testerList
-                    .filter(classInfo -> classInfo
-                            .extendsSuperclass(ComponentTester.class))
-                    .forEach(classInfo -> {
-                        try {
-                            final Class tester = UtilsKt
-                                    .findClassOrThrow(classInfo.getName());
-                            final Class[] annotation = tester
-                                    .getAnnotation(Tests.class).value();
-                            for (Class component : annotation) {
-                                testerMap.put(component,
-                                        (Class) tester);
-                            }
-                            // -- Enable annotation with fqn for components with
-                            // generics
-                            final String[] classes = tester
-                                    .getAnnotation(Tests.class).fqn();
-
-                            Arrays.stream(classes).map(clazz -> {
-                                try {
-                                    return UtilsKt.findClassOrThrow(clazz);
-                                } catch (ClassNotFoundException e) {
-                                    logTypeLoadingIssue(e,
-                                            "Tester '{}' cannot be loaded because of missing component class '{}' on classpath",
-                                            classInfo.getName(), clazz);
-                                }
-                                return null;
-                            }).filter(Objects::nonNull)
-                                    .forEach(clazz -> testerMap.put(clazz,
-                                            (Class) tester));
-
-                        } catch (TypeNotPresentException e) {
-                            logTypeLoadingIssue(e,
-                                    "Tester '{}' cannot be loaded because of missing class '{}' on classpath",
-                                    classInfo.getName(), e.typeName());
-                        } catch (ClassNotFoundException
-                                | NoClassDefFoundError e) {
-                            logTypeLoadingIssue(e,
-                                    "Tester '{}' cannot be loaded because of missing class on classpath: {}",
-                                    classInfo.getName(), e.getMessage());
-                        }
-                    });
-            return Collections.unmodifiableMap(testerMap);
-        }
-    }
-
-    private static void logTypeLoadingIssue(Throwable ex, String message,
-            Object... args) {
-        if (LOGGER.isDebugEnabled()) {
-            LOGGER.debug(message, args, ex);
-        } else {
-            LOGGER.warn(message, args);
-        }
-    }
-
     protected synchronized Routes discoverRoutes() {
         return discoverRoutes(scanPackages());
     }
@@ -152,16 +63,9 @@ protected synchronized Routes discoverRoutes() {
      * @see #initVaadinEnvironment()
      * @return Routes
      */
-    protected static synchronized Routes discoverRoutes(
-            Set packageNames) {
-        packageNames = packageNames == null || packageNames.isEmpty()
-                ? Set.of("")
-                : packageNames;
-
-        return packageNames.stream()
-                .map(pkg -> routesCache.computeIfAbsent(pkg,
-                        p -> new Routes().autoDiscoverViews(p)))
-                .reduce(new Routes(), Routes::merge);
+    // Protected for access by adapter subclass in legacy module
+    protected static Routes discoverRoutes(Set packageNames) {
+        return RouteDiscovery.discover(packageNames);
     }
 
     /**
@@ -186,13 +90,8 @@ protected void initSignalsSupport() {
      */
     protected void scanTesters() {
         if (getClass().isAnnotationPresent(ComponentTesterPackages.class)) {
-            final List packages = Arrays.asList(getClass()
+            TesterRegistry.registerPackages(getClass()
                     .getAnnotation(ComponentTesterPackages.class).value());
-            if (!scanned.containsAll((packages))) {
-                scanned.addAll(packages);
-                testers.putAll(scanForTesters(getClass()
-                        .getAnnotation(ComponentTesterPackages.class).value()));
-            }
         }
     }
 
@@ -332,15 +231,14 @@ public HasElement getCurrentView() {
     }
 
     // Protected for access by adapter subclass in legacy module
-    @SuppressWarnings("unchecked")
     protected static , Y extends Component> T internalWrap(
             Y component) {
-        return (T) initialize(getTester(component.getClass()), component);
+        return TesterRegistry.wrap(component);
     }
 
     protected static , Y extends Component> T internalWrap(
             Class wrap, Y component) {
-        return initialize(wrap, component);
+        return TesterRegistry.instantiate(wrap, component);
     }
 
     /**
@@ -376,19 +274,7 @@ public , Y extends Component> T test(
     public , Y extends Component> T test(
             Class tester, Y component) {
         verifyAndGetUI();
-        return (T) initialize(tester, component);
-    }
-
-    private static  Class getTester(
-            Class component) {
-        Class latest = component;
-        do {
-            if (testers.containsKey(latest)) {
-                return testers.get(latest);
-            }
-            latest = latest.getSuperclass();
-        } while (!Component.class.equals(latest));
-        return ComponentTester.class;
+        return TesterRegistry.instantiate(tester, component);
     }
 
     /**
@@ -489,34 +375,6 @@ public  ComponentQuery findInView(
         return findInView(componentType);
     }
 
-    /**
-     * Private initializer for tester classes.
-     *
-     * @param clazz
-     *            tester class to initialize
-     * @param component
-     *            component used with tester class
-     * @param 
-     *            component tester type
-     * @param 
-     *            component type
-     * @return tester with component set
-     */
-    private static , Y extends Component> T initialize(
-            Class clazz, Y component) {
-        try {
-            // Get the generic class for given wrapper. Component should be an
-            // instance of this.
-            final Class aClass = detectComponentType(clazz);
-            return clazz.getConstructor(aClass).newInstance(component);
-        } catch (InstantiationException | IllegalAccessException
-                | InvocationTargetException | NoSuchMethodException e) {
-            throw new RuntimeException("Could not instantiate "
-                    + clazz.getSimpleName() + " for component "
-                    + component.getClass().getSimpleName());
-        }
-    }
-
     /**
      * Simulates a server round-trip, flushing pending component changes.
      */
@@ -581,59 +439,6 @@ protected final boolean runPendingSignalsTasks(long maxWaitTime,
                 maxWaitTime, unit);
     }
 
-    /**
-     * Detects the component type for the given tester from generic declaration,
-     * by inspecting class hierarchy to resolve the concrete type for
-     * {@link ComponentTester} defined type variable.
-     *
-     * @param testerType
-     *            the tester type
-     * @return the component type the tester defines
-     */
-    @SuppressWarnings("rawtypes")
-    static Class detectComponentType(
-            Class testerType) {
-        if (testerType == ComponentTester.class) {
-            return Component.class;
-        }
-        Map typeMap = new HashMap<>();
-        Class clazz = testerType;
-        while (!clazz.equals(ComponentTester.class)) {
-            extractTypeArguments(typeMap, clazz);
-            clazz = clazz.getSuperclass();
-        }
-        return GenericTypeReflector.erase(
-                typeMap.get(ComponentTester.class.getTypeParameters()[0]));
-    }
-
-    /**
-     * Collects actual type for type variables declared by the generic
-     * declaration of given clazz.
-     *
-     * @param typeMap
-     *            map associating type variables to actual type
-     * @param clazz
-     *            the class to inspect for generic types
-     */
-    private static void extractTypeArguments(Map typeMap,
-            Class clazz) {
-        Type genericSuperclass = clazz.getGenericSuperclass();
-        if (!(genericSuperclass instanceof ParameterizedType)) {
-            return;
-        }
-
-        ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass;
-        Type[] typeParameter = ((Class) parameterizedType.getRawType())
-                .getTypeParameters();
-        Type[] actualTypeArgument = parameterizedType.getActualTypeArguments();
-        for (int i = 0; i < typeParameter.length; i++) {
-            if (typeMap.containsKey(actualTypeArgument[i])) {
-                actualTypeArgument[i] = typeMap.get(actualTypeArgument[i]);
-            }
-            typeMap.put(typeParameter[i], actualTypeArgument[i]);
-        }
-    }
-
     /*
      * Checks that the mock UI is available, otherwise fails fast with an
      * exception giving advices on possible causes of the problem.
diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java
index e820b73..bd556bd 100644
--- a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java
+++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java
@@ -22,6 +22,9 @@
 import java.util.Objects;
 import java.util.Set;
 import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.UnaryOperator;
+import java.util.stream.Stream;
 
 import com.vaadin.browserless.internal.MockVaadin;
 import com.vaadin.browserless.internal.Routes;
@@ -56,7 +59,7 @@
  * (Spring, Quarkus) provide convenience factory methods for both paths.
  *
  * 
- * var app = BrowserlessApplicationContext.create(routes);
+ * var app = BrowserlessApplicationContext.create(MyView.class);
  * var user1 = app.newUser();
  * var window1 = user1.newWindow();
  * window1.navigate(MyView.class);
@@ -89,30 +92,70 @@ public class BrowserlessApplicationContext implements AutoCloseable {
     }
 
     /**
-     * Creates a plain Java application context with default settings.
-     * 

- * The returned context has no {@link SecurityContextHandler} configured; - * use {@link #builder(Routes)} and - * {@link Builder#withSecurityContextHandler(SecurityContextHandler)} to - * enable framework-specific security integration. + * Creates a plain Java application context, scanning the given packages for + * {@code @Route}-annotated views. + * + * @param viewPackages + * package names to scan for views; an empty array falls back to + * a full classpath scan + * @return a new application context + */ + public static BrowserlessApplicationContext create(String... viewPackages) { + return new Builder().withViewPackages(viewPackages).build(); + } + + /** + * Creates a plain Java application context, scanning the packages of the + * given classes for {@code @Route}-annotated views. * - * @param routes - * the discovered routes + * @param viewPackageClasses + * classes whose packages should be scanned for views * @return a new application context */ - public static BrowserlessApplicationContext create(Routes routes) { - return builder(routes).build(); + public static BrowserlessApplicationContext create( + Class... viewPackageClasses) { + return new Builder().withViewPackages(viewPackageClasses).build(); } /** - * Creates a builder for customizing the application context. + * Creates a plain Java application context, applying the given configurer + * to a fresh builder before {@link Builder#build() building} it. The + * configurer cannot be {@code null}; pass {@link UnaryOperator#identity()} + * to accept all defaults. + * + * @param configurer + * builder configurer + * @return a new application context + */ + public static BrowserlessApplicationContext create( + UnaryOperator configurer) { + Objects.requireNonNull(configurer, "configurer must not be null"); + return configurer.apply(new Builder()).build(); + } + + /** + * Creates a credential-typed application context. The configurer must call + * {@link Builder#withSecurityContextHandler(SecurityContextHandler)} on the + * supplied builder so that it returns a + * {@link SecuredBrowserlessApplicationContext.Builder} carrying the + * required handler. + *

+ * Using a separate method name rather than an overload of + * {@link #create(UnaryOperator)} is intentional: Java overload resolution + * cannot disambiguate two lambdas whose parameter types share the + * {@link Function} erasure. * - * @param routes - * the discovered routes - * @return a new builder + * @param + * the credentials type + * @param configurer + * builder configurer that installs a security handler and + * returns the resulting secured builder + * @return a new credential-aware application context */ - public static Builder builder(Routes routes) { - return new Builder(routes); + public static SecuredBrowserlessApplicationContext createSecured( + Function> configurer) { + Objects.requireNonNull(configurer, "configurer must not be null"); + return configurer.apply(new Builder()).build(); } /** @@ -222,10 +265,104 @@ public static class Builder { private BiFunction servletFactory; private UIFactory uiFactory = () -> new MockedUI(); private Set> lookupServices = Collections.emptySet(); + private final Set viewPackages = new LinkedHashSet<>(); + private final Set componentTesterPackages = new LinkedHashSet<>(); private final List closeHooks = new ArrayList<>(); + /** + * Creates a builder with no pre-seeded routes. Routes are derived from + * the view packages configured on this builder (or from a full + * classpath scan when none are configured) at {@link #build()} time. + */ + public Builder() { + this(null); + } + Builder(Routes routes) { - this.routes = Objects.requireNonNull(routes); + this.routes = routes; + } + + /** + * Adds packages to scan for {@code @Route}-annotated views. Successive + * calls accumulate. Ignored when this builder was created with an + * explicit {@link Routes} instance. + * + * @param packages + * package names to scan + * @return this builder + * @throws NullPointerException + * if {@code packages} or any element is {@code null} + */ + public Builder withViewPackages(String... packages) { + Objects.requireNonNull(packages, "packages must not be null"); + for (String pkg : packages) { + viewPackages.add(Objects.requireNonNull(pkg, + "package name must not be null")); + } + return this; + } + + /** + * Adds the packages of the given classes to the set of packages to scan + * for {@code @Route}-annotated views. Successive calls accumulate. + * Ignored when this builder was created with an explicit {@link Routes} + * instance. + * + * @param classes + * classes whose packages should be scanned + * @return this builder + * @throws NullPointerException + * if {@code classes} or any element is {@code null} + */ + public Builder withViewPackages(Class... classes) { + Objects.requireNonNull(classes, "classes must not be null"); + Stream.of(classes) + .map(c -> Objects + .requireNonNull(c, "class must not be null") + .getPackageName()) + .forEach(viewPackages::add); + return this; + } + + /** + * Adds packages to scan for {@link ComponentTester} implementations + * annotated with {@link Tests}. Successive calls accumulate. Each + * package is scanned at most once per JVM (see {@link TesterRegistry}). + * + * @param packages + * package names to scan for testers + * @return this builder + * @throws NullPointerException + * if {@code packages} or any element is {@code null} + */ + public Builder withComponentTesterPackages(String... packages) { + Objects.requireNonNull(packages, "packages must not be null"); + for (String pkg : packages) { + componentTesterPackages.add(Objects.requireNonNull(pkg, + "package name must not be null")); + } + return this; + } + + /** + * Adds the packages of the given classes to the set of packages to scan + * for {@link ComponentTester} implementations annotated with + * {@link Tests}. Successive calls accumulate. + * + * @param classes + * classes whose packages should be scanned for testers + * @return this builder + * @throws NullPointerException + * if {@code classes} or any element is {@code null} + */ + public Builder withComponentTesterPackages(Class... classes) { + Objects.requireNonNull(classes, "classes must not be null"); + Stream.of(classes) + .map(c -> Objects + .requireNonNull(c, "class must not be null") + .getPackageName()) + .forEach(componentTesterPackages::add); + return this; } /** @@ -354,9 +491,15 @@ public BrowserlessApplicationContext build() { } VaadinServletService buildService() { + if (!componentTesterPackages.isEmpty()) { + TesterRegistry.registerPackages( + componentTesterPackages.toArray(String[]::new)); + } + Routes resolvedRoutes = routes != null ? routes + : RouteDiscovery.discover(viewPackages); VaadinServlet servlet = servletFactory != null - ? servletFactory.apply(routes, uiFactory) - : new MockVaadinServlet(routes, uiFactory); + ? servletFactory.apply(resolvedRoutes, uiFactory) + : new MockVaadinServlet(resolvedRoutes, uiFactory); return MockVaadin.setupServlet(servlet, lookupServices); } diff --git a/shared/src/main/java/com/vaadin/browserless/RouteDiscovery.java b/shared/src/main/java/com/vaadin/browserless/RouteDiscovery.java new file mode 100644 index 0000000..dd9adee --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/RouteDiscovery.java @@ -0,0 +1,59 @@ +/* + * 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.Set; +import java.util.concurrent.ConcurrentHashMap; + +import com.vaadin.browserless.internal.Routes; + +/** + * Process-wide cache of {@link Routes} discovered by classpath scan. + *

+ * Each requested package is scanned at most once per JVM; subsequent calls + * return the cached {@link Routes} for that package. Callers requesting + * multiple packages get a merged {@link Routes} composed from the cached + * per-package entries. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +final class RouteDiscovery { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private RouteDiscovery() { + } + + /** + * Discovers the routes for the given packages, reusing previously cached + * results when available. A {@code null} or empty package set falls back to + * a full classpath scan (the empty-string package marker used by + * {@link Routes#autoDiscoverViews(String...)}). + * + * @param packageNames + * package names to scan; may be {@code null} or empty + * @return the merged {@link Routes} + */ + static synchronized Routes discover(Set packageNames) { + Set effective = packageNames == null || packageNames.isEmpty() + ? Set.of("") + : packageNames; + return effective.stream() + .map(pkg -> CACHE.computeIfAbsent(pkg, + p -> new Routes().autoDiscoverViews(p))) + .reduce(new Routes(), Routes::merge); + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/SecuredBrowserlessApplicationContext.java b/shared/src/main/java/com/vaadin/browserless/SecuredBrowserlessApplicationContext.java index b1a47c4..c53a935 100644 --- a/shared/src/main/java/com/vaadin/browserless/SecuredBrowserlessApplicationContext.java +++ b/shared/src/main/java/com/vaadin/browserless/SecuredBrowserlessApplicationContext.java @@ -29,8 +29,8 @@ *

* Extends {@link BrowserlessApplicationContext} with a configured, non-null * {@link SecurityContextHandler} and credential-typed {@code newUser(...)} - * overloads for installing per-user security state. Build instances via - * {@link BrowserlessApplicationContext#builder(Routes)} followed by + * overloads for installing per-user security state. Build instances via a + * {@link BrowserlessApplicationContext.Builder} configured with * {@link BrowserlessApplicationContext.Builder#withSecurityContextHandler(SecurityContextHandler)}, * which transitions to {@link Builder} and produces this typed context. *

@@ -172,6 +172,38 @@ public Builder withLookupServices(Class... services) { return this; } + /** + * @see BrowserlessApplicationContext.Builder#withViewPackages(String...) + */ + public Builder withViewPackages(String... packages) { + base.withViewPackages(packages); + return this; + } + + /** + * @see BrowserlessApplicationContext.Builder#withViewPackages(Class[]) + */ + public Builder withViewPackages(Class... classes) { + base.withViewPackages(classes); + return this; + } + + /** + * @see BrowserlessApplicationContext.Builder#withComponentTesterPackages(String...) + */ + public Builder withComponentTesterPackages(String... packages) { + base.withComponentTesterPackages(packages); + return this; + } + + /** + * @see BrowserlessApplicationContext.Builder#withComponentTesterPackages(Class[]) + */ + public Builder withComponentTesterPackages(Class... classes) { + base.withComponentTesterPackages(classes); + return this; + } + /** * @see BrowserlessApplicationContext.Builder#withCloseHook */ diff --git a/shared/src/main/java/com/vaadin/browserless/TesterRegistry.java b/shared/src/main/java/com/vaadin/browserless/TesterRegistry.java new file mode 100644 index 0000000..128dd2c --- /dev/null +++ b/shared/src/main/java/com/vaadin/browserless/TesterRegistry.java @@ -0,0 +1,271 @@ +/* + * 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.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import com.googlecode.gentyref.GenericTypeReflector; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfoList; +import io.github.classgraph.ScanResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.browserless.internal.UtilsKt; +import com.vaadin.flow.component.Component; + +/** + * Process-wide registry that resolves a Vaadin component to its + * {@link ComponentTester}. + *

+ * The registry is seeded at class-load time with all testers found in + * {@code com.vaadin.flow.component}. Additional packages can be contributed at + * runtime through {@link #registerPackages(String...)}: each package is + * classpath-scanned at most once across the lifetime of the JVM; subsequent + * registrations of the same package are no-ops. + *

+ * Resolution walks the component's superclass chain and returns the most + * specific match, falling back to the base {@link ComponentTester} when none is + * registered. If two testers claim the same component class, the most recent + * registration wins and a warning is logged. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +final class TesterRegistry { + + private static final Logger LOGGER = LoggerFactory + .getLogger(TesterRegistry.class); + + private static final Map, Class> TESTERS = new HashMap<>(); + private static final Set SCANNED_PACKAGES = new HashSet<>(); + + static { + registerPackages("com.vaadin.flow.component"); + } + + private TesterRegistry() { + } + + /** + * Scans the given packages for {@link ComponentTester} subclasses annotated + * with {@link Tests} and adds them to the registry. Packages that have + * already been scanned are skipped. + * + * @param packages + * package names; may be empty + */ + static synchronized void registerPackages(String... packages) { + Objects.requireNonNull(packages, "packages must not be null"); + if (packages.length == 0) { + return; + } + String[] toScan = Arrays.stream(packages).filter(Objects::nonNull) + .filter(SCANNED_PACKAGES::add).toArray(String[]::new); + if (toScan.length == 0) { + return; + } + scanForTesters(toScan).forEach(TesterRegistry::registerTester); + } + + @SuppressWarnings("rawtypes") + private static void registerTester(Class component, + Class tester) { + Class previous = TESTERS.put(component, + tester); + if (previous != null && !previous.equals(tester)) { + LOGGER.warn("Replacing tester for component {}: {} -> {}. " + + "Multiple testers declare themselves for the same " + + "component class; the most recent registration wins.", + component.getName(), previous.getName(), tester.getName()); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static Map, Class> scanForTesters( + String... packages) { + try (ScanResult scan = new ClassGraph().enableClassInfo() + .enableAnnotationInfo().acceptPackages(packages).scan(2)) { + ClassInfoList testerList = scan + .getClassesWithAnnotation(Tests.class.getName()); + Map, Class> testerMap = new HashMap<>(); + testerList + .filter(classInfo -> classInfo + .extendsSuperclass(ComponentTester.class)) + .forEach(classInfo -> { + try { + final Class tester = UtilsKt + .findClassOrThrow(classInfo.getName()); + final Class[] annotation = tester + .getAnnotation(Tests.class).value(); + for (Class component : annotation) { + testerMap.put(component, + (Class) tester); + } + // Enable annotation with fqn for components with + // generics that cannot be referenced as + // Class + final String[] classes = tester + .getAnnotation(Tests.class).fqn(); + Arrays.stream(classes).map(clazz -> { + try { + return UtilsKt.findClassOrThrow(clazz); + } catch (ClassNotFoundException e) { + logTypeLoadingIssue(e, + "Tester '{}' cannot be loaded because of missing component class '{}' on classpath", + classInfo.getName(), clazz); + return null; + } + }).filter(Objects::nonNull) + .forEach(clazz -> testerMap.put(clazz, + (Class) tester)); + } catch (TypeNotPresentException e) { + logTypeLoadingIssue(e, + "Tester '{}' cannot be loaded because of missing class '{}' on classpath", + classInfo.getName(), e.typeName()); + } catch (ClassNotFoundException + | NoClassDefFoundError e) { + logTypeLoadingIssue(e, + "Tester '{}' cannot be loaded because of missing class on classpath: {}", + classInfo.getName(), e.getMessage()); + } + }); + return Collections.unmodifiableMap(testerMap); + } + } + + private static void logTypeLoadingIssue(Throwable ex, String message, + Object... args) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug(message, args, ex); + } else { + LOGGER.warn(message, args); + } + } + + /** + * Resolves the best matching tester class for the given component class. + * The component's superclass chain is walked and the most specific + * registered tester is returned, falling back to the base + * {@link ComponentTester} when nothing matches. + * + * @param componentClass + * the component class to resolve a tester for + * @return the matching tester class, never {@code null} + */ + @SuppressWarnings("rawtypes") + static synchronized Class resolve( + Class componentClass) { + Class latest = componentClass; + do { + Class tester = TESTERS.get(latest); + if (tester != null) { + return tester; + } + latest = latest.getSuperclass(); + } while (latest != null && !Component.class.equals(latest)); + return ComponentTester.class; + } + + /** + * Resolves the best matching tester for the given component and returns an + * initialized instance. + * + * @param component + * the component to wrap + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + static , Y extends Component> T wrap( + Y component) { + Class testerClass = resolve( + component.getClass()); + return (T) instantiate(testerClass, component); + } + + /** + * Initializes the given tester class with the given component, bypassing + * the registry lookup. + * + * @param testerClass + * the tester class to instantiate + * @param component + * the component the tester should wrap + */ + static , Y extends Component> T instantiate( + Class testerClass, Y component) { + try { + final Class componentType = detectComponentType(testerClass); + return testerClass.getConstructor(componentType) + .newInstance(component); + } catch (InstantiationException | IllegalAccessException + | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException("Could not instantiate " + + testerClass.getSimpleName() + " for component " + + component.getClass().getSimpleName(), e); + } + } + + /** + * Detects the component type the given tester wraps from its generic + * declaration by walking the class hierarchy and resolving the type + * variable declared on {@link ComponentTester}. + * + * @param testerType + * the tester type + * @return the component type the tester defines + */ + @SuppressWarnings("rawtypes") + static Class detectComponentType( + Class testerType) { + if (testerType == ComponentTester.class) { + return Component.class; + } + Map typeMap = new HashMap<>(); + Class clazz = testerType; + while (!clazz.equals(ComponentTester.class)) { + extractTypeArguments(typeMap, clazz); + clazz = clazz.getSuperclass(); + } + return GenericTypeReflector.erase( + typeMap.get(ComponentTester.class.getTypeParameters()[0])); + } + + private static void extractTypeArguments(Map typeMap, + Class clazz) { + Type genericSuperclass = clazz.getGenericSuperclass(); + if (!(genericSuperclass instanceof ParameterizedType)) { + return; + } + ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass; + Type[] typeParameter = ((Class) parameterizedType.getRawType()) + .getTypeParameters(); + Type[] actualTypeArgument = parameterizedType.getActualTypeArguments(); + for (int i = 0; i < typeParameter.length; i++) { + if (typeMap.containsKey(actualTypeArgument[i])) { + actualTypeArgument[i] = typeMap.get(actualTypeArgument[i]); + } + typeMap.put(typeParameter[i], actualTypeArgument[i]); + } + } +} diff --git a/shared/src/test/java/com/vaadin/browserless/BuilderCloseHookTest.java b/shared/src/test/java/com/vaadin/browserless/BuilderCloseHookTest.java index 9714898..5bbb328 100644 --- a/shared/src/test/java/com/vaadin/browserless/BuilderCloseHookTest.java +++ b/shared/src/test/java/com/vaadin/browserless/BuilderCloseHookTest.java @@ -40,7 +40,7 @@ private static Routes emptyRoutes() { @Test void hooksRunInRegistrationOrderOnClose() { List log = new ArrayList<>(); - var app = BrowserlessApplicationContext. builder(emptyRoutes()) + var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withCloseHook(() -> log.add("a")) .withCloseHook(() -> log.add("b")) .withCloseHook(() -> log.add("c")).build(); @@ -54,7 +54,7 @@ void hooksRunInRegistrationOrderOnClose() { @Test void hooksFireExactlyOnceAcrossRedundantCloseCalls() { AtomicInteger calls = new AtomicInteger(); - var app = BrowserlessApplicationContext. builder(emptyRoutes()) + var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withCloseHook(calls::incrementAndGet).build(); app.close(); @@ -68,7 +68,7 @@ void hooksFireExactlyOnceAcrossRedundantCloseCalls() { @Test void allHooksRunEvenIfSomeThrow() { List log = new ArrayList<>(); - var app = BrowserlessApplicationContext. builder(emptyRoutes()) + var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withCloseHook(() -> log.add("a")).withCloseHook(() -> { log.add("b-throws"); throw new IllegalStateException("boom-b"); @@ -96,7 +96,7 @@ void allHooksRunEvenIfSomeThrow() { @Test void singleThrowingHookSurfacesAsAggregateWithOneSuppressed() { var cause = new IllegalStateException("solo-boom"); - var app = BrowserlessApplicationContext. builder(emptyRoutes()) + var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withCloseHook(() -> { throw cause; }).build(); @@ -110,16 +110,15 @@ void singleThrowingHookSurfacesAsAggregateWithOneSuppressed() { @Test void closeWithoutHooksDoesNotThrow() { - try (var app = BrowserlessApplicationContext - . builder(emptyRoutes()).build()) { + try (var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) + .build()) { // try-with-resources triggers close(); just asserting no throw } } @Test void withCloseHook_nullThrows() { - var builder = BrowserlessApplicationContext - . builder(emptyRoutes()); + var builder = new BrowserlessApplicationContext.Builder(emptyRoutes()); Assertions.assertThrows(NullPointerException.class, () -> builder.withCloseHook(null)); } diff --git a/shared/src/test/java/com/vaadin/browserless/BuilderLookupServicesTest.java b/shared/src/test/java/com/vaadin/browserless/BuilderLookupServicesTest.java index ddba688..877ab5f 100644 --- a/shared/src/test/java/com/vaadin/browserless/BuilderLookupServicesTest.java +++ b/shared/src/test/java/com/vaadin/browserless/BuilderLookupServicesTest.java @@ -40,7 +40,7 @@ private static class ServiceC { private BrowserlessApplicationContext.Builder newBuilder() { Routes routes = new Routes(new HashSet<>(), new HashSet<>(), new HashSet<>(), true); - return BrowserlessApplicationContext.builder(routes); + return new BrowserlessApplicationContext.Builder(routes); } @Test diff --git a/shared/src/test/java/com/vaadin/browserless/BuilderSecurityContextHandlerTest.java b/shared/src/test/java/com/vaadin/browserless/BuilderSecurityContextHandlerTest.java index 23064a3..46a517d 100644 --- a/shared/src/test/java/com/vaadin/browserless/BuilderSecurityContextHandlerTest.java +++ b/shared/src/test/java/com/vaadin/browserless/BuilderSecurityContextHandlerTest.java @@ -39,14 +39,14 @@ private static Routes emptyRoutes() { @Test void withSecurityContextHandler_rejectsNull() { - var builder = BrowserlessApplicationContext.builder(emptyRoutes()); + var builder = new BrowserlessApplicationContext.Builder(emptyRoutes()); Assertions.assertThrows(NullPointerException.class, () -> builder.withSecurityContextHandler(null)); } @Test void securedBuilder_withSecurityContextHandler_rejectsNull() { - var secured = BrowserlessApplicationContext.builder(emptyRoutes()) + var secured = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withSecurityContextHandler(new MinimalHandler()); Assertions.assertThrows(NullPointerException.class, () -> secured.withSecurityContextHandler(null)); @@ -54,7 +54,7 @@ void securedBuilder_withSecurityContextHandler_rejectsNull() { @Test void newUserByUsernameAndRoles_throwsUOE_whenHandlerDoesNotOverrideCreateCredentials() { - try (var app = BrowserlessApplicationContext.builder(emptyRoutes()) + try (var app = new BrowserlessApplicationContext.Builder(emptyRoutes()) .withSecurityContextHandler(new MinimalHandler()).build()) { var ex = Assertions.assertThrows( UnsupportedOperationException.class, diff --git a/spring/src/main/java/com/vaadin/browserless/SpringBrowserlessApplicationContext.java b/spring/src/main/java/com/vaadin/browserless/SpringBrowserlessApplicationContext.java index 2343b57..4c35933 100644 --- a/spring/src/main/java/com/vaadin/browserless/SpringBrowserlessApplicationContext.java +++ b/spring/src/main/java/com/vaadin/browserless/SpringBrowserlessApplicationContext.java @@ -15,11 +15,12 @@ */ package com.vaadin.browserless; +import java.util.Objects; +import java.util.function.UnaryOperator; + import org.springframework.context.ApplicationContext; import org.springframework.security.core.Authentication; -import com.vaadin.browserless.internal.Routes; -import com.vaadin.browserless.internal.UIFactory; import com.vaadin.browserless.mocks.MockSpringServlet; import com.vaadin.browserless.mocks.MockedUI; import com.vaadin.browserless.mocks.SpringSecurityRequestCustomizer; @@ -32,19 +33,21 @@ * {@link BrowserlessTestSpringLookupInitializer} so the test context can reach * Spring beans and lifecycle. Three entry points are provided: *

    - *
  • {@link #create(Routes, ApplicationContext)} — returns the unsecured + *
  • {@link #create(ApplicationContext, UnaryOperator)} (and the view-package + * shortcuts) — returns the unsecured * {@link BrowserlessApplicationContext}.
  • - *
  • {@link #createSecured(Routes, ApplicationContext)} — returns the - * credential-typed {@link SecuredBrowserlessApplicationContext}; requires - * Spring Security on the classpath.
  • - *
  • {@link #builder(Routes, ApplicationContext)} — returns a pre-wired - * {@link BrowserlessApplicationContext.Builder} for full customization (e.g. - * plugging in a different security handler).
  • + *
  • {@link #createSecured(ApplicationContext, UnaryOperator)} (and the + * view-package shortcuts) — returns the credential-typed + * {@link SecuredBrowserlessApplicationContext}; requires Spring Security on the + * classpath.
  • + *
  • {@link #builder(ApplicationContext)} — returns a pre-wired + * {@link BrowserlessApplicationContext.Builder} for full customization (e.g. a + * custom {@code UIFactory} or a different security handler).
  • *
* *
- * var app = SpringBrowserlessApplicationContext.createSecured(routes,
- *         springCtx);
+ * var app = SpringBrowserlessApplicationContext.createSecured(springCtx,
+ *         ProtectedView.class);
  * var admin = app.newUser("admin", "ADMIN");
  * var window = admin.newWindow();
  * window.navigate(ProtectedView.class);
@@ -62,40 +65,22 @@ private SpringBrowserlessApplicationContext() {
     /**
      * Creates a Spring-pre-wired builder. The builder has the Spring servlet,
      * the lookup initializer and the lookup-initializer close hook configured;
-     * callers can chain additional customizations before calling
+     * callers can chain additional customizations (including a custom
+     * {@code UIFactory}) before calling
      * {@link BrowserlessApplicationContext.Builder#build()}.
      *
-     * @param routes
-     *            the discovered routes
      * @param applicationContext
      *            the Spring application context
      * @return a pre-wired builder
      */
-    public static BrowserlessApplicationContext.Builder builder(Routes routes,
+    public static BrowserlessApplicationContext.Builder builder(
             ApplicationContext applicationContext) {
-        return builder(routes, applicationContext, () -> new MockedUI());
-    }
-
-    /**
-     * Creates a Spring-pre-wired builder with a custom UI factory.
-     *
-     * @param routes
-     *            the discovered routes
-     * @param applicationContext
-     *            the Spring application context
-     * @param uiFactory
-     *            the UI factory
-     * @return a pre-wired builder
-     */
-    public static BrowserlessApplicationContext.Builder builder(Routes routes,
-            ApplicationContext applicationContext, UIFactory uiFactory) {
         BrowserlessTestSpringLookupInitializer
                 .setApplicationContext(applicationContext);
-        BrowserlessApplicationContext.Builder builder = BrowserlessApplicationContext
-                .builder(routes)
+        BrowserlessApplicationContext.Builder builder = new BrowserlessApplicationContext.Builder()
                 .withServletFactory((r, uif) -> new MockSpringServlet(r,
                         applicationContext, uif))
-                .withUIFactory(uiFactory)
+                .withUIFactory(() -> new MockedUI())
                 .withLookupServices(
                         BrowserlessTestSpringLookupInitializer.class)
                 .withCloseHook(
@@ -111,77 +96,121 @@ public static BrowserlessApplicationContext.Builder builder(Routes routes,
     }
 
     /**
-     * Creates an unsecured Spring-integrated application context.
+     * Creates an unsecured Spring-integrated application context that scans the
+     * given packages for {@code @Route}-annotated views.
      *
-     * @param routes
-     *            the discovered routes
      * @param applicationContext
      *            the Spring application context
+     * @param viewPackages
+     *            package names to scan for views; an empty array falls back to
+     *            a full classpath scan
      * @return a new unsecured application context configured for Spring
      */
-    public static BrowserlessApplicationContext create(Routes routes,
-            ApplicationContext applicationContext) {
-        return builder(routes, applicationContext).build();
+    public static BrowserlessApplicationContext create(
+            ApplicationContext applicationContext, String... viewPackages) {
+        return create(applicationContext,
+                b -> b.withViewPackages(viewPackages));
     }
 
     /**
-     * Creates an unsecured Spring-integrated application context with a custom
-     * UI factory.
+     * Creates an unsecured Spring-integrated application context that scans the
+     * packages of the given classes for {@code @Route}-annotated views.
      *
-     * @param routes
-     *            the discovered routes
      * @param applicationContext
      *            the Spring application context
-     * @param uiFactory
-     *            the UI factory
+     * @param viewPackageClasses
+     *            classes whose packages should be scanned for views
      * @return a new unsecured application context configured for Spring
      */
-    public static BrowserlessApplicationContext create(Routes routes,
-            ApplicationContext applicationContext, UIFactory uiFactory) {
-        return builder(routes, applicationContext, uiFactory).build();
+    public static BrowserlessApplicationContext create(
+            ApplicationContext applicationContext,
+            Class... viewPackageClasses) {
+        return create(applicationContext,
+                b -> b.withViewPackages(viewPackageClasses));
+    }
+
+    /**
+     * Creates an unsecured Spring-integrated application context, applying the
+     * given configurer to the pre-wired builder before building it.
+     *
+     * @param applicationContext
+     *            the Spring application context
+     * @param configurer
+     *            builder configurer; e.g. {@code b -> b.withViewPackages(...)}.
+     *            Pass {@link UnaryOperator#identity()} to keep defaults.
+     * @return a new unsecured application context configured for Spring
+     */
+    public static BrowserlessApplicationContext create(
+            ApplicationContext applicationContext,
+            UnaryOperator configurer) {
+        Objects.requireNonNull(configurer, "configurer must not be null");
+        return configurer.apply(builder(applicationContext)).build();
     }
 
     /**
      * Creates a Spring-integrated application context with Spring Security
-     * wiring. Requires Spring Security on the classpath; throws otherwise.
+     * wiring that scans the given packages for {@code @Route}-annotated views.
+     * Requires Spring Security on the classpath; throws otherwise.
      *
-     * @param routes
-     *            the discovered routes
      * @param applicationContext
      *            the Spring application context
+     * @param viewPackages
+     *            package names to scan for views
      * @return a new secured application context configured for Spring Security
      * @throws IllegalStateException
      *             if Spring Security is not on the classpath
      */
     public static SecuredBrowserlessApplicationContext createSecured(
-            Routes routes, ApplicationContext applicationContext) {
-        return createSecured(routes, applicationContext, () -> new MockedUI());
+            ApplicationContext applicationContext, String... viewPackages) {
+        return createSecured(applicationContext,
+                b -> b.withViewPackages(viewPackages));
     }
 
     /**
-     * Creates a secured Spring-integrated application context with a custom UI
-     * factory. Requires Spring Security on the classpath; throws otherwise.
+     * Creates a Spring-integrated application context with Spring Security
+     * wiring that scans the packages of the given classes for
+     * {@code @Route}-annotated views. Requires Spring Security on the
+     * classpath; throws otherwise.
+     *
+     * @param applicationContext
+     *            the Spring application context
+     * @param viewPackageClasses
+     *            classes whose packages should be scanned for views
+     * @return a new secured application context configured for Spring Security
+     * @throws IllegalStateException
+     *             if Spring Security is not on the classpath
+     */
+    public static SecuredBrowserlessApplicationContext createSecured(
+            ApplicationContext applicationContext,
+            Class... viewPackageClasses) {
+        return createSecured(applicationContext,
+                b -> b.withViewPackages(viewPackageClasses));
+    }
+
+    /**
+     * Creates a Spring-integrated application context with Spring Security
+     * wiring, applying the given configurer to the pre-wired builder. Requires
+     * Spring Security on the classpath; throws otherwise.
      *
-     * @param routes
-     *            the discovered routes
      * @param applicationContext
      *            the Spring application context
-     * @param uiFactory
-     *            the UI factory
+     * @param configurer
+     *            builder configurer
      * @return a new secured application context configured for Spring Security
      * @throws IllegalStateException
      *             if Spring Security is not on the classpath
      */
     public static SecuredBrowserlessApplicationContext createSecured(
-            Routes routes, ApplicationContext applicationContext,
-            UIFactory uiFactory) {
+            ApplicationContext applicationContext,
+            UnaryOperator configurer) {
+        Objects.requireNonNull(configurer, "configurer must not be null");
         if (!SpringSecuritySupport.isPresent()) {
             throw new IllegalStateException(
                     "SpringBrowserlessApplicationContext.createSecured(...)"
                             + " requires Spring Security on the classpath."
                             + " Use create(...) for unsecured contexts.");
         }
-        return builder(routes, applicationContext, uiFactory)
+        return configurer.apply(builder(applicationContext))
                 .withSecurityContextHandler(new SpringSecurityContextHandler())
                 .build();
     }
diff --git a/spring/src/test/java/com/vaadin/browserless/MultiUserSecurityTest.java b/spring/src/test/java/com/vaadin/browserless/MultiUserSecurityTest.java
index 08298fd..bd73698 100644
--- a/spring/src/test/java/com/vaadin/browserless/MultiUserSecurityTest.java
+++ b/spring/src/test/java/com/vaadin/browserless/MultiUserSecurityTest.java
@@ -28,8 +28,6 @@
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 
-import com.vaadin.browserless.internal.Routes;
-
 /**
  * Tests multi-user security context isolation with Spring Security. Verifies
  * that switching between users' windows correctly saves and restores Spring
@@ -46,9 +44,8 @@ class MultiUserSecurityTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = SpringBrowserlessApplicationContext.createSecured(routes,
-                applicationContext);
+        app = SpringBrowserlessApplicationContext
+                .createSecured(applicationContext, "com.testapp.security");
     }
 
     @AfterEach
diff --git a/spring/src/test/java/com/vaadin/browserless/SpringLookupInitializerCloseHookTest.java b/spring/src/test/java/com/vaadin/browserless/SpringLookupInitializerCloseHookTest.java
index ac76f34..84afd3e 100644
--- a/spring/src/test/java/com/vaadin/browserless/SpringLookupInitializerCloseHookTest.java
+++ b/spring/src/test/java/com/vaadin/browserless/SpringLookupInitializerCloseHookTest.java
@@ -15,14 +15,14 @@
  */
 package com.vaadin.browserless;
 
+import java.util.function.UnaryOperator;
+
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 
-import com.vaadin.browserless.internal.Routes;
-
 /**
  * Asserts that {@link SpringBrowserlessApplicationContext} releases the lookup
  * initializer's ThreadLocal when the application context is closed.
@@ -48,8 +48,8 @@ void close_clearsLookupApplicationContextThreadLocal() {
         try (var springCtx = new AnnotationConfigApplicationContext()) {
             springCtx.refresh();
 
-            var app = SpringBrowserlessApplicationContext.create(new Routes(),
-                    springCtx);
+            var app = SpringBrowserlessApplicationContext.create(springCtx,
+                    UnaryOperator.identity());
             Assertions.assertSame(springCtx,
                     BrowserlessTestSpringLookupInitializer
                             .getApplicationContext(),
@@ -72,8 +72,8 @@ void close_isIdempotent_withCloseHook() {
         try (var springCtx = new AnnotationConfigApplicationContext()) {
             springCtx.refresh();
 
-            var app = SpringBrowserlessApplicationContext.create(new Routes(),
-                    springCtx);
+            var app = SpringBrowserlessApplicationContext.create(springCtx,
+                    UnaryOperator.identity());
             app.close();
             Assertions.assertDoesNotThrow(app::close,
                     "Second close() must be a no-op even with hooks"
diff --git a/spring/src/test/java/com/vaadin/browserless/SpringNewUserHelperTest.java b/spring/src/test/java/com/vaadin/browserless/SpringNewUserHelperTest.java
index 54834b3..7ab8f3f 100644
--- a/spring/src/test/java/com/vaadin/browserless/SpringNewUserHelperTest.java
+++ b/spring/src/test/java/com/vaadin/browserless/SpringNewUserHelperTest.java
@@ -34,7 +34,6 @@
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 
-import com.vaadin.browserless.internal.Routes;
 import com.vaadin.flow.server.VaadinRequest;
 
 /**
@@ -55,9 +54,8 @@ class SpringNewUserHelperTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = SpringBrowserlessApplicationContext.createSecured(routes,
-                applicationContext);
+        app = SpringBrowserlessApplicationContext
+                .createSecured(applicationContext, "com.testapp.security");
     }
 
     @AfterEach
diff --git a/spring/src/test/java/com/vaadin/browserless/SpringSecurityAbsentTest.java b/spring/src/test/java/com/vaadin/browserless/SpringSecurityAbsentTest.java
index 4a97210..6102b38 100644
--- a/spring/src/test/java/com/vaadin/browserless/SpringSecurityAbsentTest.java
+++ b/spring/src/test/java/com/vaadin/browserless/SpringSecurityAbsentTest.java
@@ -15,6 +15,8 @@
  */
 package com.vaadin.browserless;
 
+import java.util.function.UnaryOperator;
+
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -28,7 +30,6 @@
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 
 import com.vaadin.browserless.internal.MockRequestCustomizer;
-import com.vaadin.browserless.internal.Routes;
 import com.vaadin.browserless.mocks.SpringSecurityRequestCustomizer;
 import com.vaadin.flow.di.Lookup;
 
@@ -67,9 +68,8 @@ void tearDown() {
     void create_withoutSpringSecurity_doesNotInstallSecurityHandler() {
         SpringSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = SpringBrowserlessApplicationContext.create(routes,
-                applicationContext)) {
+        try (var app = SpringBrowserlessApplicationContext
+                .create(applicationContext, UnaryOperator.identity())) {
             Assertions.assertNull(app.getSecurityContextHandler(),
                     "SpringSecurityContextHandler must not be installed when"
                             + " Spring Security is absent from the classpath");
@@ -80,9 +80,8 @@ void create_withoutSpringSecurity_doesNotInstallSecurityHandler() {
     void create_withoutSpringSecurity_doesNotRegisterRequestCustomizerBean() {
         SpringSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = SpringBrowserlessApplicationContext.create(routes,
-                applicationContext)) {
+        try (var app = SpringBrowserlessApplicationContext
+                .create(applicationContext, UnaryOperator.identity())) {
             Assertions.assertFalse(
                     applicationContext.containsBean(
                             SpringSecurityRequestCustomizer.class.getName()),
@@ -95,9 +94,8 @@ void create_withoutSpringSecurity_doesNotRegisterRequestCustomizerBean() {
     void create_withoutSpringSecurity_doesNotInstallRequestCustomizerLookup() {
         SpringSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = SpringBrowserlessApplicationContext.create(routes,
-                applicationContext)) {
+        try (var app = SpringBrowserlessApplicationContext
+                .create(applicationContext, UnaryOperator.identity())) {
             Lookup lookup = app.getService().getContext()
                     .getAttribute(Lookup.class);
             MockRequestCustomizer customizer = lookup
@@ -114,9 +112,8 @@ void create_withoutSpringSecurity_doesNotInstallRequestCustomizerLookup() {
     void newUser_withoutSpringSecurity_doesNotThrow() {
         SpringSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
-        try (var app = SpringBrowserlessApplicationContext.create(routes,
-                applicationContext)) {
+        try (var app = SpringBrowserlessApplicationContext
+                .create(applicationContext, UnaryOperator.identity())) {
             Assertions.assertDoesNotThrow(() -> {
                 var user = app.newUser();
                 user.newWindow();
@@ -128,9 +125,8 @@ void newUser_withoutSpringSecurity_doesNotThrow() {
     void createSecured_withSpringSecurity_installsHandlerAndCustomizer() {
         // Default detector: actual classpath probe (Spring Security IS present
         // in the spring module's test classpath)
-        Routes routes = new Routes();
-        try (var app = SpringBrowserlessApplicationContext.createSecured(routes,
-                applicationContext)) {
+        try (var app = SpringBrowserlessApplicationContext
+                .createSecured(applicationContext, UnaryOperator.identity())) {
             Assertions.assertInstanceOf(SpringSecurityContextHandler.class,
                     app.getSecurityContextHandler(),
                     "Handler must be installed when Spring Security is present");
@@ -152,10 +148,9 @@ void createSecured_withSpringSecurity_installsHandlerAndCustomizer() {
     void createSecured_withoutSpringSecurity_throws() {
         SpringSecuritySupport.overrideDetector(() -> false);
 
-        Routes routes = new Routes();
         var ex = Assertions.assertThrows(IllegalStateException.class,
-                () -> SpringBrowserlessApplicationContext.createSecured(routes,
-                        applicationContext),
+                () -> SpringBrowserlessApplicationContext.createSecured(
+                        applicationContext, UnaryOperator.identity()),
                 "createSecured(...) must reject calls when Spring Security is"
                         + " absent from the classpath");
         Assertions.assertTrue(ex.getMessage().contains("Spring Security"),
diff --git a/spring/src/test/java/com/vaadin/browserless/SpringSecuritySnapshotTest.java b/spring/src/test/java/com/vaadin/browserless/SpringSecuritySnapshotTest.java
index cbcc7b1..fcfece7 100644
--- a/spring/src/test/java/com/vaadin/browserless/SpringSecuritySnapshotTest.java
+++ b/spring/src/test/java/com/vaadin/browserless/SpringSecuritySnapshotTest.java
@@ -28,8 +28,6 @@
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 
-import com.vaadin.browserless.internal.Routes;
-
 /**
  * Tests that the Spring security context snapshot is a defensive copy, not a
  * mutable reference. Mutating the live SecurityContext on the thread after
@@ -46,9 +44,8 @@ class SpringSecuritySnapshotTest {
 
     @BeforeEach
     void setUp() {
-        Routes routes = new Routes().autoDiscoverViews("com.testapp.security");
-        app = SpringBrowserlessApplicationContext.createSecured(routes,
-                applicationContext);
+        app = SpringBrowserlessApplicationContext
+                .createSecured(applicationContext, "com.testapp.security");
     }
 
     @AfterEach