diff --git a/integration/test_login_flow.py b/integration/test_login_flow.py new file mode 100644 index 0000000000..144c806d3b --- /dev/null +++ b/integration/test_login_flow.py @@ -0,0 +1,144 @@ +"""Integration tests for client side storage.""" +from __future__ import annotations + +from typing import Generator + +import pytest +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By +from selenium.webdriver.remote.webdriver import WebDriver + +from reflex.testing import AppHarness + +from . import utils + + +def LoginSample(): + """Sample app for testing login/logout with LocalStorage var.""" + import reflex as rx + + class State(rx.State): + auth_token: str = rx.LocalStorage("") + + def logout(self): + self.set_auth_token("") + + def login(self): + self.set_auth_token("12345") + yield rx.redirect("/") + + def index(): + return rx.Cond.create( + State.is_hydrated & State.auth_token, # type: ignore + rx.vstack( + rx.heading(State.auth_token), + rx.button("Logout", on_click=State.logout), + ), + rx.button("Login", on_click=rx.redirect("/login")), + ) + + def login(): + return rx.vstack( + rx.button("Do it", on_click=State.login), + ) + + app = rx.App(state=State) + app.add_page(index) + app.add_page(login) + app.compile() + + +@pytest.fixture(scope="session") +def login_sample(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start LoginSample app at tmp_path via AppHarness. + + Args: + tmp_path_factory: pytest tmp_path_factory fixture + + Yields: + running AppHarness instance + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("login_sample"), + app_source=LoginSample, # type: ignore + ) as harness: + yield harness + + +@pytest.fixture() +def driver(login_sample: AppHarness) -> Generator[WebDriver, None, None]: + """Get an instance of the browser open to the login_sample app. + + Args: + login_sample: harness for LoginSample app + + Yields: + WebDriver instance. + """ + assert login_sample.app_instance is not None, "app is not running" + driver = login_sample.frontend() + try: + yield driver + finally: + driver.quit() + + +@pytest.fixture() +def local_storage(driver: WebDriver) -> Generator[utils.LocalStorage, None, None]: + """Get an instance of the local storage helper. + + Args: + driver: WebDriver instance. + + Yields: + Local storage helper. + """ + ls = utils.LocalStorage(driver) + yield ls + ls.clear() + + +def test_login_flow( + login_sample: AppHarness, driver: WebDriver, local_storage: utils.LocalStorage +): + """Test login flow. + + Args: + login_sample: harness for LoginSample app. + driver: WebDriver instance. + local_storage: Local storage helper. + """ + assert login_sample.frontend_url is not None + local_storage.clear() + + with pytest.raises(NoSuchElementException): + driver.find_element(By.TAG_NAME, "h2") + + login_button = driver.find_element(By.TAG_NAME, "button") + assert login_button.text == "Login" + with utils.poll_for_navigation(driver): + login_button.click() + assert driver.current_url.endswith("/login/") + + do_it_button = driver.find_element(By.TAG_NAME, "button") + assert do_it_button.text == "Do it" + with utils.poll_for_navigation(driver): + do_it_button.click() + assert driver.current_url == login_sample.frontend_url + "/" + + def check_auth_token_header(): + try: + auth_token_header = driver.find_element(By.TAG_NAME, "h2") + except NoSuchElementException: + return False + return auth_token_header.text + + assert login_sample._poll_for(check_auth_token_header) == "12345" + + logout_button = driver.find_element(By.TAG_NAME, "button") + assert logout_button.text == "Logout" + logout_button.click() + + assert login_sample._poll_for(lambda: local_storage["state.auth_token"] == "") + with pytest.raises(NoSuchElementException): + driver.find_element(By.TAG_NAME, "h2") diff --git a/reflex/.templates/jinja/web/pages/index.js.jinja2 b/reflex/.templates/jinja/web/pages/index.js.jinja2 index 7ac8bf2692..16610de30a 100644 --- a/reflex/.templates/jinja/web/pages/index.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/index.js.jinja2 @@ -25,7 +25,7 @@ export default function Component() { // Route after the initial page hydration. useEffect(() => { - const change_complete = () => addEvents(initialEvents.map((e) => ({...e}))) + const change_complete = () => addEvents(initialEvents()) {{const.router}}.events.on('routeChangeComplete', change_complete) return () => { {{const.router}}.events.off('routeChangeComplete', change_complete) diff --git a/reflex/.templates/jinja/web/utils/context.js.jinja2 b/reflex/.templates/jinja/web/utils/context.js.jinja2 index b8831ec984..274718a563 100644 --- a/reflex/.templates/jinja/web/utils/context.js.jinja2 +++ b/reflex/.templates/jinja/web/utils/context.js.jinja2 @@ -6,7 +6,7 @@ export const ColorModeContext = createContext(null); export const StateContext = createContext(null); export const EventLoopContext = createContext(null); export const clientStorage = {{ client_storage|json_dumps }} -export const initialEvents = [ +export const initialEvents = () => [ Event('{{state_name}}.{{const.hydrate}}', hydrateClientStorage(clientStorage)), ] export const isDevMode = {{ is_dev_mode|json_dumps }} diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9b596d0ff0..efb18c6049 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -468,7 +468,7 @@ const applyClientStorageDelta = (client_storage, delta) => { /** * Establish websocket event loop for a NextJS page. * @param initial_state The initial app state. - * @param initial_events The initial app events. + * @param initial_events Function that returns the initial app events. * @param client_storage The client storage object from context.js * * @returns [state, addEvents, connectError] - @@ -478,7 +478,7 @@ const applyClientStorageDelta = (client_storage, delta) => { */ export const useEventLoop = ( initial_state = {}, - initial_events = [], + initial_events = () => [], client_storage = {}, ) => { const socket = useRef(null) @@ -496,7 +496,7 @@ export const useEventLoop = ( // initial state hydrate useEffect(() => { if (router.isReady && !sentHydrate.current) { - addEvents(initial_events.map((e) => ({ ...e }))) + addEvents(initial_events()) sentHydrate.current = true } }, [router.isReady])