test_dynamic_routes.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. """Integration tests for dynamic route page behavior."""
  2. from __future__ import annotations
  3. import time
  4. from collections.abc import Callable, Coroutine, Generator
  5. from urllib.parse import urlsplit
  6. import pytest
  7. from selenium.webdriver.common.by import By
  8. from reflex.testing import AppHarness, WebDriver
  9. from .utils import poll_for_navigation
  10. def DynamicRoute():
  11. """App for testing dynamic routes."""
  12. import reflex as rx
  13. class DynamicState(rx.State):
  14. order: list[str] = []
  15. @rx.event
  16. def on_load(self):
  17. page_data = f"{self.router.page.path}-{self.page_id or 'no page id'}"
  18. print(f"on_load: {page_data}")
  19. self.order.append(page_data)
  20. @rx.event
  21. def on_load_redir(self):
  22. query_params = self.router.page.params
  23. page_data = f"on_load_redir-{query_params}"
  24. print(f"on_load_redir: {page_data}")
  25. self.order.append(page_data)
  26. return rx.redirect(f"/page/{query_params['page_id']}")
  27. @rx.var
  28. def next_page(self) -> str:
  29. try:
  30. return str(int(self.page_id) + 1)
  31. except ValueError:
  32. return "0"
  33. def index():
  34. return rx.fragment(
  35. rx.input(
  36. value=DynamicState.router.session.client_token,
  37. read_only=True,
  38. id="token",
  39. ),
  40. rx.input(value=rx.State.page_id, read_only=True, id="page_id"), # pyright: ignore [reportAttributeAccessIssue]
  41. rx.input(
  42. value=DynamicState.router.page.raw_path,
  43. read_only=True,
  44. id="raw_path",
  45. ),
  46. rx.link("index", href="/", id="link_index"),
  47. rx.link("page_X", href="/static/x", id="link_page_x"),
  48. rx.link(
  49. "next",
  50. href="/page/" + DynamicState.next_page,
  51. id="link_page_next",
  52. ),
  53. rx.link("missing", href="/missing", id="link_missing"),
  54. rx.list( # pyright: ignore [reportAttributeAccessIssue]
  55. rx.foreach(
  56. DynamicState.order, # pyright: ignore [reportAttributeAccessIssue]
  57. lambda i: rx.list_item(rx.text(i)),
  58. ),
  59. ),
  60. )
  61. class ArgState(rx.State):
  62. """The app state."""
  63. @rx.var(cache=False)
  64. def arg(self) -> int:
  65. return int(self.arg_str or 0)
  66. class ArgSubState(ArgState):
  67. @rx.var
  68. def cached_arg(self) -> int:
  69. return self.arg
  70. @rx.var
  71. def cached_arg_str(self) -> str:
  72. return self.arg_str
  73. @rx.page(route="/arg/[arg_str]")
  74. def arg() -> rx.Component:
  75. return rx.vstack(
  76. rx.input(
  77. value=DynamicState.router.session.client_token,
  78. read_only=True,
  79. id="token",
  80. ),
  81. rx.data_list.root(
  82. rx.data_list.item(
  83. rx.data_list.label("rx.State.arg_str (dynamic)"),
  84. rx.data_list.value(rx.State.arg_str, id="state-arg_str"), # pyright: ignore [reportAttributeAccessIssue]
  85. ),
  86. rx.data_list.item(
  87. rx.data_list.label("ArgState.arg_str (dynamic) (inherited)"),
  88. rx.data_list.value(ArgState.arg_str, id="argstate-arg_str"), # pyright: ignore [reportAttributeAccessIssue]
  89. ),
  90. rx.data_list.item(
  91. rx.data_list.label("ArgState.arg"),
  92. rx.data_list.value(ArgState.arg, id="argstate-arg"),
  93. ),
  94. rx.data_list.item(
  95. rx.data_list.label("ArgSubState.arg_str (dynamic) (inherited)"),
  96. rx.data_list.value(ArgSubState.arg_str, id="argsubstate-arg_str"), # pyright: ignore [reportAttributeAccessIssue]
  97. ),
  98. rx.data_list.item(
  99. rx.data_list.label("ArgSubState.arg (inherited)"),
  100. rx.data_list.value(ArgSubState.arg, id="argsubstate-arg"),
  101. ),
  102. rx.data_list.item(
  103. rx.data_list.label("ArgSubState.cached_arg"),
  104. rx.data_list.value(
  105. ArgSubState.cached_arg, id="argsubstate-cached_arg"
  106. ),
  107. ),
  108. rx.data_list.item(
  109. rx.data_list.label("ArgSubState.cached_arg_str"),
  110. rx.data_list.value(
  111. ArgSubState.cached_arg_str, id="argsubstate-cached_arg_str"
  112. ),
  113. ),
  114. ),
  115. rx.link("+", href=f"/arg/{ArgState.arg + 1}", id="next-page"),
  116. align="center",
  117. height="100vh",
  118. )
  119. @rx.page(route="/redirect-page/[page_id]", on_load=DynamicState.on_load_redir)
  120. def redirect_page():
  121. return rx.fragment(rx.text("redirecting..."))
  122. app = rx.App()
  123. app.add_page(index, route="/page/[page_id]", on_load=DynamicState.on_load)
  124. app.add_page(index, route="/static/x", on_load=DynamicState.on_load)
  125. app.add_page(index)
  126. app.add_custom_404_page(on_load=DynamicState.on_load)
  127. @pytest.fixture(scope="module")
  128. def dynamic_route(
  129. app_harness_env: type[AppHarness], tmp_path_factory
  130. ) -> Generator[AppHarness, None, None]:
  131. """Start DynamicRoute app at tmp_path via AppHarness.
  132. Args:
  133. app_harness_env: either AppHarness (dev) or AppHarnessProd (prod)
  134. tmp_path_factory: pytest tmp_path_factory fixture
  135. Yields:
  136. running AppHarness instance
  137. """
  138. with app_harness_env.create(
  139. root=tmp_path_factory.mktemp("dynamic_route"),
  140. app_name=f"dynamicroute_{app_harness_env.__name__.lower()}",
  141. app_source=DynamicRoute,
  142. ) as harness:
  143. yield harness
  144. @pytest.fixture
  145. def driver(dynamic_route: AppHarness) -> Generator[WebDriver, None, None]:
  146. """Get an instance of the browser open to the dynamic_route app.
  147. Args:
  148. dynamic_route: harness for DynamicRoute app
  149. Yields:
  150. WebDriver instance.
  151. """
  152. assert dynamic_route.app_instance is not None, "app is not running"
  153. driver = dynamic_route.frontend()
  154. # TODO: drop after flakiness is resolved
  155. driver.implicitly_wait(30)
  156. try:
  157. yield driver
  158. finally:
  159. driver.quit()
  160. @pytest.fixture()
  161. def token(dynamic_route: AppHarness, driver: WebDriver) -> str:
  162. """Get the token associated with backend state.
  163. Args:
  164. dynamic_route: harness for DynamicRoute app.
  165. driver: WebDriver instance.
  166. Returns:
  167. The token visible in the driver browser.
  168. """
  169. assert dynamic_route.app_instance is not None
  170. token_input = dynamic_route.poll_for_result(
  171. lambda: driver.find_element(By.ID, "token")
  172. )
  173. assert token_input
  174. # wait for the backend connection to send the token
  175. token = dynamic_route.poll_for_value(token_input)
  176. assert token is not None
  177. return token
  178. @pytest.fixture()
  179. def poll_for_order(
  180. dynamic_route: AppHarness, token: str
  181. ) -> Callable[[list[str]], Coroutine[None, None, None]]:
  182. """Poll for the order list to match the expected order.
  183. Args:
  184. dynamic_route: harness for DynamicRoute app.
  185. token: The token visible in the driver browser.
  186. Returns:
  187. An async function that polls for the order list to match the expected order.
  188. """
  189. dynamic_state_name = dynamic_route.get_state_name("_dynamic_state")
  190. dynamic_state_full_name = dynamic_route.get_full_state_name(["_dynamic_state"])
  191. async def _poll_for_order(exp_order: list[str]):
  192. async def _backend_state():
  193. return await dynamic_route.get_state(f"{token}_{dynamic_state_full_name}")
  194. async def _check():
  195. return (await _backend_state()).substates[
  196. dynamic_state_name
  197. ].order == exp_order
  198. await AppHarness._poll_for_async(_check, timeout=10)
  199. assert (
  200. list((await _backend_state()).substates[dynamic_state_name].order)
  201. == exp_order
  202. )
  203. return _poll_for_order
  204. @pytest.mark.asyncio
  205. async def test_on_load_navigate(
  206. dynamic_route: AppHarness,
  207. driver: WebDriver,
  208. token: str,
  209. poll_for_order: Callable[[list[str]], Coroutine[None, None, None]],
  210. ):
  211. """Click links to navigate between dynamic pages with on_load event.
  212. Args:
  213. dynamic_route: harness for DynamicRoute app.
  214. driver: WebDriver instance.
  215. token: The token visible in the driver browser.
  216. poll_for_order: function that polls for the order list to match the expected order.
  217. """
  218. dynamic_state_full_name = dynamic_route.get_full_state_name(["_dynamic_state"])
  219. assert dynamic_route.app_instance is not None
  220. link = driver.find_element(By.ID, "link_page_next")
  221. assert link
  222. exp_order = [f"/page/{ix}-{ix}" for ix in range(10)]
  223. # click the link a few times
  224. for ix in range(10):
  225. # wait for navigation, then assert on url
  226. with poll_for_navigation(driver):
  227. link.click()
  228. assert urlsplit(driver.current_url).path == f"/page/{ix}"
  229. link = dynamic_route.poll_for_result(
  230. lambda: driver.find_element(By.ID, "link_page_next")
  231. )
  232. page_id_input = driver.find_element(By.ID, "page_id")
  233. raw_path_input = driver.find_element(By.ID, "raw_path")
  234. assert link
  235. assert page_id_input
  236. assert dynamic_route.poll_for_value(
  237. page_id_input, exp_not_equal=str(ix - 1)
  238. ) == str(ix)
  239. assert dynamic_route.poll_for_value(raw_path_input) == f"/page/{ix}"
  240. await poll_for_order(exp_order)
  241. frontend_url = dynamic_route.frontend_url
  242. assert frontend_url
  243. frontend_url = frontend_url.removesuffix("/")
  244. # manually load the next page to trigger client side routing in prod mode
  245. exp_order += ["/page/10-10"]
  246. with poll_for_navigation(driver):
  247. driver.get(f"{frontend_url}/page/10")
  248. await poll_for_order(exp_order)
  249. # make sure internal nav still hydrates after redirect
  250. exp_order += ["/page/11-11"]
  251. link = driver.find_element(By.ID, "link_page_next")
  252. with poll_for_navigation(driver):
  253. link.click()
  254. await poll_for_order(exp_order)
  255. # load same page with a query param and make sure it passes through
  256. exp_order += ["/page/11-11"]
  257. with poll_for_navigation(driver):
  258. driver.get(f"{driver.current_url}?foo=bar")
  259. await poll_for_order(exp_order)
  260. assert (
  261. await dynamic_route.get_state(f"{token}_{dynamic_state_full_name}")
  262. ).router.page.params["foo"] == "bar"
  263. # hit a 404 and ensure we still hydrate
  264. exp_order += ["/missing-no page id"]
  265. with poll_for_navigation(driver):
  266. driver.get(f"{frontend_url}/missing")
  267. await poll_for_order(exp_order)
  268. # browser nav should still trigger hydration
  269. exp_order += ["/page/11-11"]
  270. with poll_for_navigation(driver):
  271. driver.back()
  272. await poll_for_order(exp_order)
  273. # next/link to a 404 and ensure we still hydrate
  274. exp_order += ["/missing-no page id"]
  275. link = driver.find_element(By.ID, "link_missing")
  276. with poll_for_navigation(driver):
  277. link.click()
  278. await poll_for_order(exp_order)
  279. # hit a page that redirects back to dynamic page
  280. exp_order += ["on_load_redir-{'foo': 'bar', 'page_id': '0'}", "/page/0-0"]
  281. with poll_for_navigation(driver):
  282. driver.get(f"{frontend_url}/redirect-page/0/?foo=bar")
  283. await poll_for_order(exp_order)
  284. # should have redirected back to page 0
  285. assert urlsplit(driver.current_url).path.removesuffix("/") == "/page/0"
  286. @pytest.mark.asyncio
  287. async def test_on_load_navigate_non_dynamic(
  288. dynamic_route: AppHarness,
  289. driver: WebDriver,
  290. poll_for_order: Callable[[list[str]], Coroutine[None, None, None]],
  291. ):
  292. """Click links to navigate between static pages with on_load event.
  293. Args:
  294. dynamic_route: harness for DynamicRoute app.
  295. driver: WebDriver instance.
  296. poll_for_order: function that polls for the order list to match the expected order.
  297. """
  298. assert dynamic_route.app_instance is not None
  299. link = driver.find_element(By.ID, "link_page_x")
  300. assert link
  301. with poll_for_navigation(driver):
  302. link.click()
  303. assert urlsplit(driver.current_url).path.removesuffix("/") == "/static/x"
  304. await poll_for_order(["/static/x-no page id"])
  305. # go back to the index and navigate back to the static route
  306. link = driver.find_element(By.ID, "link_index")
  307. with poll_for_navigation(driver):
  308. link.click()
  309. assert urlsplit(driver.current_url).path.removesuffix("/") == ""
  310. link = driver.find_element(By.ID, "link_page_x")
  311. with poll_for_navigation(driver):
  312. link.click()
  313. assert urlsplit(driver.current_url).path.removesuffix("/") == "/static/x"
  314. await poll_for_order(["/static/x-no page id", "/static/x-no page id"])
  315. @pytest.mark.asyncio
  316. async def test_render_dynamic_arg(
  317. dynamic_route: AppHarness,
  318. driver: WebDriver,
  319. token: str,
  320. ):
  321. """Assert that dynamic arg var is rendered correctly in different contexts.
  322. Args:
  323. dynamic_route: harness for DynamicRoute app.
  324. driver: WebDriver instance.
  325. token: The token visible in the driver browser.
  326. """
  327. assert dynamic_route.app_instance is not None
  328. frontend_url = dynamic_route.frontend_url
  329. assert frontend_url
  330. with poll_for_navigation(driver):
  331. driver.get(f"{frontend_url.removesuffix('/')}/arg/0")
  332. # TODO: drop after flakiness is resolved
  333. time.sleep(3)
  334. def assert_content(expected: str, expect_not: str):
  335. ids = [
  336. "state-arg_str",
  337. "argstate-arg",
  338. "argstate-arg_str",
  339. "argsubstate-arg_str",
  340. "argsubstate-arg",
  341. "argsubstate-cached_arg",
  342. "argsubstate-cached_arg_str",
  343. ]
  344. for id in ids:
  345. el = driver.find_element(By.ID, id)
  346. assert el
  347. assert (
  348. dynamic_route.poll_for_content(el, timeout=30, exp_not_equal=expect_not)
  349. == expected
  350. )
  351. assert_content("0", "")
  352. next_page_link = driver.find_element(By.ID, "next-page")
  353. assert next_page_link
  354. with poll_for_navigation(driver):
  355. next_page_link.click()
  356. assert (
  357. driver.current_url.removesuffix("/")
  358. == f"{frontend_url.removesuffix('/')}/arg/1"
  359. )
  360. assert_content("1", "0")
  361. next_page_link = driver.find_element(By.ID, "next-page")
  362. assert next_page_link
  363. with poll_for_navigation(driver):
  364. next_page_link.click()
  365. assert (
  366. driver.current_url.removesuffix("/")
  367. == f"{frontend_url.removesuffix('/')}/arg/2"
  368. )
  369. assert_content("2", "1")