test_event_chain.py 17 KB

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