Przeglądaj źródła

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

Masen Furer 1 rok temu
rodzic
commit
2c97c1e7ca

+ 2 - 2
.pre-commit-config.yaml

@@ -8,7 +8,7 @@ repos:
     rev: v1.1.313
     rev: v1.1.313
     hooks:
     hooks:
     - id: pyright
     - id: pyright
-      args: [reflex, tests]
+      args: [integration, reflex, tests]
       language: system
       language: system
 
 
   - repo: https://github.com/terrencepreilly/darglint
   - repo: https://github.com/terrencepreilly/darglint
@@ -21,4 +21,4 @@ repos:
     rev: 22.10.0
     rev: 22.10.0
     hooks:
     hooks:
     - id: black
     - 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"},
     {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]]
 [[package]]
 name = "bidict"
 name = "bidict"
 version = "0.22.1"
 version = "0.22.1"
@@ -131,6 +152,82 @@ files = [
     {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
     {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]]
 [[package]]
 name = "cfgv"
 name = "cfgv"
 version = "3.3.1"
 version = "3.3.1"
@@ -819,6 +916,20 @@ files = [
     {file = "numpy-1.25.0.tar.gz", hash = "sha256:f1accae9a28dc3cda46a91de86acf69de0d1b5f4edd44a9b0c3ceb8036dfff19"},
     {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]]
 [[package]]
 name = "packaging"
 name = "packaging"
 version = "23.1"
 version = "23.1"
@@ -1025,6 +1136,17 @@ files = [
 [package.extras]
 [package.extras]
 test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
 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]]
 [[package]]
 name = "pydantic"
 name = "pydantic"
 version = "1.10.11"
 version = "1.10.11"
@@ -1110,6 +1232,18 @@ typing-extensions = {version = ">=3.7", markers = "python_version < \"3.8\""}
 all = ["twine (>=3.4.1)"]
 all = ["twine (>=3.4.1)"]
 dev = ["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]]
 [[package]]
 name = "pytest"
 name = "pytest"
 version = "7.4.0"
 version = "7.4.0"
@@ -1404,6 +1538,23 @@ files = [
     {file = "ruff-0.0.244.tar.gz", hash = "sha256:7c05773e990348a6d7628b9b7294fe76303bc870dd94d9c34154bc1560053050"},
     {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]]
 [[package]]
 name = "setuptools"
 name = "setuptools"
 version = "68.0.0"
 version = "68.0.0"
@@ -1442,6 +1593,17 @@ files = [
     {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
     {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]]
 [[package]]
 name = "sqlalchemy"
 name = "sqlalchemy"
 version = "1.4.41"
 version = "1.4.41"
@@ -1623,6 +1785,42 @@ files = [
     {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
     {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]]
 [[package]]
 name = "typed-ast"
 name = "typed-ast"
 version = "1.5.5"
 version = "1.5.5"
@@ -1704,6 +1902,26 @@ files = [
     {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"},
     {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]]
 [[package]]
 name = "uvicorn"
 name = "uvicorn"
 version = "0.20.0"
 version = "0.20.0"
@@ -1895,6 +2113,20 @@ files = [
     {file = "websockets-10.4.tar.gz", hash = "sha256:eef610b23933c54d5d921c92578ae5f89813438fded840c2e9809d378dc765d3"},
     {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]]
 [[package]]
 name = "zipp"
 name = "zipp"
 version = "3.15.0"
 version = "3.15.0"
@@ -1913,4 +2145,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 python-versions = "^3.7"
-content-hash = "cc659e46041316bc81ce1758334c6fa9ccf9812612ef67e170b173d6d1caa2b2"
+content-hash = "dd8d2871ec064277b5724a8625fdeff930320b48bfadb531c14db4c928701c9e"

+ 1 - 0
pyproject.toml

@@ -63,6 +63,7 @@ pandas = [
 asynctest = "^0.13.0"
 asynctest = "^0.13.0"
 pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"}
 pre-commit = {version = "^3.2.1", python = ">=3.8,<4.0"}
 alembic = "^1.11.1"
 alembic = "^1.11.1"
+selenium = "^4.10.0"
 
 
 [tool.poetry.scripts]
 [tool.poetry.scripts]
 reflex = "reflex.reflex:main"
 reflex = "reflex.reflex:main"

+ 1 - 0
reflex/.templates/web/package.json

@@ -22,6 +22,7 @@
     "next": "^13.3.1",
     "next": "^13.3.1",
     "plotly.js": "^2.22.0",
     "plotly.js": "^2.22.0",
     "react": "^18.2.0",
     "react": "^18.2.0",
+    "react-debounce-input": "^3.3.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-dropzone": "^14.2.3",
     "react-dropzone": "^14.2.3",
     "react-markdown": "^8.0.7",
     "react-markdown": "^8.0.7",

+ 1 - 0
reflex/components/__init__.py

@@ -94,6 +94,7 @@ checkbox_group = CheckboxGroup.create
 copy_to_clipboard = CopyToClipboard.create
 copy_to_clipboard = CopyToClipboard.create
 date_picker = DatePicker.create
 date_picker = DatePicker.create
 date_time_picker = DateTimePicker.create
 date_time_picker = DateTimePicker.create
+debounce_input = DebounceInput.create
 editable = Editable.create
 editable = Editable.create
 editable_input = EditableInput.create
 editable_input = EditableInput.create
 editable_preview = EditablePreview.create
 editable_preview = EditablePreview.create

+ 1 - 0
reflex/components/forms/__init__.py

@@ -11,6 +11,7 @@ from .colormodeswitch import (
 from .copytoclipboard import CopyToClipboard
 from .copytoclipboard import CopyToClipboard
 from .date_picker import DatePicker
 from .date_picker import DatePicker
 from .date_time_picker import DateTimePicker
 from .date_time_picker import DateTimePicker
+from .debounce import DebounceInput
 from .editable import Editable, EditableInput, EditablePreview, EditableTextarea
 from .editable import Editable, EditableInput, EditablePreview, EditableTextarea
 from .email import Email
 from .email import Email
 from .form import Form, FormControl, FormErrorMessage, FormHelperText, FormLabel
 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