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..8d2d8c2 --- /dev/null +++ b/junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java @@ -0,0 +1,145 @@ +/* + * 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 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()); + + window.findButton().withCaption("Increment").click(); + Assertions.assertEquals(1, 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..13bdc08 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..d2bb660 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; @@ -67,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; @@ -214,6 +222,78 @@ 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; + } + + /** + * 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. @@ -499,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(); + } } /**