1
0

test_upload.py 19 KB

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