test_event_chain.py 18 KB

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