diff --git a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/SerializationDebugUtil.java b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/SerializationDebugUtil.java new file mode 100644 index 000000000..c89f29c59 --- /dev/null +++ b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/SerializationDebugUtil.java @@ -0,0 +1,194 @@ +/** + * Copyright (C) 2000-2025 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.testbench.unit; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.NotSerializableException; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +public final class SerializationDebugUtil { + + private SerializationDebugUtil() { + } + + /** + * Asserts that the given object graph is fully serializable. If not, throws + * an AssertionError with a detailed report of non-serializable fields + * found. + *

+ * Note: When running tests in an IDE, enable + * "sun.io.serialization.extendedDebugInfo" flag for more detailed stack + * traces on serialization errors. + * + * @param root + * the root object to test for serializability + * @param report + * a StringBuilder instance to which the detailed report is + * appended + */ + public static void assertSerializable(Object root, StringBuilder report) { + try { + serialize(root); + } catch (NotSerializableException e) { + if (report == null) { + report = new StringBuilder(); + } + report.append(buildReport(root, e)); + throw new AssertionError("Serialization failed: " + e.getMessage() + + "\n" + report.toString(), e); + } catch (IOException ioe) { + throw new AssertionError( + "Unexpected IO failure during serialization: " + + ioe.getMessage(), + ioe); + } + } + + /** + * Asserts that the given object graph is fully serializable. If not, throws + * an AssertionError with a detailed report of non-serializable fields + * found. + *

+ * Note: When running tests in an IDE, enable + * "sun.io.serialization.extendedDebugInfo" flag for more detailed stack + * traces on serialization errors. + * + * @param root + * the root object to test for serializability + */ + public static void assertSerializable(Object root) { + assertSerializable(root, null); + } + + private static void serialize(Object o) throws IOException { + if (o == null) { + return; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + try { + oos.writeObject(o); + } finally { + oos.close(); + } + } + + private static String buildReport(Object root, + NotSerializableException original) { + StringBuilder sb = new StringBuilder(); + sb.append("---- Serialization Debug Report ----\n"); + sb.append("Root type: ").append(root.getClass().getName()).append('\n'); + sb.append("Original exception: ").append(original).append('\n'); + + Set visited = Collections + .newSetFromMap(new IdentityHashMap<>()); + List offenders = new ArrayList<>(); + inspectObject(root, "root", visited, offenders); + + if (offenders.isEmpty()) { + sb.append("No direct non-Serializable fields found.\n"); + } else { + sb.append("Non-serializable field paths:\n"); + offenders.forEach(p -> sb.append(" - ").append(p).append('\n')); + } + sb.append("------------------------------------"); + return sb.toString(); + } + + private static void inspectObject(Object obj, String path, + Set visited, List offenders) { + if (obj == null || visited.contains(obj)) { + return; + } + visited.add(obj); + + Class cls = obj.getClass(); + + // Skip Java core known immutable serializable types quickly + if (isKnownSerializableLeaf(cls)) { + return; + } + + // If object itself not Serializable, record and do not dive further (to + // avoid noise) + if (!(obj instanceof Serializable)) { + offenders.add(path + " (" + cls.getName() + ")"); + return; + } + + // Dive into fields + for (Field f : getAllFields(cls)) { + if (shouldSkip(f)) { + continue; + } + try { + f.setAccessible(true); + } catch (Exception ignored) { + continue; + } + Object value; + try { + value = f.get(obj); + } catch (IllegalAccessException ignored) { + continue; + } + if (value == null) { + continue; + } + + String childPath = path + "." + f.getName(); + if (!(value instanceof Serializable)) { + offenders.add( + childPath + " (" + value.getClass().getName() + ")"); + continue; + } + + // Try serializing field alone to catch nested problematic graphs + try { + serialize(value); + } catch (NotSerializableException nse) { + // Dive deeper to isolate + inspectObject(value, childPath, visited, offenders); + } catch (IOException ignored) { + // Ignore other IO issues for this isolated attempt + } + } + } + + private static boolean shouldSkip(Field f) { + int mod = f.getModifiers(); + return Modifier.isStatic(mod) || Modifier.isTransient(mod); + } + + private static List getAllFields(Class cls) { + List fields = new ArrayList<>(); + while (cls != null && cls != Object.class) { + fields.addAll(Arrays.asList(cls.getDeclaredFields())); + cls = cls.getSuperclass(); + } + return fields; + } + + private static boolean isKnownSerializableLeaf(Class cls) { + return cls.isPrimitive() || cls == String.class + || Number.class.isAssignableFrom(cls) || cls == Boolean.class + || cls == Character.class || cls.isEnum() + || cls.getName().startsWith("java.time."); + } +} diff --git a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServlet.java b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServlet.java index 7e76c9d34..bc25a1a16 100644 --- a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServlet.java +++ b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServlet.java @@ -30,6 +30,7 @@ import com.vaadin.flow.server.VaadinServletService; import com.vaadin.flow.spring.SpringServlet; import com.vaadin.testbench.unit.internal.Routes; +import com.vaadin.testbench.unit.internal.UIFactory; import com.vaadin.testbench.unit.internal.UtilsKt; /** @@ -46,13 +47,23 @@ public class MockSpringServlet extends SpringServlet { @NotNull public final ApplicationContext ctx; @NotNull - public final Function0 uiFactory; + public final UIFactory uiFactory; + @Deprecated(forRemoval = true) public MockSpringServlet(@NotNull Routes routes, @NotNull ApplicationContext ctx, @NotNull Function0 uiFactory) { super(ctx, false); this.ctx = ctx; this.routes = routes; + this.uiFactory = uiFactory::invoke; + } + + public MockSpringServlet(@NotNull Routes routes, + + @NotNull ApplicationContext ctx, @NotNull UIFactory uiFactory) { + super(ctx, false); + this.ctx = ctx; + this.routes = routes; this.uiFactory = uiFactory; } diff --git a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServletService.java b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServletService.java index 3b6f5c462..addfd3cc0 100644 --- a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServletService.java +++ b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringServletService.java @@ -18,6 +18,7 @@ import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.spring.SpringVaadinServletService; +import com.vaadin.testbench.unit.internal.UIFactory; /** * A mocking service that performs three very important tasks: @@ -35,12 +36,20 @@ */ public class MockSpringServletService extends SpringVaadinServletService { @NotNull - private final Function0 uiFactory; + private final UIFactory uiFactory; + @Deprecated(forRemoval = true) public MockSpringServletService(@NotNull MockSpringServlet servlet, @NotNull DeploymentConfiguration deploymentConfiguration, @NotNull ApplicationContext ctx, @NotNull Function0 uiFactory) { super(servlet, deploymentConfiguration, ctx); + this.uiFactory = uiFactory::invoke; + } + + public MockSpringServletService(@NotNull MockSpringServlet servlet, + @NotNull DeploymentConfiguration deploymentConfiguration, + @NotNull ApplicationContext ctx, @NotNull UIFactory uiFactory) { + super(servlet, deploymentConfiguration, ctx); this.uiFactory = uiFactory; } diff --git a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringVaadinSession.java b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringVaadinSession.java index 2a917f8cc..85fc3787e 100644 --- a/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringVaadinSession.java +++ b/vaadin-testbench-unit-shared/src/main/java/com/vaadin/testbench/unit/mocks/MockSpringVaadinSession.java @@ -15,6 +15,7 @@ import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinSession; import com.vaadin.testbench.unit.internal.MockVaadin; +import com.vaadin.testbench.unit.internal.UIFactory; /** * A Vaadin Session with one important difference: @@ -31,11 +32,18 @@ */ public class MockSpringVaadinSession extends VaadinSession { @NotNull - private final Function0 uiFactory; + private final UIFactory uiFactory; + @Deprecated(forRemoval = true) public MockSpringVaadinSession(@NotNull VaadinService service, @NotNull Function0 uiFactory) { super(service); + this.uiFactory = uiFactory::invoke; + } + + public MockSpringVaadinSession(@NotNull VaadinService service, + @NotNull UIFactory uiFactory) { + super(service); this.uiFactory = uiFactory; } diff --git a/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/internal/MockVaadin.kt b/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/internal/MockVaadin.kt index a523acc2f..822e5a58f 100644 --- a/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/internal/MockVaadin.kt +++ b/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/internal/MockVaadin.kt @@ -84,7 +84,7 @@ object MockVaadin { @JvmStatic @JvmOverloads fun setup(routes: Routes = Routes(), - uiFactory: () -> UI = UIFactory { MockedUI() }, + uiFactory: UIFactory = UIFactory { MockedUI() }, lookupServices: Set> = emptySet()) { // init servlet val servlet = MockVaadinServlet(routes) @@ -109,7 +109,7 @@ object MockVaadin { * @param lookupServices service classes to be provided to the lookup initializer */ @JvmStatic - fun setup(uiFactory: () -> UI = { MockedUI() }, servlet: VaadinServlet, + fun setup(uiFactory: UIFactory = UIFactory { MockedUI() }, servlet: VaadinServlet, lookupServices: Set> = emptySet() ) { if (!servlet.isInitialized) { @@ -199,7 +199,7 @@ object MockVaadin { */ var mockRequestFactory: (MockHttpSession) -> MockRequest = { MockRequest(it) } - private fun createSession(ctx: ServletContext, uiFactory: () -> UI) { + private fun createSession(ctx: ServletContext, uiFactory: UIFactory) { val service: VaadinServletService = checkNotNull(VaadinService.getCurrent()) as VaadinServletService val httpSession: MockHttpSession = MockHttpSession.create(ctx) @@ -242,7 +242,7 @@ object MockVaadin { createUI(uiFactory, session) } - internal fun createUI(uiFactory: () -> UI, session: VaadinSession) { + internal fun createUI(uiFactory: UIFactory, session: VaadinSession) { val request: VaadinRequest = checkNotNull(VaadinRequest.getCurrent()) val ui: UI = uiFactory() require(ui.session == null) { @@ -373,7 +373,7 @@ object MockVaadin { * See [MockVaadinSession] on how to call this properly. */ @JvmStatic - public fun afterSessionClose(session: VaadinSession, uiFactory: () -> UI) { + public fun afterSessionClose(session: VaadinSession, uiFactory: UIFactory) { // We need to simulate the actual browser + servlet container behavior here. // Imagine that we want a test scenario where the user logs out, and we want to check that a login prompt appears. @@ -445,4 +445,4 @@ fun interface MockRequestCustomizer { fun apply(request: MockRequest) } -fun interface UIFactory : () -> UI, Serializable \ No newline at end of file +fun interface UIFactory : () -> UI, Serializable diff --git a/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/mocks/MockService.kt b/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/mocks/MockService.kt index c1919f636..b4d9cc92b 100644 --- a/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/mocks/MockService.kt +++ b/vaadin-testbench-unit-shared/src/main/kotlin/com/vaadin/testbench/unit/mocks/MockService.kt @@ -18,6 +18,8 @@ import com.vaadin.flow.server.VaadinServlet import com.vaadin.flow.server.VaadinServletService import com.vaadin.flow.server.VaadinSession +import com.vaadin.testbench.unit.internal.UIFactory + /** * A mocking service that performs three very important tasks: * * Overrides [isAtmosphereAvailable] to tell Vaadin that we don't have Atmosphere (otherwise Vaadin will crash) @@ -29,7 +31,7 @@ import com.vaadin.flow.server.VaadinSession */ open class MockService(servlet: VaadinServlet, deploymentConfiguration: DeploymentConfiguration, - val uiFactory: () -> UI = { MockedUI() } + val uiFactory: UIFactory = UIFactory { MockedUI() } ) : VaadinServletService(servlet, deploymentConfiguration) { // need to have this override. Setting `VaadinService.atmosphereAvailable` to false via // reflection after the servlet has been initialized is too late, since Atmo is initialized diff --git a/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/SpringUIUnit4BaseClassTest.java b/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/SpringUIUnit4BaseClassTest.java index edf6812e4..689cff1a8 100644 --- a/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/SpringUIUnit4BaseClassTest.java +++ b/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/SpringUIUnit4BaseClassTest.java @@ -13,6 +13,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.context.ContextConfiguration; +import com.vaadin.flow.component.UI; import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinSession; import com.vaadin.testbench.unit.mocks.MockSpringServletService; @@ -40,6 +41,12 @@ public void extendingBaseClass_runTest_vaadinSpringMockingIsSetup() { VaadinSession.getCurrent() instanceof MockSpringVaadinSession); } + @Test + public void mockVaadinIsSerializable() { + SerializationDebugUtil.assertSerializable(UI.getCurrent()); + SerializationDebugUtil.assertSerializable(VaadinSession.getCurrent()); + } + // Empty configuration class used only to be able to bootstrap spring // ApplicationContext @Configuration diff --git a/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/UIUnit4BaseClassTest.java b/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/UIUnit4BaseClassTest.java index 223ee369e..6fc238c61 100644 --- a/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/UIUnit4BaseClassTest.java +++ b/vaadin-testbench-unit/src/test/java/com/vaadin/testbench/unit/UIUnit4BaseClassTest.java @@ -62,6 +62,11 @@ public void extendingBaseClass_runTest_defaultRouteActive() { getCurrentView() instanceof WelcomeView); } + @Test + public void mockVaadinIsSerializable() { + SerializationDebugUtil.assertSerializable(UI.getCurrent()); + } + } public static class DiscoverAllRoutesTest extends UIUnit4Test {