test_event_chain.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. """Ensure that Event Chains are properly queued and handled between frontend and backend."""
  2. from __future__ import annotations
  3. from collections.abc 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 = event_chain.poll_for_result(
  265. lambda: driver.find_element(By.ID, "token")
  266. )
  267. assert token_input
  268. # wait for the backend connection to send the token
  269. token = event_chain.poll_for_value(token_input)
  270. assert token is not None
  271. state_name = event_chain.get_full_state_name(["_state"])
  272. return f"{token}_{state_name}"
  273. @pytest.mark.parametrize(
  274. ("button_id", "exp_event_order"),
  275. [
  276. ("return_event", ["click_return_event", "event_no_args"]),
  277. (
  278. "return_events",
  279. ["click_return_events", "event_arg:7", "event_arg:8", "event_arg:9"],
  280. ),
  281. (
  282. "yield_chain",
  283. [
  284. "click_yield_chain:0",
  285. "click_yield_chain:1",
  286. "click_yield_chain:2",
  287. "click_yield_chain:3",
  288. "event_arg:10",
  289. "event_arg:11",
  290. "event_arg:12",
  291. ],
  292. ),
  293. (
  294. "yield_many_events",
  295. [
  296. "click_yield_many_events",
  297. "click_yield_many_events_done",
  298. *[f"event_arg:{ix}" for ix in range(MANY_EVENTS)],
  299. ],
  300. ),
  301. (
  302. "yield_nested",
  303. [
  304. "click_yield_nested",
  305. "event_nested_1",
  306. "event_arg:yield_nested",
  307. "event_nested_2",
  308. "event_arg:nested_1",
  309. "event_nested_3",
  310. "event_arg:nested_2",
  311. "event_no_args",
  312. "event_arg:nested_3",
  313. ],
  314. ),
  315. (
  316. "redirect_return_chain",
  317. [
  318. "redirect_return_chain",
  319. "on_load_return_chain",
  320. "event_arg:1",
  321. "event_arg:2",
  322. "event_arg:3",
  323. ],
  324. ),
  325. (
  326. "redirect_yield_chain",
  327. [
  328. "redirect_yield_chain",
  329. "on_load_yield_chain",
  330. "event_arg:4",
  331. "event_arg:5",
  332. "event_arg:6",
  333. ],
  334. ),
  335. (
  336. "click_int_type",
  337. ["event_arg_repr:1_int"],
  338. ),
  339. (
  340. "click_dict_type",
  341. ["event_arg_repr:{'a': 1}_dict"],
  342. ),
  343. (
  344. "return_int_type",
  345. ["click_return_int_type", "event_arg_repr:1_int"],
  346. ),
  347. (
  348. "return_dict_type",
  349. ["click_return_dict_type", "event_arg_repr:{'a': 1}_dict"],
  350. ),
  351. ],
  352. )
  353. @pytest.mark.asyncio
  354. async def test_event_chain_click(
  355. event_chain: AppHarness,
  356. driver: WebDriver,
  357. button_id: str,
  358. exp_event_order: list[str],
  359. ):
  360. """Click the button, assert that the events are handled in the correct order.
  361. Args:
  362. event_chain: AppHarness for the event_chain app
  363. driver: selenium WebDriver open to the app
  364. button_id: the ID of the button to click
  365. exp_event_order: the expected events recorded in the State
  366. """
  367. token = assert_token(event_chain, driver)
  368. state_name = event_chain.get_state_name("_state")
  369. btn = driver.find_element(By.ID, button_id)
  370. btn.click()
  371. async def _has_all_events():
  372. return len(
  373. (await event_chain.get_state(token)).substates[state_name].event_order
  374. ) == len(exp_event_order)
  375. await AppHarness._poll_for_async(_has_all_events)
  376. event_order = (await event_chain.get_state(token)).substates[state_name].event_order
  377. assert event_order == exp_event_order
  378. @pytest.mark.parametrize(
  379. ("uri", "exp_event_order"),
  380. [
  381. (
  382. "/on-load-return-chain",
  383. [
  384. "on_load_return_chain",
  385. "event_arg:1",
  386. "event_arg:2",
  387. "event_arg:3",
  388. ],
  389. ),
  390. (
  391. "/on-load-yield-chain",
  392. [
  393. "on_load_yield_chain",
  394. "event_arg:4",
  395. "event_arg:5",
  396. "event_arg:6",
  397. ],
  398. ),
  399. ],
  400. )
  401. @pytest.mark.asyncio
  402. async def test_event_chain_on_load(
  403. event_chain: AppHarness,
  404. driver: WebDriver,
  405. uri: str,
  406. exp_event_order: list[str],
  407. ):
  408. """Load the URI, assert that the events are handled in the correct order.
  409. Args:
  410. event_chain: AppHarness for the event_chain app
  411. driver: selenium WebDriver open to the app
  412. uri: the page to load
  413. exp_event_order: the expected events recorded in the State
  414. """
  415. assert event_chain.frontend_url is not None
  416. driver.get(event_chain.frontend_url + uri)
  417. token = assert_token(event_chain, driver)
  418. state_name = event_chain.get_state_name("_state")
  419. async def _has_all_events():
  420. return len(
  421. (await event_chain.get_state(token)).substates[state_name].event_order
  422. ) == len(exp_event_order)
  423. await AppHarness._poll_for_async(_has_all_events)
  424. backend_state = (await event_chain.get_state(token)).substates[state_name]
  425. assert backend_state.event_order == exp_event_order
  426. assert backend_state.is_hydrated is True
  427. @pytest.mark.parametrize(
  428. ("uri", "exp_event_order"),
  429. [
  430. (
  431. "/on-mount-return-chain",
  432. [
  433. "on_load_return_chain",
  434. "event_arg:1",
  435. "event_arg:2",
  436. "event_arg:3",
  437. "event_arg:unmount",
  438. ],
  439. ),
  440. (
  441. "/on-mount-yield-chain",
  442. [
  443. "on_load_yield_chain",
  444. "event_arg:mount",
  445. "event_arg:4",
  446. "event_arg:5",
  447. "event_arg:6",
  448. "event_no_args",
  449. ],
  450. ),
  451. ],
  452. )
  453. @pytest.mark.asyncio
  454. async def test_event_chain_on_mount(
  455. event_chain: AppHarness,
  456. driver: WebDriver,
  457. uri: str,
  458. exp_event_order: list[str],
  459. ):
  460. """Load the URI, assert that the events are handled in the correct order.
  461. These pages use `on_mount` and `on_unmount`, which get fired twice in dev mode
  462. due to react StrictMode being used.
  463. In prod mode, these events are only fired once.
  464. Args:
  465. event_chain: AppHarness for the event_chain app
  466. driver: selenium WebDriver open to the app
  467. uri: the page to load
  468. exp_event_order: the expected events recorded in the State
  469. """
  470. assert event_chain.frontend_url is not None
  471. driver.get(event_chain.frontend_url + uri)
  472. token = assert_token(event_chain, driver)
  473. state_name = event_chain.get_state_name("_state")
  474. unmount_button = driver.find_element(By.ID, "unmount")
  475. assert unmount_button
  476. unmount_button.click()
  477. async def _has_all_events():
  478. return len(
  479. (await event_chain.get_state(token)).substates[state_name].event_order
  480. ) == len(exp_event_order)
  481. await AppHarness._poll_for_async(_has_all_events)
  482. event_order = (await event_chain.get_state(token)).substates[state_name].event_order
  483. assert event_order == exp_event_order
  484. @pytest.mark.parametrize(
  485. ("button_id",),
  486. [
  487. ("click_yield_interim_value_async",),
  488. ("click_yield_interim_value",),
  489. ],
  490. )
  491. def test_yield_state_update(event_chain: AppHarness, driver: WebDriver, button_id: str):
  492. """Click the button, assert that the interim value is set, then final value is set.
  493. Args:
  494. event_chain: AppHarness for the event_chain app
  495. driver: selenium WebDriver open to the app
  496. button_id: the ID of the button to click
  497. """
  498. assert_token(event_chain, driver)
  499. interim_value_input = driver.find_element(By.ID, "interim_value")
  500. btn = driver.find_element(By.ID, button_id)
  501. btn.click()
  502. assert (
  503. event_chain.poll_for_value(interim_value_input, exp_not_equal="") == "interim"
  504. )
  505. assert (
  506. event_chain.poll_for_value(interim_value_input, exp_not_equal="interim")
  507. == "final"
  508. )