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