Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions junit6/src/test/java/com/example/adhoc/CounterWidget.java
Original file line number Diff line number Diff line change
@@ -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<VerticalLayout> {

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;
}
}
145 changes: 145 additions & 0 deletions junit6/src/test/java/com/vaadin/browserless/AdhocComponentTest.java
Original file line number Diff line number Diff line change
@@ -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()");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ public static BrowserlessApplicationContext create(Routes routes) {
return builder(routes).build();
}

/**
* Creates a plain Java application context with no routes registered.
* <p>
* 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -214,6 +222,78 @@ public <T extends Component> 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.
* <p>
* 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.
* <p>
* 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 <C>
* the component type
* @return the given component, for direct use in assertions
*/
public <C extends Component> 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:
*
* <pre>
* try (var window = BrowserlessUIContext.adhoc(new MyForm())) {
* window.findButton().withCaption("Save").click();
* }
* </pre>
*
* 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.
Expand Down Expand Up @@ -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();
}
}

/**
Expand Down
Loading