test_event_chain.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. """Ensure that Event Chains are properly queued and handled between frontend and backend."""
  2. from __future__ import annotations
  3. from typing import Generator
  4. import pytest
  5. from selenium.webdriver.common.by import By
  6. from reflex.testing import AppHarness, WebDriver
  7. MANY_EVENTS = 50
  8. def EventChain():
  9. """App with chained event handlers."""
  10. import asyncio
  11. import time
  12. import reflex as rx
  13. # repeated here since the outer global isn't exported into the App module
  14. MANY_EVENTS = 50
  15. class State(rx.State):
  16. event_order: list[str] = []
  17. interim_value: str = ""
  18. @rx.event
  19. def event_no_args(self):
  20. self.event_order.append("event_no_args")
  21. @rx.event
  22. def event_arg(self, arg):
  23. self.event_order.append(f"event_arg:{arg}")
  24. @rx.event
  25. def event_arg_repr_type(self, arg):
  26. self.event_order.append(f"event_arg_repr:{arg!r}_{type(arg).__name__}")
  27. @rx.event
  28. def event_nested_1(self):
  29. self.event_order.append("event_nested_1")
  30. yield State.event_nested_2
  31. yield State.event_arg("nested_1")
  32. @rx.event
  33. def event_nested_2(self):
  34. self.event_order.append("event_nested_2")
  35. yield State.event_nested_3
  36. yield rx.console_log("event_nested_2")
  37. yield State.event_arg("nested_2")
  38. @rx.event
  39. def event_nested_3(self):
  40. self.event_order.append("event_nested_3")
  41. yield State.event_no_args
  42. yield State.event_arg("nested_3")
  43. @rx.event
  44. def on_load_return_chain(self):
  45. self.event_order.append("on_load_return_chain")
  46. return [State.event_arg(1), State.event_arg(2), State.event_arg(3)]
  47. @rx.event
  48. def on_load_yield_chain(self):
  49. self.event_order.append("on_load_yield_chain")
  50. yield State.event_arg(4)
  51. yield State.event_arg(5)
  52. yield State.event_arg(6)
  53. @rx.event
  54. def click_return_event(self):
  55. self.event_order.append("click_return_event")
  56. return State.event_no_args
  57. @rx.event
  58. def click_return_events(self):
  59. self.event_order.append("click_return_events")
  60. return [
  61. State.event_arg(7),
  62. rx.console_log("click_return_events"),
  63. State.event_arg(8),
  64. State.event_arg(9),
  65. ]
  66. @rx.event
  67. def click_yield_chain(self):
  68. self.event_order.append("click_yield_chain:0")
  69. yield State.event_arg(10)
  70. self.event_order.append("click_yield_chain:1")
  71. yield rx.console_log("click_yield_chain")
  72. yield State.event_arg(11)
  73. self.event_order.append("click_yield_chain:2")
  74. yield State.event_arg(12)
  75. self.event_order.append("click_yield_chain:3")
  76. @rx.event
  77. def click_yield_many_events(self):
  78. self.event_order.append("click_yield_many_events")
  79. for ix in range(MANY_EVENTS):
  80. yield State.event_arg(ix)
  81. yield rx.console_log(f"many_events_{ix}")
  82. self.event_order.append("click_yield_many_events_done")
  83. @rx.event
  84. def click_yield_nested(self):
  85. self.event_order.append("click_yield_nested")
  86. yield State.event_nested_1
  87. yield State.event_arg("yield_nested")
  88. @rx.event
  89. def redirect_return_chain(self):
  90. self.event_order.append("redirect_return_chain")
  91. yield rx.redirect("/on-load-return-chain")
  92. @rx.event
  93. def redirect_yield_chain(self):
  94. self.event_order.append("redirect_yield_chain")
  95. yield rx.redirect("/on-load-yield-chain")
  96. @rx.event
  97. def click_return_int_type(self):
  98. self.event_order.append("click_return_int_type")
  99. return State.event_arg_repr_type(1)
  100. @rx.event
  101. def click_return_dict_type(self):
  102. self.event_order.append("click_return_dict_type")
  103. return State.event_arg_repr_type({"a": 1})
  104. @rx.event
  105. async def click_yield_interim_value_async(self):
  106. self.interim_value = "interim"
  107. yield
  108. await asyncio.sleep(0.5)
  109. self.interim_value = "final"
  110. @rx.event
  111. def click_yield_interim_value(self):
  112. self.interim_value = "interim"
  113. yield
  114. time.sleep(0.5)
  115. self.interim_value = "final"
  116. app = rx.App()
  117. token_input = rx.input(
  118. value=State.router.session.client_token, is_read_only=True, id="token"
  119. )
  120. @app.add_page
  121. def index():
  122. return rx.fragment(
  123. token_input,
  124. rx.input(value=State.interim_value, is_read_only=True, id="interim_value"),
  125. rx.button(
  126. "Return Event",
  127. id="return_event",
  128. on_click=State.click_return_event,
  129. ),
  130. rx.button(
  131. "Return Events",
  132. id="return_events",
  133. on_click=State.click_return_events,
  134. ),
  135. rx.button(
  136. "Yield Chain",
  137. id="yield_chain",
  138. on_click=State.click_yield_chain,
  139. ),
  140. rx.button(
  141. "Yield Many events",
  142. id="yield_many_events",
  143. on_click=State.click_yield_many_events,
  144. ),
  145. rx.button(
  146. "Yield Nested",
  147. id="yield_nested",
  148. on_click=State.click_yield_nested,
  149. ),
  150. rx.button(
  151. "Redirect Yield Chain",
  152. id="redirect_yield_chain",
  153. on_click=State.redirect_yield_chain,
  154. ),
  155. rx.button(
  156. "Redirect Return Chain",
  157. id="redirect_return_chain",
  158. on_click=State.redirect_return_chain,
  159. ),
  160. rx.button(
  161. "Click Int Type",
  162. id="click_int_type",
  163. on_click=lambda: State.event_arg_repr_type(1),
  164. ),
  165. rx.button(
  166. "Click Dict Type",
  167. id="click_dict_type",
  168. on_click=lambda: State.event_arg_repr_type({"a": 1}),
  169. ),
  170. rx.button(
  171. "Return Chain Int Type",
  172. id="return_int_type",
  173. on_click=State.click_return_int_type,
  174. ),
  175. rx.button(
  176. "Return Chain Dict Type",
  177. id="return_dict_type",
  178. on_click=State.click_return_dict_type,
  179. ),
  180. rx.button(
  181. "Click Yield Interim Value (Async)",
  182. id="click_yield_interim_value_async",
  183. on_click=State.click_yield_interim_value_async,
  184. ),
  185. rx.button(
  186. "Click Yield Interim Value",
  187. id="click_yield_interim_value",
  188. on_click=State.click_yield_interim_value,
  189. ),
  190. )
  191. def on_load_return_chain():
  192. return rx.fragment(
  193. rx.text("return"),
  194. token_input,
  195. )
  196. def on_load_yield_chain():
  197. return rx.fragment(
  198. rx.text("yield"),
  199. token_input,
  200. )
  201. def on_mount_return_chain():
  202. return rx.fragment(
  203. rx.text(
  204. "return",
  205. on_mount=State.on_load_return_chain,
  206. on_unmount=lambda: State.event_arg("unmount"),
  207. ),
  208. token_input,
  209. rx.button("Unmount", on_click=rx.redirect("/"), id="unmount"),
  210. )
  211. def on_mount_yield_chain():
  212. return rx.fragment(
  213. rx.text(
  214. "yield",
  215. on_mount=[
  216. State.on_load_yield_chain,
  217. lambda: State.event_arg("mount"),
  218. ],
  219. on_unmount=State.event_no_args,
  220. ),
  221. token_input,
  222. rx.button("Unmount", on_click=rx.redirect("/"), id="unmount"),
  223. )
  224. app.add_page(on_load_return_chain, on_load=State.on_load_return_chain)
  225. app.add_page(on_load_yield_chain, on_load=State.on_load_yield_chain)
  226. app.add_page(on_mount_return_chain)
  227. app.add_page(on_mount_yield_chain)
  228. @pytest.fixture(scope="module")
  229. def event_chain(tmp_path_factory) -> Generator[AppHarness, None, None]:
  230. """Start EventChain app at tmp_path via AppHarness.
  231. Args:
  232. tmp_path_factory: pytest tmp_path_factory fixture
  233. Yields:
  234. running AppHarness instance
  235. """
  236. with AppHarness.create(
  237. root=tmp_path_factory.mktemp("event_chain"),
  238. app_source=EventChain,
  239. ) as harness:
  240. yield harness
  241. @pytest.fixture
  242. def driver(event_chain: AppHarness) -> Generator[WebDriver, None, None]:
  243. """Get an instance of the browser open to the event_chain app.
  244. Args:
  245. event_chain: harness for EventChain app
  246. Yields:
  247. WebDriver instance.
  248. """
  249. assert event_chain.app_instance is not None, "app is not running"
  250. driver = event_chain.frontend()
  251. try:
  252. yield driver
  253. finally:
  254. driver.quit()
  255. def assert_token(event_chain: AppHarness, driver: WebDriver) -> str:
  256. """Get the token associated with backend state.
  257. Args:
  258. event_chain: harness for EventChain app.
  259. driver: WebDriver instance.
  260. Returns:
  261. The token visible in the driver browser.
  262. """
  263. assert event_chain.app_instance is not None
  264. token_input = driver.find_element(By.ID, "token")
  265. assert token_input
  266. # wait for the backend connection to send the token
  267. token = event_chain.poll_for_value(token_input)
  268. assert token is not None
  269. state_name = event_chain.get_full_state_name(["_state"])
  270. return f"{token}_{state_name}"
  271. @pytest.mark.parametrize(
  272. ("button_id", "exp_event_order"),
  273. [
  274. ("return_event", ["click_return_event", "event_no_args"]),
  275. (
  276. "return_events",
  277. ["click_return_events", "event_arg:7", "event_arg:8", "event_arg:9"],
  278. ),
  279. (
  280. "yield_chain",
  281. [
  282. "click_yield_chain:0",
  283. "click_yield_chain:1",
  284. "click_yield_chain:2",
  285. "click_yield_chain:3",
  286. "event_arg:10",
  287. "event_arg:11",
  288. "event_arg:12",
  289. ],
  290. ),
  291. (
  292. "yield_many_events",
  293. [
  294. "click_yield_many_events",
  295. "click_yield_many_events_done",
  296. *[f"event_arg:{ix}" for ix in range(MANY_EVENTS)],
  297. ],
  298. ),
  299. (
  300. "yield_nested",
  301. [
  302. "click_yield_nested",
  303. "event_nested_1",
  304. "event_arg:yield_nested",
  305. "event_nested_2",
  306. "event_arg:nested_1",
  307. "event_nested_3",
  308. "event_arg:nested_2",
  309. "event_no_args",
  310. "event_arg:nested_3",
  311. ],
  312. ),
  313. (
  314. "redirect_return_chain",
  315. [
  316. "redirect_return_chain",
  317. "on_load_return_chain",
  318. "event_arg:1",
  319. "event_arg:2",
  320. "event_arg:3",
  321. ],
  322. ),
  323. (
  324. "redirect_yield_chain",
  325. [
  326. "redirect_yield_chain",
  327. "on_load_yield_chain",
  328. "event_arg:4",
  329. "event_arg:5",
  330. "event_arg:6",
  331. ],
  332. ),
  333. (
  334. "click_int_type",
  335. ["event_arg_repr:1_int"],
  336. ),
  337. (
  338. "click_dict_type",
  339. ["event_arg_repr:{'a': 1}_dict"],
  340. ),
  341. (
  342. "return_int_type",
  343. ["click_return_int_type", "event_arg_repr:1_int"],
  344. ),
  345. (
  346. "return_dict_type",
  347. ["click_return_dict_type", "event_arg_repr:{'a': 1}_dict"],
  348. ),
  349. ],
  350. )
  351. @pytest.mark.asyncio
  352. async def test_event_chain_click(
  353. event_chain: AppHarness,
  354. driver: WebDriver,
  355. button_id: str,
  356. exp_event_order: list[str],
  357. ):
  358. """Click the button, assert that the events are handled in the correct order.
  359. Args:
  360. event_chain: AppHarness for the event_chain app
  361. driver: selenium WebDriver open to the app
  362. button_id: the ID of the button to click
  363. exp_event_order: the expected events recorded in the State
  364. """
  365. token = assert_token(event_chain, driver)
  366. state_name = event_chain.get_state_name("_state")
  367. btn = driver.find_element(By.ID, button_id)
  368. btn.click()
  369. async def _has_all_events():
  370. return len(
  371. (await event_chain.get_state(token)).substates[state_name].event_order
  372. ) == len(exp_event_order)
  373. await AppHarness._poll_for_async(_has_all_events)
  374. event_order = (await event_chain.get_state(token)).substates[state_name].event_order
  375. assert event_order == exp_event_order
  376. @pytest.mark.parametrize(
  377. ("uri", "exp_event_order"),
  378. [
  379. (
  380. "/on-load-return-chain",
  381. [
  382. "on_load_return_chain",
  383. "event_arg:1",
  384. "event_arg:2",
  385. "event_arg:3",
  386. ],
  387. ),
  388. (
  389. "/on-load-yield-chain",
  390. [
  391. "on_load_yield_chain",
  392. "event_arg:4",
  393. "event_arg:5",
  394. "event_arg:6",
  395. ],
  396. ),
  397. ],
  398. )
  399. @pytest.mark.asyncio
  400. async def test_event_chain_on_load(
  401. event_chain: AppHarness,
  402. driver: WebDriver,
  403. uri: str,
  404. exp_event_order: list[str],
  405. ):
  406. """Load the URI, assert that the events are handled in the correct order.
  407. Args:
  408. event_chain: AppHarness for the event_chain app
  409. driver: selenium WebDriver open to the app
  410. uri: the page to load
  411. exp_event_order: the expected events recorded in the State
  412. """
  413. assert event_chain.frontend_url is not None
  414. driver.get(event_chain.frontend_url + uri)
  415. token = assert_token(event_chain, driver)
  416. state_name = event_chain.get_state_name("_state")
  417. async def _has_all_events():
  418. return len(
  419. (await event_chain.get_state(token)).substates[state_name].event_order
  420. ) == len(exp_event_order)
  421. await AppHarness._poll_for_async(_has_all_events)
  422. backend_state = (await event_chain.get_state(token)).substates[state_name]
  423. assert backend_state.event_order == exp_event_order
  424. assert backend_state.is_hydrated is True
  425. @pytest.mark.parametrize(
  426. ("uri", "exp_event_order"),
  427. [
  428. (
  429. "/on-mount-return-chain",
  430. [
  431. "on_load_return_chain",
  432. "event_arg:1",
  433. "event_arg:2",
  434. "event_arg:3",
  435. "event_arg:unmount",
  436. ],
  437. ),
  438. (
  439. "/on-mount-yield-chain",
  440. [
  441. "on_load_yield_chain",
  442. "event_arg:mount",
  443. "event_arg:4",
  444. "event_arg:5",
  445. "event_arg:6",
  446. "event_no_args",
  447. ],
  448. ),
  449. ],
  450. )
  451. @pytest.mark.asyncio
  452. async def test_event_chain_on_mount(
  453. event_chain: AppHarness,
  454. driver: WebDriver,
  455. uri: str,
  456. exp_event_order: list[str],
  457. ):
  458. """Load the URI, assert that the events are handled in the correct order.
  459. These pages use `on_mount` and `on_unmount`, which get fired twice in dev mode
  460. due to react StrictMode being used.
  461. In prod mode, these events are only fired once.
  462. Args:
  463. event_chain: AppHarness for the event_chain app
  464. driver: selenium WebDriver open to the app
  465. uri: the page to load
  466. exp_event_order: the expected events recorded in the State
  467. """
  468. assert event_chain.frontend_url is not None
  469. driver.get(event_chain.frontend_url + uri)
  470. token = assert_token(event_chain, driver)
  471. state_name = event_chain.get_state_name("_state")
  472. unmount_button = driver.find_element(By.ID, "unmount")
  473. assert unmount_button
  474. unmount_button.click()
  475. async def _has_all_events():
  476. return len(
  477. (await event_chain.get_state(token)).substates[state_name].event_order
  478. ) == len(exp_event_order)
  479. await AppHarness._poll_for_async(_has_all_events)
  480. event_order = (await event_chain.get_state(token)).substates[state_name].event_order
  481. assert event_order == exp_event_order
  482. @pytest.mark.parametrize(
  483. ("button_id",),
  484. [
  485. ("click_yield_interim_value_async",),
  486. ("click_yield_interim_value",),
  487. ],
  488. )
  489. def test_yield_state_update(event_chain: AppHarness, driver: WebDriver, button_id: str):
  490. """Click the button, assert that the interim value is set, then final value is set.
  491. Args:
  492. event_chain: AppHarness for the event_chain app
  493. driver: selenium WebDriver open to the app
  494. button_id: the ID of the button to click
  495. """
  496. interim_value_input = driver.find_element(By.ID, "interim_value")
  497. assert_token(event_chain, driver)
  498. btn = driver.find_element(By.ID, button_id)
  499. btn.click()
  500. assert (
  501. event_chain.poll_for_value(interim_value_input, exp_not_equal="") == "interim"
  502. )
  503. assert (
  504. event_chain.poll_for_value(interim_value_input, exp_not_equal="interim")
  505. == "final"
  506. )