test_event_chain.py 18 KB

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