test_upload.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. """Integration tests for file upload."""
  2. from __future__ import annotations
  3. import asyncio
  4. import time
  5. from pathlib import Path
  6. from typing import Generator
  7. from urllib.parse import urlsplit
  8. import pytest
  9. from selenium.webdriver.common.by import By
  10. from reflex.constants.event import Endpoint
  11. from reflex.testing import AppHarness, WebDriver
  12. from .utils import poll_for_navigation
  13. def UploadFile():
  14. """App for testing dynamic routes."""
  15. from typing import Dict, List
  16. import reflex as rx
  17. LARGE_DATA = "DUMMY" * 1024 * 512
  18. class UploadState(rx.State):
  19. _file_data: Dict[str, str] = {}
  20. event_order: rx.Field[List[str]] = rx.field([])
  21. progress_dicts: List[dict] = []
  22. disabled: bool = False
  23. large_data: str = ""
  24. async def handle_upload(self, files: List[rx.UploadFile]):
  25. for file in files:
  26. upload_data = await file.read()
  27. self._file_data[file.filename or ""] = upload_data.decode("utf-8")
  28. async def handle_upload_secondary(self, files: List[rx.UploadFile]):
  29. for file in files:
  30. upload_data = await file.read()
  31. self._file_data[file.filename or ""] = upload_data.decode("utf-8")
  32. self.large_data = LARGE_DATA
  33. yield UploadState.chain_event
  34. def upload_progress(self, progress):
  35. assert progress
  36. self.event_order.append("upload_progress")
  37. self.progress_dicts.append(progress)
  38. def chain_event(self):
  39. assert self.large_data == LARGE_DATA
  40. self.large_data = ""
  41. self.event_order.append("chain_event")
  42. @rx.event
  43. async def handle_upload_tertiary(self, files: List[rx.UploadFile]):
  44. for file in files:
  45. (rx.get_upload_dir() / (file.filename or "INVALID")).write_bytes(
  46. await file.read()
  47. )
  48. @rx.event
  49. def do_download(self):
  50. return rx.download(rx.get_upload_url("test.txt"))
  51. def index():
  52. return rx.vstack(
  53. rx.input(
  54. value=UploadState.router.session.client_token,
  55. read_only=True,
  56. id="token",
  57. ),
  58. rx.heading("Default Upload"),
  59. rx.upload.root(
  60. rx.vstack(
  61. rx.button("Select File"),
  62. rx.text("Drag and drop files here or click to select files"),
  63. ),
  64. disabled=UploadState.disabled,
  65. ),
  66. rx.button(
  67. "Upload",
  68. on_click=lambda: UploadState.handle_upload(rx.upload_files()), # pyright: ignore [reportCallIssue]
  69. id="upload_button",
  70. ),
  71. rx.box(
  72. rx.foreach(
  73. rx.selected_files(),
  74. lambda f: rx.text(f, as_="p"),
  75. ),
  76. id="selected_files",
  77. ),
  78. rx.button(
  79. "Clear",
  80. on_click=rx.clear_selected_files,
  81. id="clear_button",
  82. ),
  83. rx.heading("Secondary Upload"),
  84. rx.upload.root(
  85. rx.vstack(
  86. rx.button("Select File"),
  87. rx.text("Drag and drop files here or click to select files"),
  88. ),
  89. id="secondary",
  90. ),
  91. rx.button(
  92. "Upload",
  93. on_click=UploadState.handle_upload_secondary( # pyright: ignore [reportCallIssue]
  94. rx.upload_files(
  95. upload_id="secondary",
  96. on_upload_progress=UploadState.upload_progress,
  97. ),
  98. ),
  99. id="upload_button_secondary",
  100. ),
  101. rx.box(
  102. rx.foreach(
  103. rx.selected_files("secondary"),
  104. lambda f: rx.text(f, as_="p"),
  105. ),
  106. id="selected_files_secondary",
  107. ),
  108. rx.button(
  109. "Clear",
  110. on_click=rx.clear_selected_files("secondary"),
  111. id="clear_button_secondary",
  112. ),
  113. rx.vstack(
  114. rx.foreach(
  115. UploadState.progress_dicts,
  116. lambda d: rx.text(d.to_string()),
  117. )
  118. ),
  119. rx.button(
  120. "Cancel",
  121. on_click=rx.cancel_upload("secondary"),
  122. id="cancel_button_secondary",
  123. ),
  124. rx.heading("Tertiary Upload/Download"),
  125. rx.upload.root(
  126. rx.vstack(
  127. rx.button("Select File"),
  128. rx.text("Drag and drop files here or click to select files"),
  129. ),
  130. id="tertiary",
  131. ),
  132. rx.button(
  133. "Upload",
  134. on_click=UploadState.handle_upload_tertiary(
  135. rx.upload_files( # pyright: ignore [reportArgumentType]
  136. upload_id="tertiary",
  137. ),
  138. ),
  139. id="upload_button_tertiary",
  140. ),
  141. rx.button(
  142. "Download - Frontend",
  143. on_click=rx.download(rx.get_upload_url("test.txt")),
  144. id="download-frontend",
  145. ),
  146. rx.button(
  147. "Download - Backend",
  148. on_click=UploadState.do_download,
  149. id="download-backend",
  150. ),
  151. rx.text(UploadState.event_order.to_string(), id="event-order"),
  152. )
  153. app = rx.App(_state=rx.State)
  154. app.add_page(index)
  155. @pytest.fixture(scope="module")
  156. def upload_file(tmp_path_factory) -> Generator[AppHarness, None, None]:
  157. """Start UploadFile app at tmp_path via AppHarness.
  158. Args:
  159. tmp_path_factory: pytest tmp_path_factory fixture
  160. Yields:
  161. running AppHarness instance
  162. """
  163. with AppHarness.create(
  164. root=tmp_path_factory.mktemp("upload_file"),
  165. app_source=UploadFile,
  166. ) as harness:
  167. yield harness
  168. @pytest.fixture
  169. def driver(upload_file: AppHarness):
  170. """Get an instance of the browser open to the upload_file app.
  171. Args:
  172. upload_file: harness for DynamicRoute app
  173. Yields:
  174. WebDriver instance.
  175. """
  176. assert upload_file.app_instance is not None, "app is not running"
  177. driver = upload_file.frontend()
  178. try:
  179. yield driver
  180. finally:
  181. driver.quit()
  182. def poll_for_token(driver: WebDriver, upload_file: AppHarness) -> str:
  183. """Poll for the token input to be populated.
  184. Args:
  185. driver: WebDriver instance.
  186. upload_file: harness for UploadFile app.
  187. Returns:
  188. token value
  189. """
  190. token_input = driver.find_element(By.ID, "token")
  191. assert token_input
  192. # wait for the backend connection to send the token
  193. token = upload_file.poll_for_value(token_input)
  194. assert token is not None
  195. return token
  196. @pytest.mark.parametrize("secondary", [False, True])
  197. @pytest.mark.asyncio
  198. async def test_upload_file(
  199. tmp_path, upload_file: AppHarness, driver: WebDriver, secondary: bool
  200. ):
  201. """Submit a file upload and check that it arrived on the backend.
  202. Args:
  203. tmp_path: pytest tmp_path fixture
  204. upload_file: harness for UploadFile app.
  205. driver: WebDriver instance.
  206. secondary: whether to use the secondary upload form
  207. """
  208. assert upload_file.app_instance is not None
  209. token = poll_for_token(driver, upload_file)
  210. full_state_name = upload_file.get_full_state_name(["_upload_state"])
  211. state_name = upload_file.get_state_name("_upload_state")
  212. substate_token = f"{token}_{full_state_name}"
  213. suffix = "_secondary" if secondary else ""
  214. upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[
  215. 1 if secondary else 0
  216. ]
  217. assert upload_box
  218. upload_button = driver.find_element(By.ID, f"upload_button{suffix}")
  219. assert upload_button
  220. exp_name = "test.txt"
  221. exp_contents = "test file contents!"
  222. target_file = tmp_path / exp_name
  223. target_file.write_text(exp_contents)
  224. upload_box.send_keys(str(target_file))
  225. upload_button.click()
  226. # check that the selected files are displayed
  227. selected_files = driver.find_element(By.ID, f"selected_files{suffix}")
  228. assert Path(selected_files.text).name == Path(exp_name).name
  229. if secondary:
  230. event_order_displayed = driver.find_element(By.ID, "event-order")
  231. AppHarness._poll_for(lambda: "chain_event" in event_order_displayed.text)
  232. state = await upload_file.get_state(substate_token)
  233. # only the secondary form tracks progress and chain events
  234. assert state.substates[state_name].event_order.count("upload_progress") == 1
  235. assert state.substates[state_name].event_order.count("chain_event") == 1
  236. # look up the backend state and assert on uploaded contents
  237. async def get_file_data():
  238. return (
  239. (await upload_file.get_state(substate_token))
  240. .substates[state_name]
  241. ._file_data
  242. )
  243. file_data = await AppHarness._poll_for_async(get_file_data)
  244. assert isinstance(file_data, dict)
  245. normalized_file_data = {Path(k).name: v for k, v in file_data.items()}
  246. assert normalized_file_data[Path(exp_name).name] == exp_contents
  247. @pytest.mark.asyncio
  248. async def test_upload_file_multiple(tmp_path, upload_file: AppHarness, driver):
  249. """Submit several file uploads and check that they arrived on the backend.
  250. Args:
  251. tmp_path: pytest tmp_path fixture
  252. upload_file: harness for UploadFile app.
  253. driver: WebDriver instance.
  254. """
  255. assert upload_file.app_instance is not None
  256. token = poll_for_token(driver, upload_file)
  257. full_state_name = upload_file.get_full_state_name(["_upload_state"])
  258. state_name = upload_file.get_state_name("_upload_state")
  259. substate_token = f"{token}_{full_state_name}"
  260. upload_box = driver.find_element(By.XPATH, "//input[@type='file']")
  261. assert upload_box
  262. upload_button = driver.find_element(By.ID, "upload_button")
  263. assert upload_button
  264. exp_files = {
  265. "test1.txt": "test file contents!",
  266. "test2.txt": "this is test file number 2!",
  267. "reflex.txt": "reflex is awesome!",
  268. }
  269. for exp_name, exp_contents in exp_files.items():
  270. target_file = tmp_path / exp_name
  271. target_file.write_text(exp_contents)
  272. upload_box.send_keys(str(target_file))
  273. time.sleep(0.2)
  274. # check that the selected files are displayed
  275. selected_files = driver.find_element(By.ID, "selected_files")
  276. assert [Path(name).name for name in selected_files.text.split("\n")] == [
  277. Path(name).name for name in exp_files
  278. ]
  279. # do the upload
  280. upload_button.click()
  281. # look up the backend state and assert on uploaded contents
  282. async def get_file_data():
  283. return (
  284. (await upload_file.get_state(substate_token))
  285. .substates[state_name]
  286. ._file_data
  287. )
  288. file_data = await AppHarness._poll_for_async(get_file_data)
  289. assert isinstance(file_data, dict)
  290. normalized_file_data = {Path(k).name: v for k, v in file_data.items()}
  291. for exp_name, exp_contents in exp_files.items():
  292. assert normalized_file_data[Path(exp_name).name] == exp_contents
  293. @pytest.mark.parametrize("secondary", [False, True])
  294. def test_clear_files(
  295. tmp_path, upload_file: AppHarness, driver: WebDriver, secondary: bool
  296. ):
  297. """Select then clear several file uploads and check that they are cleared.
  298. Args:
  299. tmp_path: pytest tmp_path fixture
  300. upload_file: harness for UploadFile app.
  301. driver: WebDriver instance.
  302. secondary: whether to use the secondary upload form.
  303. """
  304. assert upload_file.app_instance is not None
  305. poll_for_token(driver, upload_file)
  306. suffix = "_secondary" if secondary else ""
  307. upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[
  308. 1 if secondary else 0
  309. ]
  310. assert upload_box
  311. upload_button = driver.find_element(By.ID, f"upload_button{suffix}")
  312. assert upload_button
  313. exp_files = {
  314. "test1.txt": "test file contents!",
  315. "test2.txt": "this is test file number 2!",
  316. "reflex.txt": "reflex is awesome!",
  317. }
  318. for exp_name, exp_contents in exp_files.items():
  319. target_file = tmp_path / exp_name
  320. target_file.write_text(exp_contents)
  321. upload_box.send_keys(str(target_file))
  322. time.sleep(0.2)
  323. # check that the selected files are displayed
  324. selected_files = driver.find_element(By.ID, f"selected_files{suffix}")
  325. assert [Path(name).name for name in selected_files.text.split("\n")] == [
  326. Path(name).name for name in exp_files
  327. ]
  328. clear_button = driver.find_element(By.ID, f"clear_button{suffix}")
  329. assert clear_button
  330. clear_button.click()
  331. # check that the selected files are cleared
  332. selected_files = driver.find_element(By.ID, f"selected_files{suffix}")
  333. assert selected_files.text == ""
  334. # TODO: drag and drop directory
  335. # https://gist.github.com/florentbr/349b1ab024ca9f3de56e6bf8af2ac69e
  336. @pytest.mark.asyncio
  337. async def test_cancel_upload(tmp_path, upload_file: AppHarness, driver: WebDriver):
  338. """Submit a large file upload and cancel it.
  339. Args:
  340. tmp_path: pytest tmp_path fixture
  341. upload_file: harness for UploadFile app.
  342. driver: WebDriver instance.
  343. """
  344. assert upload_file.app_instance is not None
  345. token = poll_for_token(driver, upload_file)
  346. state_name = upload_file.get_state_name("_upload_state")
  347. state_full_name = upload_file.get_full_state_name(["_upload_state"])
  348. substate_token = f"{token}_{state_full_name}"
  349. upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[1]
  350. upload_button = driver.find_element(By.ID, "upload_button_secondary")
  351. cancel_button = driver.find_element(By.ID, "cancel_button_secondary")
  352. exp_name = "large.txt"
  353. target_file = tmp_path / exp_name
  354. with target_file.open("wb") as f:
  355. f.seek(1024 * 1024 * 256)
  356. f.write(b"0")
  357. upload_box.send_keys(str(target_file))
  358. upload_button.click()
  359. await asyncio.sleep(0.3)
  360. cancel_button.click()
  361. # Wait a bit for the upload to get cancelled.
  362. await asyncio.sleep(0.5)
  363. # Get interim progress dicts saved in the on_upload_progress handler.
  364. async def _progress_dicts():
  365. state = await upload_file.get_state(substate_token)
  366. return state.substates[state_name].progress_dicts
  367. # We should have _some_ progress
  368. assert await AppHarness._poll_for_async(_progress_dicts)
  369. # But there should never be a final progress record for a cancelled upload.
  370. for p in await _progress_dicts():
  371. assert p["progress"] != 1
  372. state = await upload_file.get_state(substate_token)
  373. file_data = state.substates[state_name]._file_data
  374. assert isinstance(file_data, dict)
  375. normalized_file_data = {Path(k).name: v for k, v in file_data.items()}
  376. assert Path(exp_name).name not in normalized_file_data
  377. target_file.unlink()
  378. @pytest.mark.asyncio
  379. async def test_upload_download_file(
  380. tmp_path,
  381. upload_file: AppHarness,
  382. driver: WebDriver,
  383. ):
  384. """Submit a file upload and then fetch it with rx.download.
  385. This checks the special case `getBackendURL` logic in the _download event
  386. handler in state.js.
  387. Args:
  388. tmp_path: pytest tmp_path fixture
  389. upload_file: harness for UploadFile app.
  390. driver: WebDriver instance.
  391. """
  392. assert upload_file.app_instance is not None
  393. poll_for_token(driver, upload_file)
  394. upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[2]
  395. assert upload_box
  396. upload_button = driver.find_element(By.ID, "upload_button_tertiary")
  397. assert upload_button
  398. exp_name = "test.txt"
  399. exp_contents = "test file contents!"
  400. target_file = tmp_path / exp_name
  401. target_file.write_text(exp_contents)
  402. upload_box.send_keys(str(target_file))
  403. upload_button.click()
  404. # Download via event embedded in frontend code.
  405. download_frontend = driver.find_element(By.ID, "download-frontend")
  406. with poll_for_navigation(driver):
  407. download_frontend.click()
  408. assert urlsplit(driver.current_url).path == f"/{Endpoint.UPLOAD.value}/test.txt"
  409. assert driver.find_element(by=By.TAG_NAME, value="body").text == exp_contents
  410. # Go back and wait for the app to reload.
  411. with poll_for_navigation(driver):
  412. driver.back()
  413. poll_for_token(driver, upload_file)
  414. # Download via backend event handler.
  415. download_backend = driver.find_element(By.ID, "download-backend")
  416. with poll_for_navigation(driver):
  417. download_backend.click()
  418. assert urlsplit(driver.current_url).path == f"/{Endpoint.UPLOAD.value}/test.txt"
  419. assert driver.find_element(by=By.TAG_NAME, value="body").text == exp_contents