test_event_chain.py 15 KB

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