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

This commit is contained in:
Masen Furer 2023-07-13 15:45:57 -07:00 committed by GitHub
parent 3a07e990be
commit 2c97c1e7ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 890 additions and 3 deletions

View File

@ -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
integration/test_input.py Normal file
View File

@ -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"

234
poetry.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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
reflex/testing.py Normal file
View File

@ -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
tests/test_testing.py Normal file
View File

@ -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