diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c645b2337..70e42bd77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: rev: v1.1.313 hooks: - id: pyright - args: [reflex, tests] + args: [integration, reflex, tests] language: system - repo: https://github.com/terrencepreilly/darglint @@ -21,4 +21,4 @@ repos: rev: 22.10.0 hooks: - id: black - args: [reflex, tests] + args: [integration, reflex, tests] diff --git a/integration/test_input.py b/integration/test_input.py new file mode 100644 index 000000000..be143502b --- /dev/null +++ b/integration/test_input.py @@ -0,0 +1,91 @@ +"""Integration tests for text input and related components.""" +import time +from typing import Generator + +import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from reflex.testing import AppHarness + + +def FullyControlledInput(): + """App using a fully controlled input with debounce wrapper.""" + import reflex as rx + + class State(rx.State): + text: str = "initial" + + app = rx.App(state=State) + + @app.add_page + def index(): + return rx.debounce_input( + rx.input( + value=State.text, + on_change=State.set_text, # type: ignore + ), + debounce_timeout=0, + ) + + app.compile() + + +@pytest.fixture() +def fully_controlled_input(tmp_path) -> Generator[AppHarness, None, None]: + """Start FullyControlledInput app at tmp_path via AppHarness. + + Args: + tmp_path: pytest tmp_path fixture + + Yields: + running AppHarness instance + """ + with AppHarness.create( + root=tmp_path, + app_source=FullyControlledInput, # type: ignore + ) as harness: + yield harness + + +@pytest.mark.asyncio +async def test_fully_controlled_input(fully_controlled_input: AppHarness): + """Type text after moving cursor. Update text on backend. + + Args: + fully_controlled_input: harness for FullyControlledInput app + """ + assert fully_controlled_input.app_instance is not None, "app is not running" + driver = fully_controlled_input.frontend() + + # get a reference to the connected client + assert len(fully_controlled_input.poll_for_clients()) == 1 + token, backend_state = list( + fully_controlled_input.app_instance.state_manager.states.items() + )[0] + + # find the input and wait for it to have the initial state value + text_input = driver.find_element(By.TAG_NAME, "input") + assert fully_controlled_input.poll_for_value(text_input) == "initial" + + # move cursor to home, then to the right and type characters + text_input.send_keys(Keys.HOME, Keys.ARROW_RIGHT) + text_input.send_keys("foo") + assert text_input.get_attribute("value") == "ifoonitial" + assert backend_state.text == "ifoonitial" + + # clear the input on the backend + backend_state.text = "" + fully_controlled_input.app_instance.state_manager.set_state(token, backend_state) + await fully_controlled_input.emit_state_updates() + assert backend_state.text == "" + assert ( + fully_controlled_input.poll_for_value(text_input, exp_not_equal="ifoonitial") + == "" + ) + + # type more characters + text_input.send_keys("getting testing done") + time.sleep(0.1) + assert text_input.get_attribute("value") == "getting testing done" + assert backend_state.text == "getting testing done" diff --git a/poetry.lock b/poetry.lock index a5e3a2a3a..8484b9ae1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -68,6 +68,27 @@ files = [ {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] + [[package]] name = "bidict" version = "0.22.1" @@ -131,6 +152,82 @@ files = [ {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, ] +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -819,6 +916,20 @@ files = [ {file = "numpy-1.25.0.tar.gz", hash = "sha256:f1accae9a28dc3cda46a91de86acf69de0d1b5f4edd44a9b0c3ceb8036dfff19"}, ] +[[package]] +name = "outcome" +version = "1.2.0" +description = "Capture the outcome of Python function calls." +optional = false +python-versions = ">=3.7" +files = [ + {file = "outcome-1.2.0-py2.py3-none-any.whl", hash = "sha256:c4ab89a56575d6d38a05aa16daeaa333109c1f96167aba8901ab18b6b5e0f7f5"}, + {file = "outcome-1.2.0.tar.gz", hash = "sha256:6f82bd3de45da303cf1f771ecafa1633750a358436a8bb60e06a1ceb745d2672"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + [[package]] name = "packaging" version = "23.1" @@ -1025,6 +1136,17 @@ files = [ [package.extras] test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "1.10.11" @@ -1110,6 +1232,18 @@ typing-extensions = {version = ">=3.7", markers = "python_version < \"3.8\""} all = ["twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "7.4.0" @@ -1404,6 +1538,23 @@ files = [ {file = "ruff-0.0.244.tar.gz", hash = "sha256:7c05773e990348a6d7628b9b7294fe76303bc870dd94d9c34154bc1560053050"}, ] +[[package]] +name = "selenium" +version = "4.10.0" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "selenium-4.10.0-py3-none-any.whl", hash = "sha256:40241b9d872f58959e9b34e258488bf11844cd86142fd68182bd41db9991fc5c"}, + {file = "selenium-4.10.0.tar.gz", hash = "sha256:871bf800c4934f745b909c8dfc7d15c65cf45bd2e943abd54451c810ada395e3"}, +] + +[package.dependencies] +certifi = ">=2021.10.8" +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +urllib3 = {version = ">=1.26,<3", extras = ["socks"]} + [[package]] name = "setuptools" version = "68.0.0" @@ -1442,6 +1593,17 @@ files = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + [[package]] name = "sqlalchemy" version = "1.4.41" @@ -1623,6 +1785,42 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "trio" +version = "0.22.2" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.7" +files = [ + {file = "trio-0.22.2-py3-none-any.whl", hash = "sha256:f43da357620e5872b3d940a2e3589aa251fd3f881b65a608d742e00809b1ec38"}, + {file = "trio-0.22.2.tar.gz", hash = "sha256:3887cf18c8bcc894433420305468388dac76932e9668afa1c49aa3806b6accb3"}, +] + +[package.dependencies] +attrs = ">=20.1.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +exceptiongroup = {version = ">=1.0.0rc9", markers = "python_version < \"3.11\""} +idna = "*" +outcome = "*" +sniffio = "*" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.10.3" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "trio-websocket-0.10.3.tar.gz", hash = "sha256:1a748604ad906a7dcab9a43c6eb5681e37de4793ba0847ef0bc9486933ed027b"}, + {file = "trio_websocket-0.10.3-py3-none-any.whl", hash = "sha256:a9937d48e8132ebf833019efde2a52ca82d223a30a7ea3e8d60a7d28f75a4e3a"}, +] + +[package.dependencies] +exceptiongroup = "*" +trio = ">=0.11" +wsproto = ">=0.14" + [[package]] name = "typed-ast" version = "1.5.5" @@ -1704,6 +1902,26 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "urllib3" +version = "2.0.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +files = [ + {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"}, + {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"}, +] + +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "uvicorn" version = "0.20.0" @@ -1895,6 +2113,20 @@ files = [ {file = "websockets-10.4.tar.gz", hash = "sha256:eef610b23933c54d5d921c92578ae5f89813438fded840c2e9809d378dc765d3"}, ] +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + [[package]] name = "zipp" version = "3.15.0" @@ -1913,4 +2145,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "cc659e46041316bc81ce1758334c6fa9ccf9812612ef67e170b173d6d1caa2b2" +content-hash = "dd8d2871ec064277b5724a8625fdeff930320b48bfadb531c14db4c928701c9e" diff --git a/pyproject.toml b/pyproject.toml index 1ac4d8780..9991d367a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ pandas = [ asynctest = "^0.13.0" pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"} alembic = "^1.11.1" +selenium = "^4.10.0" [tool.poetry.scripts] reflex = "reflex.reflex:main" diff --git a/reflex/.templates/web/package.json b/reflex/.templates/web/package.json index 9724e0835..af26b82d6 100644 --- a/reflex/.templates/web/package.json +++ b/reflex/.templates/web/package.json @@ -22,6 +22,7 @@ "next": "^13.3.1", "plotly.js": "^2.22.0", "react": "^18.2.0", + "react-debounce-input": "^3.3.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-markdown": "^8.0.7", diff --git a/reflex/components/__init__.py b/reflex/components/__init__.py index 3e4ea3cbc..d0a86907e 100644 --- a/reflex/components/__init__.py +++ b/reflex/components/__init__.py @@ -94,6 +94,7 @@ checkbox_group = CheckboxGroup.create copy_to_clipboard = CopyToClipboard.create date_picker = DatePicker.create date_time_picker = DateTimePicker.create +debounce_input = DebounceInput.create editable = Editable.create editable_input = EditableInput.create editable_preview = EditablePreview.create diff --git a/reflex/components/forms/__init__.py b/reflex/components/forms/__init__.py index 9e93702d4..15c038e0e 100644 --- a/reflex/components/forms/__init__.py +++ b/reflex/components/forms/__init__.py @@ -11,6 +11,7 @@ from .colormodeswitch import ( from .copytoclipboard import CopyToClipboard from .date_picker import DatePicker from .date_time_picker import DateTimePicker +from .debounce import DebounceInput from .editable import Editable, EditableInput, EditablePreview, EditableTextarea from .email import Email from .form import Form, FormControl, FormErrorMessage, FormHelperText, FormLabel diff --git a/reflex/components/forms/debounce.py b/reflex/components/forms/debounce.py new file mode 100644 index 000000000..fa96f3ef2 --- /dev/null +++ b/reflex/components/forms/debounce.py @@ -0,0 +1,77 @@ +"""Wrapper around react-debounce-input.""" +from __future__ import annotations + +from typing import Any + +from reflex.components import Component +from reflex.components.tags import Tag +from reflex.vars import Var + + +class DebounceInput(Component): + """The DebounceInput component is used to buffer input events on the client side. + + It is intended to wrap various form controls and should be used whenever a + fully-controlled input is needed to prevent lost input data when the backend + is experiencing high latency. + """ + + library = "react-debounce-input" + tag = "DebounceInput" + + # Minimum input characters before triggering the on_change event + min_length: Var[int] = 0 # type: ignore + + # Time to wait between end of input and triggering on_change + debounce_timeout: Var[int] = 100 # type: ignore + + # If true, notify when Enter key is pressed + force_notify_by_enter: Var[bool] = True # type: ignore + + # If true, notify when form control loses focus + force_notify_on_blur: Var[bool] = True # type: ignore + + def _render(self) -> Tag: + """Carry first child props directly on this tag. + + Since react-debounce-input wants to create and manage the underlying + input component itself, we carry all props, events, and styles from + the child, and then neuter the child's render method so it produces no output. + + Returns: + The rendered debounce element wrapping the first child element. + + Raises: + RuntimeError: unless exactly one child element is provided. + """ + if not self.children or len(self.children) > 1: + raise RuntimeError( + "Provide a single child for DebounceInput, such as rx.input() or " + "rx.text_area()", + ) + child = self.children[0] + tag = super()._render() + tag.add_props( + **child.event_triggers, + **props_not_none(child), + sx=child.style, + id=child.id, + class_name=child.class_name, + element=Var.create("{%s}" % child.tag, is_local=False, is_string=False), + ) + # do NOT render the child, DebounceInput will create it + object.__setattr__(child, "render", lambda: "") + return tag + + +def props_not_none(c: Component) -> dict[str, Any]: + """Get all properties of the component that are not None. + + Args: + c: the component to get_props from + + Returns: + dict of all props that are not None. + """ + cdict = {a: getattr(c, a) for a in c.get_props() if getattr(c, a, None) is not None} + return cdict diff --git a/reflex/testing.py b/reflex/testing.py new file mode 100644 index 000000000..3f26382e2 --- /dev/null +++ b/reflex/testing.py @@ -0,0 +1,454 @@ +"""reflex.testing - tools for testing reflex apps.""" +from __future__ import annotations + +import contextlib +import dataclasses +import inspect +import os +import pathlib +import platform +import re +import signal +import socket +import subprocess +import textwrap +import threading +import time +import types +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Coroutine, + Optional, + Type, + TypeVar, + Union, + cast, +) + +import psutil +import uvicorn + +import reflex +import reflex.reflex +import reflex.utils.build +import reflex.utils.exec +import reflex.utils.prerequisites +import reflex.utils.processes +from reflex.app import EventNamespace + +try: + from selenium import webdriver # pyright: ignore [reportMissingImports] + from selenium.webdriver.remote.webdriver import ( # pyright: ignore [reportMissingImports] + WebDriver, + ) + + if TYPE_CHECKING: + from selenium.webdriver.remote.webelement import ( # pyright: ignore [reportMissingImports] + WebElement, + ) + + has_selenium = True +except ImportError: + has_selenium = False + +DEFAULT_TIMEOUT = 10 +POLL_INTERVAL = 0.25 +FRONTEND_LISTENING_MESSAGE = re.compile(r"ready started server on.*, url: (.*:[0-9]+)$") +FRONTEND_POPEN_ARGS = {} +T = TypeVar("T") +TimeoutType = Optional[Union[int, float]] + +if platform.system == "Windows": + FRONTEND_POPEN_ARGS["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore +else: + FRONTEND_POPEN_ARGS["start_new_session"] = True + +# borrowed from py3.11 +class chdir(contextlib.AbstractContextManager): + """Non thread-safe context manager to change the current working directory.""" + + def __init__(self, path): + """Prepare contextmanager. + + Args: + path: the path to change to + """ + self.path = path + self._old_cwd = [] + + def __enter__(self): + """Save current directory and perform chdir.""" + self._old_cwd.append(os.getcwd()) + os.chdir(self.path) + + def __exit__(self, *excinfo): + """Change back to previous directory on stack. + + Args: + excinfo: sys.exc_info captured in the context block + """ + os.chdir(self._old_cwd.pop()) + + +@dataclasses.dataclass +class AppHarness: + """AppHarness executes a reflex app in-process for testing.""" + + app_name: str + app_source: Optional[types.FunctionType | types.ModuleType] + app_path: pathlib.Path + app_module_path: pathlib.Path + app_module: Optional[types.ModuleType] = None + app_instance: Optional[reflex.App] = None + frontend_process: Optional[subprocess.Popen] = None + frontend_url: Optional[str] = None + backend_thread: Optional[threading.Thread] = None + backend: Optional[uvicorn.Server] = None + _frontends: list["WebDriver"] = dataclasses.field(default_factory=list) + + @classmethod + def create( + cls, + root: pathlib.Path, + app_source: Optional[types.FunctionType | types.ModuleType] = None, + app_name: Optional[str] = None, + ) -> "AppHarness": + """Create an AppHarness instance at root. + + Args: + root: the directory that will contain the app under test. + app_source: if specified, the source code from this function or module is used + as the main module for the app. If unspecified, then root must already + contain a working reflex app and will be used directly. + app_name: provide the name of the app, otherwise will be derived from app_source or root. + + Returns: + AppHarness instance + """ + if app_name is None: + if app_source is None: + app_name = root.name.lower() + else: + app_name = app_source.__name__.lower() + return cls( + app_name=app_name, + app_source=app_source, + app_path=root, + app_module_path=root / app_name / f"{app_name}.py", + ) + + def _initialize_app(self): + self.app_path.mkdir(parents=True, exist_ok=True) + if self.app_source is not None: + # get the source from a function or module object + source_code = textwrap.dedent( + "".join(inspect.getsource(self.app_source).splitlines(True)[1:]), + ) + with chdir(self.app_path): + reflex.reflex.init( + name=self.app_name, + template=reflex.constants.Template.DEFAULT, + ) + self.app_module_path.write_text(source_code) + with chdir(self.app_path): + self.app_module = reflex.utils.prerequisites.get_app() + self.app_instance = self.app_module.app + + def _start_backend(self): + if self.app_instance is None: + raise RuntimeError("App was not initialized.") + self.backend = uvicorn.Server( + uvicorn.Config( + app=self.app_instance.api, + host="127.0.0.1", + port=0, + ) + ) + self.backend_thread = threading.Thread(target=self.backend.run) + self.backend_thread.start() + + def _start_frontend(self): + with chdir(self.app_path): + config = reflex.config.get_config() + config.api_url = "http://{0}:{1}".format( + *self._poll_for_servers().getsockname(), + ) + reflex.utils.build.setup_frontend(self.app_path) + frontend_env = os.environ.copy() + frontend_env["PORT"] = "0" + self.frontend_process = subprocess.Popen( + [reflex.utils.prerequisites.get_package_manager(), "run", "dev"], + stdout=subprocess.PIPE, + encoding="utf-8", + cwd=self.app_path / reflex.constants.WEB_DIR, + env=frontend_env, + **FRONTEND_POPEN_ARGS, + ) + + def _wait_frontend(self): + while self.frontend_url is None: + line = ( + self.frontend_process.stdout.readline() # pyright: ignore [reportOptionalMemberAccess] + ) + if not line: + break + print(line) # for pytest diagnosis + m = FRONTEND_LISTENING_MESSAGE.search(line) + if m is not None: + self.frontend_url = m.group(1) + break + if self.frontend_url is None: + raise RuntimeError("Frontend did not start") + + def start(self) -> "AppHarness": + """Start the backend in a new thread and dev frontend as a separate process. + + Returns: + self + """ + self._initialize_app() + self._start_backend() + self._start_frontend() + self._wait_frontend() + return self + + def __enter__(self) -> "AppHarness": + """Contextmanager protocol for `start()`. + + Returns: + Instance of AppHarness after calling start() + """ + return self.start() + + def stop(self) -> None: + """Stop the frontend and backend servers.""" + if self.backend is not None: + self.backend.should_exit = True + if self.frontend_process is not None: + # https://stackoverflow.com/a/70565806 + frontend_children = psutil.Process(self.frontend_process.pid).children( + recursive=True, + ) + if platform.system() == "Windows": + self.frontend_process.terminate() + else: + pgrp = os.getpgid(self.frontend_process.pid) + os.killpg(pgrp, signal.SIGTERM) + # kill any remaining child processes + for child in frontend_children: + child.terminate() + _, still_alive = psutil.wait_procs(frontend_children, timeout=3) + for child in still_alive: + child.kill() + # wait for main process to exit + self.frontend_process.communicate() + if self.backend_thread is not None: + self.backend_thread.join() + for driver in self._frontends: + driver.quit() + + def __exit__(self, *excinfo) -> None: + """Contextmanager protocol for `stop()`. + + Args: + excinfo: sys.exc_info captured in the context block + """ + self.stop() + + @staticmethod + def _poll_for( + target: Callable[[], T], + timeout: TimeoutType = None, + step: TimeoutType = None, + ) -> T | bool: + """Generic polling logic. + + Args: + target: callable that returns truthy if polling condition is met. + timeout: max polling time + step: interval between checking target() + + Returns: + return value of target() if truthy within timeout + False if timeout elapses + """ + if timeout is None: + timeout = DEFAULT_TIMEOUT + if step is None: + step = POLL_INTERVAL + deadline = time.time() + timeout + while time.time() < deadline: + success = target() + if success: + return success + return False + + def _poll_for_servers(self, timeout: TimeoutType = None) -> socket.socket: + """Poll backend server for listening sockets. + + Args: + timeout: how long to wait for listening socket. + + Returns: + first active listening socket on the backend + + Raises: + RuntimeError: when the backend hasn't started running + TimeoutError: when server or sockets are not ready + """ + if self.backend is None: + raise RuntimeError("Backend is not running.") + backend = self.backend + # check for servers to be initialized + if not self._poll_for( + target=lambda: getattr(backend, "servers", False), + timeout=timeout, + ): + raise TimeoutError("Backend servers are not initialized.") + # check for sockets to be listening + if not self._poll_for( + target=lambda: getattr(backend.servers[0], "sockets", False), + timeout=timeout, + ): + raise TimeoutError("Backend is not listening.") + return backend.servers[0].sockets[0] + + def frontend(self, driver_clz: Optional[Type["WebDriver"]] = None) -> "WebDriver": + """Get a selenium webdriver instance pointed at the app. + + Args: + driver_clz: webdriver.Chrome (default), webdriver.Firefox, webdriver.Safari, + webdriver.Edge, etc + + Returns: + Instance of the given webdriver navigated to the frontend url of the app. + + Raises: + RuntimeError: when selenium is not importable or frontend is not running + """ + if not has_selenium: + raise RuntimeError( + "Frontend functionality requires `selenium` to be installed, " + "and it could not be imported." + ) + if self.frontend_url is None: + raise RuntimeError("Frontend is not running.") + driver = driver_clz() if driver_clz is not None else webdriver.Chrome() + driver.get(self.frontend_url) + self._frontends.append(driver) + return driver + + async def emit_state_updates(self) -> list[Any]: + """Send any backend state deltas to the frontend. + + Returns: + List of awaited response from each EventNamespace.emit() call. + + Raises: + RuntimeError: when the app hasn't started running + """ + if self.app_instance is None or self.app_instance.sio is None: + raise RuntimeError("App is not running.") + event_ns: EventNamespace = cast( + EventNamespace, + self.app_instance.sio.namespace_handlers["/event"], + ) + pending: list[Coroutine[Any, Any, Any]] = [] + for state in self.app_instance.state_manager.states.values(): + delta = state.get_delta() + if delta: + update = reflex.state.StateUpdate(delta=delta, events=[], final=True) + state.clean() + # Emit the event. + pending.append( + event_ns.emit( + str(reflex.constants.SocketEvent.EVENT), + update.json(), + to=state.get_sid(), + ), + ) + responses = [] + for request in pending: + responses.append(await request) + return responses + + def poll_for_content( + self, + element: "WebElement", + timeout: TimeoutType = None, + exp_not_equal: str = "", + ) -> str: + """Poll element.text for change. + + Args: + element: selenium webdriver element to check + timeout: how long to poll element.text + exp_not_equal: exit the polling loop when the element text does not match + + Returns: + The element text when the polling loop exited + + Raises: + TimeoutError: when the timeout expires before text changes + """ + if not self._poll_for( + target=lambda: element.text != exp_not_equal, + timeout=timeout, + ): + raise TimeoutError( + f"{element} content remains {exp_not_equal!r} while polling.", + ) + return element.text + + def poll_for_value( + self, + element: "WebElement", + timeout: TimeoutType = None, + exp_not_equal: str = "", + ) -> Optional[str]: + """Poll element.get_attribute("value") for change. + + Args: + element: selenium webdriver element to check + timeout: how long to poll element value attribute + exp_not_equal: exit the polling loop when the value does not match + + Returns: + The element value when the polling loop exited + + Raises: + TimeoutError: when the timeout expires before value changes + """ + if not self._poll_for( + target=lambda: element.get_attribute("value") != exp_not_equal, + timeout=timeout, + ): + raise TimeoutError( + f"{element} content remains {exp_not_equal!r} while polling.", + ) + return element.get_attribute("value") + + def poll_for_clients(self, timeout: TimeoutType = None) -> dict[str, reflex.State]: + """Poll app state_manager for any connected clients. + + Args: + timeout: how long to wait for client states + + Returns: + active state instances when the polling loop exited + + Raises: + RuntimeError: when the app hasn't started running + TimeoutError: when the timeout expires before any states are seen + """ + if self.app_instance is None: + raise RuntimeError("App is not running.") + state_manager = self.app_instance.state_manager + if not self._poll_for( + target=lambda: state_manager.states, + timeout=timeout, + ): + raise TimeoutError("No states were observed while polling.") + return state_manager.states diff --git a/tests/test_testing.py b/tests/test_testing.py new file mode 100644 index 000000000..dfbe61fbc --- /dev/null +++ b/tests/test_testing.py @@ -0,0 +1,29 @@ +"""Unit tests for the included testing tools.""" +from reflex.testing import AppHarness + + +def test_app_harness(tmp_path): + """Ensure that AppHarness can compile and start an app. + + Args: + tmp_path: pytest tmp_path fixture + """ + + def BasicApp(): + import reflex as rx + + app = rx.App() + app.add_page(lambda: rx.text("Basic App"), route="/", title="index") + app.compile() + + with AppHarness.create( + root=tmp_path, + app_source=BasicApp, # type: ignore + ) as harness: + assert harness.app_instance is not None + assert harness.backend is not None + assert harness.frontend_url is not None + assert harness.frontend_process is not None + assert harness.frontend_process.poll() is None + + assert harness.frontend_process.poll() is not None