All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.mvysny.kaributesting.v10.MockVaadin.kt Maven / Gradle / Ivy

There is a newer version: 2.1.8
Show newest version
package com.github.mvysny.kaributesting.v10

import com.github.mvysny.fakeservlet.FakeHttpSession
import com.github.mvysny.fakeservlet.FakeRequest
import com.github.mvysny.fakeservlet.FakeResponse
import com.github.mvysny.fakeservlet.FakeServletConfig
import com.github.mvysny.kaributesting.mockhttp.*
import com.github.mvysny.kaributesting.v10.mock.*
import com.github.mvysny.kaributools.VaadinVersion
import com.vaadin.flow.component.ComponentUtil
import com.vaadin.flow.component.UI
import com.vaadin.flow.component.page.ExtendedClientDetails
import com.vaadin.flow.component.page.Page
import com.vaadin.flow.internal.CurrentInstance
import com.vaadin.flow.internal.StateTree
import com.vaadin.flow.router.Location
import com.vaadin.flow.router.NavigationTrigger
import com.vaadin.flow.server.*
import com.vaadin.flow.shared.communication.PushMode
import java.lang.reflect.Constructor
import java.lang.reflect.Field
import java.util.concurrent.ExecutionException
import java.util.concurrent.locks.ReentrantLock
import jakarta.servlet.ServletContext
import kotlin.test.expect

public object MockVaadin {
    // prevent GC on Vaadin Session and Vaadin UI as they are only soft-referenced from the Vaadin itself.
    // use ThreadLocals so that multiple threads may initialize fresh Vaadin instances at the same time.
    private val strongRefSession = ThreadLocal()
    private val strongRefUI = ThreadLocal()
    private val strongRefReq = ThreadLocal()
    private val strongRefRes = ThreadLocal()

    /**
     * When closing UI via [MockVaadin.closeCurrentUI], the UI location is remembered here.
     *
     * The reason is: when reloading the page via [Page.reload], the current UI is closed
     * and a new one is opened; the new one needs to preserve the location of the old one.
     *
     * See [MockPage.reload] for more details.
     */
    private val lastUILocation = ThreadLocal()

    /**
     * Mocks Vaadin for the current test method:
     * ```
     * MockVaadin.setup(Routes().autoDiscoverViews("com.myapp"))
     * ```
     *
     * The UI factory *must* provide a new, fresh instance of the UI, so that the
     * tests start from a pre-known state. If you're using Spring and you're getting UI
     * from the injector, you must reconfigure Spring to use prototype scope,
     * otherwise an old UI from the UI scope or Session Scope will be provided.
     *
     * Sometimes you wish to provide a specific [VaadinServletService],
     * e.g. to override
     * [VaadinServletService.loadInstantiators] and provide your own way of instantiating Views, e.g. via Spring or Guice.
     * Please do that by extending [MockVaadinServlet] and overriding [MockVaadinServlet.createServletService]
     * `createServletService(DeploymentConfiguration)`.
     * Please consult [MockService] on what methods you must override in your custom service.
     * Alternatively, see `MockSpringServlet` on how to extend your custom servlet and
     * provide all necessary mocking code.
     * @param routes all classes annotated with [com.vaadin.flow.router.Route]; use [Routes.autoDiscoverViews] to auto-discover all such classes.
     * @param uiFactory produces [UI] instances and sets them as current, by default simply instantiates [MockedUI] class.
     */
    @JvmStatic
    @JvmOverloads
    public fun setup(routes: Routes = Routes(),
              uiFactory: () -> UI = @JvmSerializableLambda { MockedUI() }) {
        // init servlet
        val servlet = MockVaadinServlet(routes, uiFactory)
        setup(uiFactory, servlet)
    }

    /**
     * Use this method when you need to provide a completely custom servlet (e.g. `SpringServlet`). Do not forget to create a specialized service
     * which works in mocked environment. See below for details on how to do this.
     *
     * The UI factory *must* provide a new, fresh instance of the UI, so that the
     * tests start from a pre-known state. If you're using Spring and you're getting UI
     * from the injector, you must reconfigure Spring to use prototype scope,
     * otherwise an old UI from the UI scope or Session Scope will be provided.
     * @param uiFactory produces [UI] instances and sets them as current, by default
     * simply instantiates [MockedUI] class.
     * @param servlet allows you to provide your own implementation of [VaadinServlet].
     * You MUST override [VaadinServlet.createServletService]
     * and construct a custom service which overrides important methods.
     * Please consult [com.github.mvysny.kaributesting.v10.mock.MockService]
     * on what methods you must override in your custom service.
     */
    @JvmStatic
    public fun setup(uiFactory: () -> UI = @JvmSerializableLambda { MockedUI() }, servlet: VaadinServlet) {
        check(VaadinVersion.get.isAtLeast(24)) {
            "Karibu-Testing 2.x only works with Vaadin 24+ but you're using ${VaadinVersion.get}"
        }
        check(!VaadinMeta.isCompatibilityMode)

        // disable the Page.reload() detection if tearDown() was not called.
       lastUILocation.remove()

        // initialize servlet if necessary
        if (!servlet.isInitialized) {
            val ctx: ServletContext = MockVaadinHelper.createMockContext()
            servlet.init(FakeServletConfig(ctx))
        }
        val service: VaadinServletService = checkNotNull(servlet.service)
        check(service.router != null) { "$servlet failed to call VaadinServletService.init() in createServletService()" }
        VaadinService.setCurrent(service)

        // init Vaadin Session
        createSession(servlet.servletContext, uiFactory)
    }

    /**
     * Properly closes the current UI and fire the detach event on it.
     * Does nothing if there is no current UI.
     */
    public fun closeCurrentUI(fireUIDetach: Boolean) {
        val ui: UI = UI.getCurrent() ?: return
        lastUILocation.set(ui.internals.activeViewLocation)
        if (ui.isClosing && ui.internals.session != null) {
            ui._close()
        }
        if (fireUIDetach) {
            ComponentUtil.onComponentDetach(ui)
        }
        UI.setCurrent(null)
        strongRefUI.remove()
    }

    /**
     * Cleans up and removes the Vaadin UI and Vaadin Session. You can call this function in `afterEach{}` block,
     * to clean up after the test. This comes handy when you want to be extra-sure that the next test won't accidentally reuse old UI,
     * should you forget to call [setup] properly.
     *
     * You don't have to call this function though; [setup] will overwrite any current UI/Session instances with a fresh ones.
     *
     * Any exceptions thrown by listeners such as UI detach listener, or session/service destroy listeners, or scheduled calls
     * to [UI.access] will be propagated and thrown by this function.
     */
    @JvmStatic
    public fun tearDown() {
        try {
            clearVaadinInstances(false)
        } finally {
            lastUILocation.remove()
        }
        val service: VaadinService? = VaadinService.getCurrent()
        if (service != null) {
            service.fireServiceDestroyListeners(ServiceDestroyEvent(service))
            VaadinService.setCurrent(null)
        }
    }

    private fun clearVaadinInstances(fireUIDetach: Boolean) {
        try {
            closeCurrentUI(fireUIDetach)
            closeCurrentSession()
        } finally {
            CurrentInstance.set(VaadinRequest::class.java, null)
            CurrentInstance.set(VaadinResponse::class.java, null)
            strongRefReq.remove()
            strongRefRes.remove()
        }
    }

    /**
     * Closes current session if [VaadinSession.getCurrent] is not null.
     */
    private fun closeCurrentSession() {
        val session: VaadinSession? = VaadinSession.getCurrent()
        strongRefSession.remove()
        if (session != null) {
            val service: VaadinService = VaadinService.getCurrent()
            service.fireSessionDestroy(session)
            VaadinSession.setCurrent(null)
            // service destroys session via session.access(); we need to run that action now.
            currentlyClosingSession.set(true)
            try {
                runUIQueue(session = session)
            } finally {
                currentlyClosingSession.set(false)
            }
        }
    }

    private val currentlyClosingSession: ThreadLocal = ThreadLocal.withInitial { false }

    /**
     * Change & call [setup] to set a different browser.
     *
     * The default is Firefox 94 on Ubuntu Linux.
     */
    public var userAgent: String = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) Gecko/20100101 Firefox/94.0"

    /**
     * Creates [MockRequest]; override if you need to return a class that extends [MockRequest]
     * and modifies its behavior.
     */
    public var mockRequestFactory: (FakeHttpSession) -> FakeRequest = { FakeRequest(it) }

    private fun createSession(ctx: ServletContext, uiFactory: () -> UI) {
        val service: VaadinServletService = checkNotNull(VaadinService.getCurrent()) as VaadinServletService
        val httpSession: FakeHttpSession = FakeHttpSession.create(ctx)

        // init Vaadin Request
        val mockRequest = mockRequestFactory(httpSession)
        // so that session.browser.updateRequestDetails() also creates browserDetails
        mockRequest.headers["User-Agent"] = listOf(userAgent)
        val request = VaadinServletRequest(mockRequest, service)
        strongRefReq.set(request)
        CurrentInstance.set(VaadinRequest::class.java, request)

        // init Session.
        // Use the underlying Service to create the Vaadin Session; however
        // you MUST mock certain things in order for Karibu to work.
        // See MockSession for more details. By default the service is a MockService
        // which creates MockSession.
        val session: VaadinSession = service._createVaadinSession(VaadinRequest.getCurrent())
        httpSession.setAttribute(service.serviceName + ".lock", ReentrantLock().apply { lock() })
        httpSession.setAttribute(VaadinSession::class.java.name + "." + service.serviceName, session)
        session.refreshTransients(WrappedHttpSession(httpSession), service)
        check(session.lockInstance != null) { "$session created from $service has null lock. See the MockSession class on how to mock locks properly" }
        check((session.lockInstance as ReentrantLock).isLocked) { "$session created from $service: lock must be locked!" }
        session.configuration = service.deploymentConfiguration

        VaadinSession.setCurrent(session)
        strongRefSession.set(session)
        session.browser = WebBrowser(request)
        checkNotNull(session.browser.browserApplication) { "The WebBrowser has not been mocked properly" }

        // init Vaadin Response
        val response = VaadinServletResponse(FakeResponse(), service)
        strongRefRes.set(response)
        CurrentInstance.set(VaadinResponse::class.java, response)

        // fire session init listeners
        service.fireSessionInitListeners(SessionInitEvent(service, session, request))

        // create UI
        createUI(uiFactory, session)
    }

    internal fun createUI(uiFactory: () -> UI, session: VaadinSession) {
        val request: VaadinRequest = checkNotNull(VaadinRequest.getCurrent())
        val ui: UI = uiFactory()
        require(ui.session == null) {
            "uiFactory produced UI $ui which is already attached to a Session, " +
                    "yet we expect the UI to be a fresh new instance, not yet attached to a Session, so that the tests" +
                    " are able to always start with a fresh UI with a pre-known state. Perhaps you're " +
                    "using Spring which reuses a scoped instance of the UI?"
        }

        // hook into Page.reload() and recreate the UI
        UI::class.java.getDeclaredField("page").apply {
            isAccessible = true
            set(ui, MockPage(ui, uiFactory, session))
        }
        ui.internals.session = session
        UI.setCurrent(ui)
        ui.doInit(request, 1, "ROOT-1")
        strongRefUI.set(ui)

        session.addUI(ui)
        session.service.fireUIInitListeners(ui)

        // navigate to the initial page
        if (lastUILocation.get() != null) {
            UI.getCurrent().internals.router.navigate(UI.getCurrent(), lastUILocation.get()!!, NavigationTrigger.PROGRAMMATIC)
            lastUILocation.remove()
        } else {
            if (UI.getCurrent().internals.router.registry.getNavigationTarget("").isPresent) {
                UI.getCurrent().navigate("")
            }
        }

        // make sure that UI.getCurrent().push() can be called.
        // https://github.com/mvysny/karibu-testing/issues/80
        ui.pushConfiguration.pushMode = PushMode.AUTOMATIC
    }

    /**
     * Since Karibu-Testing runs in the same JVM as the server and there is no browser, the boundaries between the client and
     * the server become unclear. When looking into sources of any test method, it's really hard to tell where exactly the server request ends, and
     * where another request starts.
     *
     * You can establish an explicit client boundary in your test, by explicitly calling this method. However, since that
     * would be both laborous and error-prone, the default operation is that Karibu Testing pretends as if there was a client-server
     * roundtrip before every component lookup
     * via the [_get]/[_find]/[_expectNone]/[_expectOne] call. See [TestingLifecycleHook] for more details.
     *
     * Calls the following:
     * * [runUIQueue]
     * * [StateTree.runExecutionsBeforeClientResponse] which runs all blocks scheduled via [UI.beforeClientResponse]
     * * [cleanupDialogs]
     *
     * If you'd like to test your [ErrorHandler] then take a look at [runUIQueue] instead.
     * @throws IllegalStateException if the environment is not mocked
     */
    @JvmStatic
    public fun clientRoundtrip() {
        checkNotNull(VaadinSession.getCurrent()) { "No VaadinSession" }
        runUIQueue()
        UI.getCurrent().internals.stateTree.runExecutionsBeforeClientResponse()
        cleanupDialogs()
    }

    /**
     * Runs all tasks scheduled by [UI.access].
     *
     * If [VaadinSession.errorHandler] is not set or [propagateExceptionToHandler]
     * is false, any exceptions thrown from [Command]s scheduled via the [UI.access] will make this function fail.
     * The exceptions will be wrapped in [ExecutionException]. Generally
     * it's best to keep [propagateExceptionToHandler] set to false to
     * make any exceptions fail the test; however if you're testing
     * how your own custom [VaadinSession.errorHandler] responds to exceptions then
     * set this parameter to true.
     *
     * Called automatically by [clientRoundtrip] which is by default called automatically from [TestingLifecycleHook]. You generally
     * don't need to call this method unless you need to test your [ErrorHandler].
     *
     * @param propagateExceptionToHandler defaults to false. If true and [VaadinSession.errorHandler]
     * is set, any exceptions thrown from [Command]s scheduled via the [UI.access] will be
     * redirected to [VaadinSession.errorHandler] and will not be re-thrown from this method.
     * @throws IllegalStateException if the environment is not mocked
     */
    @JvmOverloads
    @JvmStatic
    public fun runUIQueue(propagateExceptionToHandler: Boolean = false, session: VaadinSession = VaadinSession.getCurrent()) {
        // we need to set up UI error handler which will be notified for every exception thrown out of the acccess{} block
        // otherwise the exceptions would simply be logged but unlock() wouldn't fail.
        val errors: MutableList = mutableListOf()
        val oldErrorHandler: ErrorHandler? = session.errorHandler
        if (oldErrorHandler == null || oldErrorHandler is DefaultErrorHandler || !propagateExceptionToHandler) {
            session.errorHandler = ErrorHandler {
                var t: Throwable = it.throwable
                if (t !is ExecutionException) {
                    // for some weird reason t may not be ExecutionException when it originates from a coroutine :confused:
                    // the stacktrace would point someplace random. Wrap it in ExecutionException whose stacktrace will point to the test
                    t = ExecutionException(t.message, t)
                }
                errors.add(t)
            }
        }

        try {
            // make sure the lock is held exactly once, otherwise the session.unlock() won't
            // process all Runnables registered via ui.access()
            expect(1) { (session.lockInstance as ReentrantLock).holdCount }
            session.unlock()  // this will process all Runnables registered via ui.access()
            // lock the session back, so that the test can continue running as-if in the UI thread.
            session.lock()
        } finally {
            session.errorHandler = oldErrorHandler
        }

        if (errors.isNotEmpty()) {
            errors.drop(1).forEach { errors[0].addSuppressed(it) }
            throw errors[0]
        }
    }

    /**
     * Internal function, do not call directly.
     *
     * Only usable when you are providing your own implementation of [VaadinSession].
     * See [MockVaadinSession] on how to call this properly.
     */
    @JvmStatic
    public fun afterSessionClose(session: VaadinSession, uiFactory: () -> UI) {
        // 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.

        // To log out the user, the code typically closes the session and tells the browser to reload
        // the page (Page.getCurrent().reload() or similar).
        // Thus the page is reloaded by the browser, and since the session is gone, the servlet container
        // will create a new, fresh session.

        // That's exactly what we need to do here. We need to close the current UI and eradicate it,
        // then we need to close the current session and eradicate it, and then we need to create a completely fresh
        // new UI and Session.

        // A problem appears when the uiFactory accidentally doesn't create a new, fresh instance of UI. Say that
        // we call Spring injector to provide us an instance of the UI, but we accidentally scoped the UI to Session.
        // Spring doesn't know that (since we haven't told Spring that the Session scope is gone) and provides
        // the previous UI instance which is still attached to the session. And it blows.

        if (!currentlyClosingSession.get()) {
            // Vaadin 20.0.5+: closing session also clears the wrapped VaadinSession.getSession().
            // Acquire the wrapped session beforehand.
            val mockSession: FakeHttpSession = session.fake
            clearVaadinInstances(true)
            mockSession.destroy()
            createSession(mockSession.servletContext, uiFactory)
        }
    }
}

private val _VaadinService_sessionInitListeners: Field by lazy(LazyThreadSafetyMode.PUBLICATION) {
    val field: Field = VaadinService::class.java.getDeclaredField("sessionInitListeners")
    field.isAccessible = true
    field
}

private fun VaadinService.fireSessionInitListeners(event: SessionInitEvent) {
    @Suppress("UNCHECKED_CAST")
    val sessionInitListeners: Collection =
        _VaadinService_sessionInitListeners.get(this) as Collection
    for (sessionInitListener in sessionInitListeners) {
        sessionInitListener.sessionInit(event)
    }
}

private val _VaadinService_sessionDestroyListeners: Field by lazy(LazyThreadSafetyMode.PUBLICATION) {
    val field: Field = VaadinService::class.java.getDeclaredField("serviceDestroyListeners")
    field.isAccessible = true
    field
}

private fun VaadinService.fireServiceDestroyListeners(event: ServiceDestroyEvent) {
    @Suppress("UNCHECKED_CAST")
    val listeners: Collection =
        _VaadinService_sessionDestroyListeners.get(this) as Collection
    for (listener in listeners) {
        listener.serviceDestroy(event)
    }
}

private class MockPage(private val ui: UI, private val uiFactory: () -> UI, private val session: VaadinSession) : Page(ui) {
    override fun reload() {
        // recreate the UI on reload(), to simulate browser's F5
        super.reload()
        MockVaadin.closeCurrentUI(true)
        MockVaadin.createUI(uiFactory, session)
    }

    override fun retrieveExtendedClientDetails(receiver: ExtendedClientDetailsReceiver) {
        if (!fakeExtendedClientDetails) {
            super.retrieveExtendedClientDetails(receiver)
            return
        }

        // construct mock ExtendedClientDetails then set it to ui.internals, which will cause
        // super to call receiver straight away.
        val constructor: Constructor<*> = ExtendedClientDetails::class.java.declaredConstructors[0]
        constructor.isAccessible = true
        val ecd: ExtendedClientDetails = constructor.newInstance(
            "1920", "1080", "1846", "939", "1846", "939",
            "10800000", "7200000", "3600000", "true", "Europe/Helsinki",
            null, "false", "1.0", "ROOT-2521314-0.2626611481", "Linux x86_64"
        ) as ExtendedClientDetails
        ui.internals.extendedClientDetails = ecd
        super.retrieveExtendedClientDetails(receiver)
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy