test_client_storage.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. """Integration tests for client side storage."""
  2. from __future__ import annotations
  3. import time
  4. from typing import Generator
  5. import pytest
  6. from selenium.webdriver import Firefox
  7. from selenium.webdriver.common.by import By
  8. from selenium.webdriver.remote.webdriver import WebDriver
  9. from reflex.testing import AppHarness
  10. from . import utils
  11. def ClientSide():
  12. """App for testing client-side state."""
  13. import reflex as rx
  14. class ClientSideState(rx.State):
  15. state_var: str = ""
  16. input_value: str = ""
  17. class ClientSideSubState(ClientSideState):
  18. # cookies with default settings
  19. c1: str = rx.Cookie()
  20. c2: rx.Cookie = "c2 default" # type: ignore
  21. # cookies with custom settings
  22. c3: str = rx.Cookie(max_age=2) # expires after 2 second
  23. c4: rx.Cookie = rx.Cookie(same_site="strict")
  24. c5: str = rx.Cookie(path="/foo/") # only accessible on `/foo/`
  25. c6: str = rx.Cookie(name="c6")
  26. c7: str = rx.Cookie("c7 default")
  27. # local storage with default settings
  28. l1: str = rx.LocalStorage()
  29. l2: rx.LocalStorage = "l2 default" # type: ignore
  30. # local storage with custom settings
  31. l3: str = rx.LocalStorage(name="l3")
  32. l4: str = rx.LocalStorage("l4 default")
  33. # Sync'd local storage
  34. l5: str = rx.LocalStorage(sync=True)
  35. l6: str = rx.LocalStorage(sync=True, name="l6")
  36. def set_l6(self, my_param: str):
  37. self.l6 = my_param
  38. def set_var(self):
  39. setattr(self, self.state_var, self.input_value)
  40. self.state_var = self.input_value = ""
  41. class ClientSideSubSubState(ClientSideSubState):
  42. c1s: str = rx.Cookie()
  43. l1s: str = rx.LocalStorage()
  44. def set_var(self):
  45. setattr(self, self.state_var, self.input_value)
  46. self.state_var = self.input_value = ""
  47. def index():
  48. return rx.fragment(
  49. rx.chakra.input(
  50. value=ClientSideState.router.session.client_token,
  51. is_read_only=True,
  52. id="token",
  53. ),
  54. rx.chakra.input(
  55. placeholder="state var",
  56. value=ClientSideState.state_var,
  57. on_change=ClientSideState.set_state_var, # type: ignore
  58. id="state_var",
  59. ),
  60. rx.chakra.input(
  61. placeholder="input value",
  62. value=ClientSideState.input_value,
  63. on_change=ClientSideState.set_input_value, # type: ignore
  64. id="input_value",
  65. ),
  66. rx.button(
  67. "Set ClientSideSubState",
  68. on_click=ClientSideSubState.set_var,
  69. id="set_sub_state",
  70. ),
  71. rx.button(
  72. "Set ClientSideSubSubState",
  73. on_click=ClientSideSubSubState.set_var,
  74. id="set_sub_sub_state",
  75. ),
  76. rx.box(ClientSideSubState.c1, id="c1"),
  77. rx.box(ClientSideSubState.c2, id="c2"),
  78. rx.box(ClientSideSubState.c3, id="c3"),
  79. rx.box(ClientSideSubState.c4, id="c4"),
  80. rx.box(ClientSideSubState.c5, id="c5"),
  81. rx.box(ClientSideSubState.c6, id="c6"),
  82. rx.box(ClientSideSubState.c7, id="c7"),
  83. rx.box(ClientSideSubState.l1, id="l1"),
  84. rx.box(ClientSideSubState.l2, id="l2"),
  85. rx.box(ClientSideSubState.l3, id="l3"),
  86. rx.box(ClientSideSubState.l4, id="l4"),
  87. rx.box(ClientSideSubState.l5, id="l5"),
  88. rx.box(ClientSideSubState.l6, id="l6"),
  89. rx.box(ClientSideSubSubState.c1s, id="c1s"),
  90. rx.box(ClientSideSubSubState.l1s, id="l1s"),
  91. )
  92. app = rx.App(state=rx.State)
  93. app.add_page(index)
  94. app.add_page(index, route="/foo")
  95. @pytest.fixture(scope="module")
  96. def client_side(tmp_path_factory) -> Generator[AppHarness, None, None]:
  97. """Start ClientSide app at tmp_path via AppHarness.
  98. Args:
  99. tmp_path_factory: pytest tmp_path_factory fixture
  100. Yields:
  101. running AppHarness instance
  102. """
  103. with AppHarness.create(
  104. root=tmp_path_factory.mktemp("client_side"),
  105. app_source=ClientSide, # type: ignore
  106. ) as harness:
  107. yield harness
  108. @pytest.fixture
  109. def driver(client_side: AppHarness) -> Generator[WebDriver, None, None]:
  110. """Get an instance of the browser open to the client_side app.
  111. Args:
  112. client_side: harness for ClientSide app
  113. Yields:
  114. WebDriver instance.
  115. """
  116. assert client_side.app_instance is not None, "app is not running"
  117. driver = client_side.frontend()
  118. try:
  119. yield driver
  120. finally:
  121. driver.quit()
  122. @pytest.fixture()
  123. def local_storage(driver: WebDriver) -> Generator[utils.LocalStorage, None, None]:
  124. """Get an instance of the local storage helper.
  125. Args:
  126. driver: WebDriver instance.
  127. Yields:
  128. Local storage helper.
  129. """
  130. ls = utils.LocalStorage(driver)
  131. yield ls
  132. ls.clear()
  133. @pytest.fixture(autouse=True)
  134. def delete_all_cookies(driver: WebDriver) -> Generator[None, None, None]:
  135. """Delete all cookies after each test.
  136. Args:
  137. driver: WebDriver instance.
  138. Yields:
  139. None
  140. """
  141. yield
  142. driver.delete_all_cookies()
  143. def cookie_info_map(driver: WebDriver) -> dict[str, dict[str, str]]:
  144. """Get a map of cookie names to cookie info.
  145. Args:
  146. driver: WebDriver instance.
  147. Returns:
  148. A map of cookie names to cookie info.
  149. """
  150. return {cookie_info["name"]: cookie_info for cookie_info in driver.get_cookies()}
  151. @pytest.mark.asyncio
  152. async def test_client_side_state(
  153. client_side: AppHarness, driver: WebDriver, local_storage: utils.LocalStorage
  154. ):
  155. """Test client side state.
  156. Args:
  157. client_side: harness for ClientSide app.
  158. driver: WebDriver instance.
  159. local_storage: Local storage helper.
  160. """
  161. assert client_side.app_instance is not None
  162. assert client_side.frontend_url is not None
  163. def poll_for_token():
  164. token_input = driver.find_element(By.ID, "token")
  165. assert token_input
  166. # wait for the backend connection to send the token
  167. token = client_side.poll_for_value(token_input)
  168. assert token is not None
  169. return token
  170. def set_sub(var: str, value: str):
  171. # Get a reference to the cookie manipulation form.
  172. state_var_input = driver.find_element(By.ID, "state_var")
  173. input_value_input = driver.find_element(By.ID, "input_value")
  174. set_sub_state_button = driver.find_element(By.ID, "set_sub_state")
  175. AppHarness._poll_for(lambda: state_var_input.get_attribute("value") == "")
  176. AppHarness._poll_for(lambda: input_value_input.get_attribute("value") == "")
  177. # Set the values.
  178. state_var_input.send_keys(var)
  179. input_value_input.send_keys(value)
  180. set_sub_state_button.click()
  181. def set_sub_sub(var: str, value: str):
  182. # Get a reference to the cookie manipulation form.
  183. state_var_input = driver.find_element(By.ID, "state_var")
  184. input_value_input = driver.find_element(By.ID, "input_value")
  185. set_sub_sub_state_button = driver.find_element(By.ID, "set_sub_sub_state")
  186. AppHarness._poll_for(lambda: state_var_input.get_attribute("value") == "")
  187. AppHarness._poll_for(lambda: input_value_input.get_attribute("value") == "")
  188. # Set the values.
  189. state_var_input.send_keys(var)
  190. input_value_input.send_keys(value)
  191. set_sub_sub_state_button.click()
  192. token = poll_for_token()
  193. # get a reference to all cookie and local storage elements
  194. c1 = driver.find_element(By.ID, "c1")
  195. c2 = driver.find_element(By.ID, "c2")
  196. c3 = driver.find_element(By.ID, "c3")
  197. c4 = driver.find_element(By.ID, "c4")
  198. c5 = driver.find_element(By.ID, "c5")
  199. c6 = driver.find_element(By.ID, "c6")
  200. c7 = driver.find_element(By.ID, "c7")
  201. l1 = driver.find_element(By.ID, "l1")
  202. l2 = driver.find_element(By.ID, "l2")
  203. l3 = driver.find_element(By.ID, "l3")
  204. l4 = driver.find_element(By.ID, "l4")
  205. c1s = driver.find_element(By.ID, "c1s")
  206. l1s = driver.find_element(By.ID, "l1s")
  207. # assert on defaults where present
  208. assert c1.text == ""
  209. assert c2.text == "c2 default"
  210. assert c3.text == ""
  211. assert c4.text == ""
  212. assert c5.text == ""
  213. assert c6.text == ""
  214. assert c7.text == "c7 default"
  215. assert l1.text == ""
  216. assert l2.text == "l2 default"
  217. assert l3.text == ""
  218. assert l4.text == "l4 default"
  219. assert c1s.text == ""
  220. assert l1s.text == ""
  221. # no cookies should be set yet!
  222. assert not driver.get_cookies()
  223. local_storage_items = local_storage.items()
  224. local_storage_items.pop("chakra-ui-color-mode", None)
  225. assert not local_storage_items
  226. # set some cookies and local storage values
  227. set_sub("c1", "c1 value")
  228. set_sub("c2", "c2 value")
  229. set_sub("c4", "c4 value")
  230. set_sub("c5", "c5 value")
  231. set_sub("c6", "c6 throwaway value")
  232. set_sub("c6", "c6 value")
  233. set_sub("c7", "c7 value")
  234. set_sub("l1", "l1 value")
  235. set_sub("l2", "l2 value")
  236. set_sub("l3", "l3 value")
  237. set_sub("l4", "l4 value")
  238. set_sub_sub("c1s", "c1s value")
  239. set_sub_sub("l1s", "l1s value")
  240. exp_cookies = {
  241. "state.client_side_state.client_side_sub_state.c1": {
  242. "domain": "localhost",
  243. "httpOnly": False,
  244. "name": "state.client_side_state.client_side_sub_state.c1",
  245. "path": "/",
  246. "sameSite": "Lax",
  247. "secure": False,
  248. "value": "c1%20value",
  249. },
  250. "state.client_side_state.client_side_sub_state.c2": {
  251. "domain": "localhost",
  252. "httpOnly": False,
  253. "name": "state.client_side_state.client_side_sub_state.c2",
  254. "path": "/",
  255. "sameSite": "Lax",
  256. "secure": False,
  257. "value": "c2%20value",
  258. },
  259. "state.client_side_state.client_side_sub_state.c4": {
  260. "domain": "localhost",
  261. "httpOnly": False,
  262. "name": "state.client_side_state.client_side_sub_state.c4",
  263. "path": "/",
  264. "sameSite": "Strict",
  265. "secure": False,
  266. "value": "c4%20value",
  267. },
  268. "c6": {
  269. "domain": "localhost",
  270. "httpOnly": False,
  271. "name": "c6",
  272. "path": "/",
  273. "sameSite": "Lax",
  274. "secure": False,
  275. "value": "c6%20value",
  276. },
  277. "state.client_side_state.client_side_sub_state.c7": {
  278. "domain": "localhost",
  279. "httpOnly": False,
  280. "name": "state.client_side_state.client_side_sub_state.c7",
  281. "path": "/",
  282. "sameSite": "Lax",
  283. "secure": False,
  284. "value": "c7%20value",
  285. },
  286. "state.client_side_state.client_side_sub_state.client_side_sub_sub_state.c1s": {
  287. "domain": "localhost",
  288. "httpOnly": False,
  289. "name": "state.client_side_state.client_side_sub_state.client_side_sub_sub_state.c1s",
  290. "path": "/",
  291. "sameSite": "Lax",
  292. "secure": False,
  293. "value": "c1s%20value",
  294. },
  295. }
  296. AppHarness._poll_for(
  297. lambda: all(cookie_key in cookie_info_map(driver) for cookie_key in exp_cookies)
  298. )
  299. cookies = cookie_info_map(driver)
  300. for exp_cookie_key, exp_cookie_data in exp_cookies.items():
  301. assert cookies.pop(exp_cookie_key) == exp_cookie_data
  302. # assert all cookies have been popped for this page
  303. assert not cookies
  304. # Test cookie with expiry by itself to avoid timing flakiness
  305. set_sub("c3", "c3 value")
  306. AppHarness._poll_for(
  307. lambda: "state.client_side_state.client_side_sub_state.c3"
  308. in cookie_info_map(driver)
  309. )
  310. c3_cookie = cookie_info_map(driver)[
  311. "state.client_side_state.client_side_sub_state.c3"
  312. ]
  313. assert c3_cookie.pop("expiry") is not None
  314. assert c3_cookie == {
  315. "domain": "localhost",
  316. "httpOnly": False,
  317. "name": "state.client_side_state.client_side_sub_state.c3",
  318. "path": "/",
  319. "sameSite": "Lax",
  320. "secure": False,
  321. "value": "c3%20value",
  322. }
  323. time.sleep(2) # wait for c3 to expire
  324. if not isinstance(driver, Firefox):
  325. # Note: Firefox does not remove expired cookies Bug 576347
  326. assert (
  327. "state.client_side_state.client_side_sub_state.c3"
  328. not in cookie_info_map(driver)
  329. )
  330. local_storage_items = local_storage.items()
  331. local_storage_items.pop("chakra-ui-color-mode", None)
  332. assert (
  333. local_storage_items.pop("state.client_side_state.client_side_sub_state.l1")
  334. == "l1 value"
  335. )
  336. assert (
  337. local_storage_items.pop("state.client_side_state.client_side_sub_state.l2")
  338. == "l2 value"
  339. )
  340. assert local_storage_items.pop("l3") == "l3 value"
  341. assert (
  342. local_storage_items.pop("state.client_side_state.client_side_sub_state.l4")
  343. == "l4 value"
  344. )
  345. assert (
  346. local_storage_items.pop(
  347. "state.client_side_state.client_side_sub_state.client_side_sub_sub_state.l1s"
  348. )
  349. == "l1s value"
  350. )
  351. assert not local_storage_items
  352. assert c1.text == "c1 value"
  353. assert c2.text == "c2 value"
  354. assert c3.text == "c3 value"
  355. assert c4.text == "c4 value"
  356. assert c5.text == "c5 value"
  357. assert c6.text == "c6 value"
  358. assert c7.text == "c7 value"
  359. assert l1.text == "l1 value"
  360. assert l2.text == "l2 value"
  361. assert l3.text == "l3 value"
  362. assert l4.text == "l4 value"
  363. assert c1s.text == "c1s value"
  364. assert l1s.text == "l1s value"
  365. # navigate to the /foo route
  366. with utils.poll_for_navigation(driver):
  367. driver.get(client_side.frontend_url + "/foo")
  368. # get new references to all cookie and local storage elements
  369. c1 = driver.find_element(By.ID, "c1")
  370. c2 = driver.find_element(By.ID, "c2")
  371. c3 = driver.find_element(By.ID, "c3")
  372. c4 = driver.find_element(By.ID, "c4")
  373. c5 = driver.find_element(By.ID, "c5")
  374. c6 = driver.find_element(By.ID, "c6")
  375. c7 = driver.find_element(By.ID, "c7")
  376. l1 = driver.find_element(By.ID, "l1")
  377. l2 = driver.find_element(By.ID, "l2")
  378. l3 = driver.find_element(By.ID, "l3")
  379. l4 = driver.find_element(By.ID, "l4")
  380. c1s = driver.find_element(By.ID, "c1s")
  381. l1s = driver.find_element(By.ID, "l1s")
  382. assert c1.text == "c1 value"
  383. assert c2.text == "c2 value"
  384. assert c3.text == "" # cookie expired so value removed from state
  385. assert c4.text == "c4 value"
  386. assert c5.text == "c5 value"
  387. assert c6.text == "c6 value"
  388. assert c7.text == "c7 value"
  389. assert l1.text == "l1 value"
  390. assert l2.text == "l2 value"
  391. assert l3.text == "l3 value"
  392. assert l4.text == "l4 value"
  393. assert c1s.text == "c1s value"
  394. assert l1s.text == "l1s value"
  395. # reset the backend state to force refresh from client storage
  396. async with client_side.modify_state(f"{token}_state.client_side_state") as state:
  397. state.reset()
  398. driver.refresh()
  399. # wait for the backend connection to send the token (again)
  400. token_input = driver.find_element(By.ID, "token")
  401. assert token_input
  402. token = client_side.poll_for_value(token_input)
  403. assert token is not None
  404. # get new references to all cookie and local storage elements (again)
  405. c1 = driver.find_element(By.ID, "c1")
  406. c2 = driver.find_element(By.ID, "c2")
  407. c3 = driver.find_element(By.ID, "c3")
  408. c4 = driver.find_element(By.ID, "c4")
  409. c5 = driver.find_element(By.ID, "c5")
  410. c6 = driver.find_element(By.ID, "c6")
  411. c7 = driver.find_element(By.ID, "c7")
  412. l1 = driver.find_element(By.ID, "l1")
  413. l2 = driver.find_element(By.ID, "l2")
  414. l3 = driver.find_element(By.ID, "l3")
  415. l4 = driver.find_element(By.ID, "l4")
  416. c1s = driver.find_element(By.ID, "c1s")
  417. l1s = driver.find_element(By.ID, "l1s")
  418. assert c1.text == "c1 value"
  419. assert c2.text == "c2 value"
  420. assert c3.text == "" # temporary cookie expired after reset state!
  421. assert c4.text == "c4 value"
  422. assert c5.text == "c5 value"
  423. assert c6.text == "c6 value"
  424. assert c7.text == "c7 value"
  425. assert l1.text == "l1 value"
  426. assert l2.text == "l2 value"
  427. assert l3.text == "l3 value"
  428. assert l4.text == "l4 value"
  429. assert c1s.text == "c1s value"
  430. assert l1s.text == "l1s value"
  431. # make sure c5 cookie shows up on the `/foo` route
  432. AppHarness._poll_for(
  433. lambda: "state.client_side_state.client_side_sub_state.c5"
  434. in cookie_info_map(driver)
  435. )
  436. assert cookie_info_map(driver)[
  437. "state.client_side_state.client_side_sub_state.c5"
  438. ] == {
  439. "domain": "localhost",
  440. "httpOnly": False,
  441. "name": "state.client_side_state.client_side_sub_state.c5",
  442. "path": "/foo/",
  443. "sameSite": "Lax",
  444. "secure": False,
  445. "value": "c5%20value",
  446. }
  447. # Open a new tab to check that sync'd local storage is working
  448. main_tab = driver.window_handles[0]
  449. driver.switch_to.new_window("window")
  450. driver.get(client_side.frontend_url)
  451. # New tab should have a different state token.
  452. assert poll_for_token() != token
  453. # Set values and check them in the new tab.
  454. set_sub("l5", "l5 value")
  455. set_sub("l6", "l6 value")
  456. l5 = driver.find_element(By.ID, "l5")
  457. l6 = driver.find_element(By.ID, "l6")
  458. assert AppHarness._poll_for(lambda: l6.text == "l6 value")
  459. assert l5.text == "l5 value"
  460. # Switch back to main window.
  461. driver.switch_to.window(main_tab)
  462. # The values should have updated automatically.
  463. l5 = driver.find_element(By.ID, "l5")
  464. l6 = driver.find_element(By.ID, "l6")
  465. assert AppHarness._poll_for(lambda: l6.text == "l6 value")
  466. assert l5.text == "l5 value"
  467. # clear the cookie jar and local storage, ensure state reset to default
  468. driver.delete_all_cookies()
  469. local_storage.clear()
  470. # refresh the page to trigger re-hydrate
  471. driver.refresh()
  472. # wait for the backend connection to send the token (again)
  473. token_input = driver.find_element(By.ID, "token")
  474. assert token_input
  475. token = client_side.poll_for_value(token_input)
  476. assert token is not None
  477. # all values should be back to their defaults
  478. c1 = driver.find_element(By.ID, "c1")
  479. c2 = driver.find_element(By.ID, "c2")
  480. c3 = driver.find_element(By.ID, "c3")
  481. c4 = driver.find_element(By.ID, "c4")
  482. c5 = driver.find_element(By.ID, "c5")
  483. c6 = driver.find_element(By.ID, "c6")
  484. c7 = driver.find_element(By.ID, "c7")
  485. l1 = driver.find_element(By.ID, "l1")
  486. l2 = driver.find_element(By.ID, "l2")
  487. l3 = driver.find_element(By.ID, "l3")
  488. l4 = driver.find_element(By.ID, "l4")
  489. c1s = driver.find_element(By.ID, "c1s")
  490. l1s = driver.find_element(By.ID, "l1s")
  491. # assert on defaults where present
  492. assert c1.text == ""
  493. assert c2.text == "c2 default"
  494. assert c3.text == ""
  495. assert c4.text == ""
  496. assert c5.text == ""
  497. assert c6.text == ""
  498. assert c7.text == "c7 default"
  499. assert l1.text == ""
  500. assert l2.text == "l2 default"
  501. assert l3.text == ""
  502. assert l4.text == "l4 default"
  503. assert c1s.text == ""
  504. assert l1s.text == ""