Bläddra i källkod

reflex.testing.AppHarness: tools for testing reflex apps (#1326)

Masen Furer 1 år sedan
förälder
incheckning
2c97c1e7ca

+ 2 - 2
.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]

+ 91 - 0
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"

+ 233 - 1
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"

+ 1 - 0
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"

+ 1 - 0
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",

+ 1 - 0
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

+ 1 - 0
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

+ 77 - 0
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

+ 454 - 0
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

+ 29 - 0
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