From 45f3ab5099c9ea2ca89970a80bbf475356410bd1 Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Thu, 21 May 2026 12:40:08 +0000 Subject: [PATCH 1/3] feat: ad-hoc component testing via window.show(component) (closes #69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two pieces that together cover "test a single reusable component without writing a @Route view": - BrowserlessApplicationContext.create() — no-arg overload that defaults to an empty Routes. No view scanning needed. - BrowserlessUIContext.show(Component) — attaches a component directly to this window's UI, clearing any prior content first. Roundtrips before returning so attach effects flush. End-user shape: try (var app = BrowserlessApplicationContext.create()) { var window = app.newUser().newWindow(); MyForm form = window.show(new MyForm()); window.findButton().withCaption("Save").click(); } Element-level manipulation stays inside the framework — tests no longer need UI.getCurrent().getElement().appendChild(...) just to put a component on screen. Tests cover attach lifecycle, replace-on- subsequent-show semantics, layout-wrapped fixtures, and re-attaching a component from a prior parent. Scope: deliberately does not register an ad-hoc route. Components that depend on router context (BeforeEnterObserver, role checks) still need a real @Route view, documented in the show() javadoc. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../java/com/example/adhoc/CounterWidget.java | 46 +++++++ .../browserless/AdhocComponentTest.java | 117 ++++++++++++++++++ .../BrowserlessApplicationContext.java | 16 +++ .../browserless/BrowserlessUIContext.java | 35 ++++++ 4 files changed, 214 insertions(+) create mode 100644 junit6/src/test/java/com/example/adhoc/CounterWidget.java create mode 100644 junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java diff --git a/junit6/src/test/java/com/example/adhoc/CounterWidget.java b/junit6/src/test/java/com/example/adhoc/CounterWidget.java new file mode 100644 index 0000000..7088af0 --- /dev/null +++ b/junit6/src/test/java/com/example/adhoc/CounterWidget.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.example.adhoc; + +import com.vaadin.flow.component.Composite; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; + +/** + * Tiny reusable widget used to exercise the ad-hoc component testing path — + * deliberately not a {@code @Route} view. + */ +public class CounterWidget extends Composite { + + private int count; + private final Span counter = new Span("0"); + private final Button increment; + + public CounterWidget() { + increment = new Button("Increment", e -> { + count++; + counter.setText(String.valueOf(count)); + }); + increment.setId("increment"); + counter.setId("counter"); + getContent().add(counter, increment); + } + + public int getCount() { + return count; + } +} diff --git a/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java new file mode 100644 index 0000000..f538ea8 --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java @@ -0,0 +1,117 @@ +/* + * 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.adhoc.CounterWidget; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.textfield.TextField; + +/** + * Exercises the ad-hoc component testing path + * ({@link BrowserlessApplicationContext#create()} + + * {@link BrowserlessUIContext#show(com.vaadin.flow.component.Component)}). No + * {@code @Route} view or {@code Routes} discovery is required — components + * are attached directly to a fresh UI. + */ +class AdhocComponentTest { + + @Test + void show_widget_attachedAndInteractive() { + try (var app = BrowserlessApplicationContext.create()) { + var window = app.newUser().newWindow(); + + CounterWidget widget = window.show(new CounterWidget()); + + Assertions.assertTrue(widget.isAttached(), + "show() should attach the component to the UI"); + + window.findButton().withCaption("Increment").click(); + window.findButton().withCaption("Increment").click(); + + Assertions.assertEquals(2, widget.getCount()); + } + } + + @Test + void show_returnsSameInstance() { + try (var app = BrowserlessApplicationContext.create()) { + var window = app.newUser().newWindow(); + + CounterWidget widget = new CounterWidget(); + CounterWidget returned = window.show(widget); + + Assertions.assertSame(widget, returned); + } + } + + @Test + void show_secondCall_replacesPriorContent() { + try (var app = BrowserlessApplicationContext.create()) { + var window = app.newUser().newWindow(); + + CounterWidget first = window.show(new CounterWidget()); + CounterWidget second = window.show(new CounterWidget()); + + Assertions.assertFalse(first.isAttached(), + "Prior content should be detached on a fresh show()"); + Assertions.assertTrue(second.isAttached()); + + // Only the second counter is visible — the increment in this test + // affects only the still-attached widget. + window.findButton().withCaption("Increment").click(); + Assertions.assertEquals(0, first.getCount()); + Assertions.assertEquals(1, second.getCount()); + } + } + + @Test + void show_wrappedInLayout_componentsInsideAreFindable() { + try (var app = BrowserlessApplicationContext.create()) { + var window = app.newUser().newWindow(); + + TextField field = new TextField("Name"); + field.setId("name"); + HorizontalLayout row = new HorizontalLayout(field); + window.show(new VerticalLayout(row)); + + window.findTextField().withId("name").setValue("Ada"); + Assertions.assertEquals("Ada", field.getValue()); + } + } + + @Test + void show_reattachesComponentFromAnotherParent() { + try (var app = BrowserlessApplicationContext.create()) { + var window = app.newUser().newWindow(); + + TextField field = new TextField("Name"); + new VerticalLayout(field); // detached parent; field has parent now + Assertions.assertTrue(field.getParent().isPresent()); + + window.show(field); + + Assertions.assertTrue(field.isAttached()); + // Direct parent is the UI now, not the prior VerticalLayout. + Assertions.assertTrue(field.getParent() + .filter(p -> p == window.getUI()).isPresent(), + "field's parent should be the UI after show()"); + } + } +} diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java index e820b73..373c28e 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java @@ -104,6 +104,22 @@ public static BrowserlessApplicationContext create(Routes routes) { return builder(routes).build(); } + /** + * Creates a plain Java application context with no routes registered. + *

+ * Useful for ad-hoc unit tests of individual components that don't depend + * on Vaadin's router — combine with + * {@link BrowserlessUIContext#show(com.vaadin.flow.component.Component)} + * to attach a component directly to a fresh UI without writing a + * {@code @Route} view. + * + * @return a new application context with an empty {@link Routes} + * @see BrowserlessUIContext#show(com.vaadin.flow.component.Component) + */ + public static BrowserlessApplicationContext create() { + return create(new Routes()); + } + /** * Creates a builder for customizing the application context. * diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java index fab3d0d..e80ab47 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; import com.vaadin.browserless.internal.MockPage; @@ -214,6 +215,40 @@ public T navigate(String location, return BrowserlessDSL.navigate(ui, location, expectedTarget); } + /** + * Attaches the given component directly to this window's UI for ad-hoc + * component testing — no {@code @Route} view required. Any previously + * shown content (including a navigated view) is removed first, so each + * {@code show()} call gives a clean slate. + *

+ * The component is attached through the public Vaadin API, so its + * attach lifecycle (events, signals, {@code onAttach}, listeners) fires + * normally. A {@code roundTrip} is performed after attach so any pending + * before-client-response runnables flush before {@code show()} returns — + * the component is in a steady state by the time the test continues. + *

+ * For testing flows that depend on Vaadin's router (e.g. + * {@code BeforeEnterObserver}, role-based access checks), keep using a + * real {@code @Route} view and {@link #navigate(Class)}. + * + * @param component + * the component to attach; must not be {@code null} + * @param + * the component type + * @return the given component, for direct use in assertions + */ + public C show(C component) { + Objects.requireNonNull(component, "component must not be null"); + activate(); + if (component.getParent().isPresent()) { + component.getElement().removeFromParent(); + } + ui.getElement().removeAllChildren(); + ui.getElement().appendChild(component.getElement()); + BaseBrowserlessTest.roundTrip(); + return component; + } + /** * Gets a query object for finding components of the given type in this * window's UI. From 5f8c0c452e3d4ccb22ef092f05cf281a2d051977 Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Thu, 21 May 2026 12:45:51 +0000 Subject: [PATCH 2/3] feat: BrowserlessUIContext.adhoc(Component) one-line shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the common ad-hoc case — test a single component, no need for multiple windows, users or custom credentials — the four-line setup (create app, newUser, newWindow, show) collapses to one: try (var window = BrowserlessUIContext.adhoc(new MyForm())) { window.findButton().withCaption("Save").click(); } The returned window owns the bundled application context and closes it on close(), so a single try-with-resources covers the whole lifecycle. For multi-user / multi-window / routed tests the existing BrowserlessApplicationContext.create(routes) + newUser().newWindow() chain is unchanged. The 'show' verb is retained — it pairs cleanly with navigate(Class) as the route-vs-ad-hoc dichotomy, mirrors JavaFX/Swing precedent, and doesn't collide with other Vaadin terms (attach is the lifecycle word, open is for dialogs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../browserless/AdhocComponentTest.java | 39 +++++++++++--- .../browserless/BrowserlessUIContext.java | 52 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java index f538ea8..7470345 100644 --- a/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java @@ -33,19 +33,46 @@ class AdhocComponentTest { @Test - void show_widget_attachedAndInteractive() { + void adhoc_widget_attachedAndInteractive() { + CounterWidget widget = new CounterWidget(); + try (var window = BrowserlessUIContext.adhoc(widget)) { + Assertions.assertTrue(widget.isAttached(), + "adhoc() should attach the component to the UI"); + + window.findButton().withCaption("Increment").click(); + window.findButton().withCaption("Increment").click(); + + Assertions.assertEquals(2, widget.getCount()); + } + } + + @Test + void adhoc_close_cascadesToOwnedApp() { + CounterWidget widget = new CounterWidget(); + var window = BrowserlessUIContext.adhoc(widget); + Assertions.assertTrue(widget.isAttached()); + + window.close(); + + Assertions.assertFalse(widget.isAttached(), + "Closing the adhoc window should tear down the owned app and detach the widget"); + // The thread-local Vaadin state should be cleared too: no UI on this + // thread once the bundled app is gone. + Assertions.assertNull(com.vaadin.flow.component.UI.getCurrent(), + "Owned app should be closed, clearing thread-locals"); + } + + @Test + void show_widget_attachedAndInteractive_longForm() { try (var app = BrowserlessApplicationContext.create()) { var window = app.newUser().newWindow(); CounterWidget widget = window.show(new CounterWidget()); - Assertions.assertTrue(widget.isAttached(), - "show() should attach the component to the UI"); + Assertions.assertTrue(widget.isAttached()); window.findButton().withCaption("Increment").click(); - window.findButton().withCaption("Increment").click(); - - Assertions.assertEquals(2, widget.getCount()); + Assertions.assertEquals(1, widget.getCount()); } } diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java index e80ab47..bdb3203 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java @@ -68,6 +68,13 @@ public class BrowserlessUIContext private final BrowserlessUserContext user; private UI ui; private boolean closed; + /** + * When this context was created by {@link #adhoc(Component)}, it owns + * (and on close, closes) the surrounding application context. {@code + * null} for windows produced via the standard + * {@code app.newUser().newWindow()} chain. + */ + private BrowserlessApplicationContext ownedApp; BrowserlessUIContext(BrowserlessUserContext user) { this.user = user; @@ -249,6 +256,44 @@ public C show(C component) { return component; } + /** + * Shorthand for ad-hoc component testing: builds a self-contained + * application context with no routes, opens a single user + window, and + * {@linkplain #show(Component) shows} the given component. The returned + * window owns its surrounding {@link BrowserlessApplicationContext} and + * closes it on {@link #close()}, so a single try-with-resources is enough: + * + *

+     * try (var window = BrowserlessUIContext.adhoc(new MyForm())) {
+     *     window.findButton().withCaption("Save").click();
+     * }
+     * 
+ * + * Use this when the test doesn't need multiple users, multiple windows, + * or a real {@code @Route} view. For anything more involved, the + * standard {@code BrowserlessApplicationContext.create(routes)} + + * {@code newUser().newWindow()} chain still applies. + * + * @param component + * the component to show; must not be {@code null} + * @return a window with the component attached + */ + public static BrowserlessUIContext adhoc(Component component) { + Objects.requireNonNull(component, "component must not be null"); + BrowserlessApplicationContext app = BrowserlessApplicationContext + .create(); + BrowserlessUIContext window; + try { + window = app.newUser().newWindow(); + window.ownedApp = app; + window.show(component); + } catch (RuntimeException ex) { + app.close(); + throw ex; + } + return window; + } + /** * Gets a query object for finding components of the given type in this * window's UI. @@ -534,6 +579,13 @@ public void close() { } else { user.clearThreadLocals(); } + // Cascade-close the bundled application context for windows produced + // via the adhoc(...) shorthand. + if (ownedApp != null) { + BrowserlessApplicationContext toClose = ownedApp; + ownedApp = null; + toClose.close(); + } } /** From 8f917ad287d8e2aa799567119b72b48940676428 Mon Sep 17 00:00:00 2001 From: Matti Tahvonen Date: Thu, 21 May 2026 15:48:22 +0300 Subject: [PATCH 3/3] format --- .../browserless/AdhocComponentTest.java | 9 +++---- .../BrowserlessApplicationContext.java | 4 ++-- .../browserless/BrowserlessUIContext.java | 24 +++++++++---------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java index 7470345..8d2d8c2 100644 --- a/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java +++ b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java @@ -27,8 +27,8 @@ * Exercises the ad-hoc component testing path * ({@link BrowserlessApplicationContext#create()} + * {@link BrowserlessUIContext#show(com.vaadin.flow.component.Component)}). No - * {@code @Route} view or {@code Routes} discovery is required — components - * are attached directly to a fresh UI. + * {@code @Route} view or {@code Routes} discovery is required — components are + * attached directly to a fresh UI. */ class AdhocComponentTest { @@ -136,8 +136,9 @@ void show_reattachesComponentFromAnotherParent() { Assertions.assertTrue(field.isAttached()); // Direct parent is the UI now, not the prior VerticalLayout. - Assertions.assertTrue(field.getParent() - .filter(p -> p == window.getUI()).isPresent(), + Assertions.assertTrue( + field.getParent().filter(p -> p == window.getUI()) + .isPresent(), "field's parent should be the UI after show()"); } } diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java index 373c28e..13bdc08 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessApplicationContext.java @@ -109,8 +109,8 @@ public static BrowserlessApplicationContext create(Routes routes) { *

* Useful for ad-hoc unit tests of individual components that don't depend * on Vaadin's router — combine with - * {@link BrowserlessUIContext#show(com.vaadin.flow.component.Component)} - * to attach a component directly to a fresh UI without writing a + * {@link BrowserlessUIContext#show(com.vaadin.flow.component.Component)} to + * attach a component directly to a fresh UI without writing a * {@code @Route} view. * * @return a new application context with an empty {@link Routes} diff --git a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java index bdb3203..d2bb660 100644 --- a/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java +++ b/shared/src/main/java/com/vaadin/browserless/BrowserlessUIContext.java @@ -69,8 +69,8 @@ public class BrowserlessUIContext private UI ui; private boolean closed; /** - * When this context was created by {@link #adhoc(Component)}, it owns - * (and on close, closes) the surrounding application context. {@code + * When this context was created by {@link #adhoc(Component)}, it owns (and + * on close, closes) the surrounding application context. {@code * null} for windows produced via the standard * {@code app.newUser().newWindow()} chain. */ @@ -224,19 +224,19 @@ public T navigate(String location, /** * Attaches the given component directly to this window's UI for ad-hoc - * component testing — no {@code @Route} view required. Any previously - * shown content (including a navigated view) is removed first, so each + * component testing — no {@code @Route} view required. Any previously shown + * content (including a navigated view) is removed first, so each * {@code show()} call gives a clean slate. *

- * The component is attached through the public Vaadin API, so its - * attach lifecycle (events, signals, {@code onAttach}, listeners) fires - * normally. A {@code roundTrip} is performed after attach so any pending + * The component is attached through the public Vaadin API, so its attach + * lifecycle (events, signals, {@code onAttach}, listeners) fires normally. + * A {@code roundTrip} is performed after attach so any pending * before-client-response runnables flush before {@code show()} returns — * the component is in a steady state by the time the test continues. *

* For testing flows that depend on Vaadin's router (e.g. - * {@code BeforeEnterObserver}, role-based access checks), keep using a - * real {@code @Route} view and {@link #navigate(Class)}. + * {@code BeforeEnterObserver}, role-based access checks), keep using a real + * {@code @Route} view and {@link #navigate(Class)}. * * @param component * the component to attach; must not be {@code null} @@ -269,9 +269,9 @@ public C show(C component) { * } * * - * Use this when the test doesn't need multiple users, multiple windows, - * or a real {@code @Route} view. For anything more involved, the - * standard {@code BrowserlessApplicationContext.create(routes)} + + * Use this when the test doesn't need multiple users, multiple windows, or + * a real {@code @Route} view. For anything more involved, the standard + * {@code BrowserlessApplicationContext.create(routes)} + * {@code newUser().newWindow()} chain still applies. * * @param component