reflex.testing.AppHarness: tools for testing reflex apps (#1326)
This commit is contained in:
parent
3a07e990be
commit
2c97c1e7ca
@ -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
91
integration/test_input.py
Normal 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
234
poetry.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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
reflex/components/forms/debounce.py
Normal file
77
reflex/components/forms/debounce.py
Normal 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
454
reflex/testing.py
Normal 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
29
tests/test_testing.py
Normal 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
|
Loading…
Reference in New Issue
Block a user