Skip to content

Commit 5c386b1

Browse files
Merge pull request #68 from streamlit/STREAMLIT-3894-duplicate-e2e-setup-for-all-examples-and-components
Add test setup for each component
2 parents a71e37f + 4ef22f4 commit 5c386b1

File tree

26 files changed

+1213
-143
lines changed

26 files changed

+1213
-143
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ $ npm run start # Start the Webpack dev server
5454
```
5555
$ cd template
5656
$ . venv/bin/activate # activate the venv you created earlier
57-
$ streamlit run my_component/__init__.py # run the example
57+
$ streamlit run my_component/example.py # run the example
5858
```
5959
* If all goes well, you should see something like this:
6060
![Quickstart Success](quickstart.png)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import contextlib
2+
import logging
3+
import os
4+
import shlex
5+
import socket
6+
import subprocess
7+
import sys
8+
import time
9+
import typing
10+
from contextlib import closing
11+
from tempfile import TemporaryFile
12+
13+
import requests
14+
15+
16+
LOGGER = logging.getLogger(__file__)
17+
18+
19+
def _find_free_port():
20+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
21+
s.bind(("", 0)) # 0 means that the OS chooses a random port
22+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
23+
return int(s.getsockname()[1]) # [1] contains the randomly selected port number
24+
25+
26+
class AsyncSubprocess:
27+
"""A context manager. Wraps subprocess. Popen to capture output safely."""
28+
29+
def __init__(self, args, cwd=None, env=None):
30+
self.args = args
31+
self.cwd = cwd
32+
self.env = env
33+
self._proc = None
34+
self._stdout_file = None
35+
36+
def terminate(self):
37+
"""Terminate the process and return its stdout/stderr in a string."""
38+
if self._proc is not None:
39+
self._proc.terminate()
40+
self._proc.wait()
41+
self._proc = None
42+
43+
# Read the stdout file and close it
44+
stdout = None
45+
if self._stdout_file is not None:
46+
self._stdout_file.seek(0)
47+
stdout = self._stdout_file.read()
48+
self._stdout_file.close()
49+
self._stdout_file = None
50+
51+
return stdout
52+
53+
def __enter__(self):
54+
self.start()
55+
return self
56+
57+
def __exit__(self, exc_type, exc_val, exc_tb):
58+
self.stop()
59+
60+
def start(self):
61+
# Start the process and capture its stdout/stderr output to a temp
62+
# file. We do this instead of using subprocess.PIPE (which causes the
63+
# Popen object to capture the output to its own internal buffer),
64+
# because large amounts of output can cause it to deadlock.
65+
self._stdout_file = TemporaryFile("w+")
66+
LOGGER.info("Running command: %s", shlex.join(self.args))
67+
self._proc = subprocess.Popen(
68+
self.args,
69+
cwd=self.cwd,
70+
stdout=self._stdout_file,
71+
stderr=subprocess.STDOUT,
72+
text=True,
73+
env={**os.environ.copy(), **self.env} if self.env else None,
74+
)
75+
76+
def stop(self):
77+
if self._proc is not None:
78+
self._proc.terminate()
79+
self._proc = None
80+
if self._stdout_file is not None:
81+
self._stdout_file.close()
82+
self._stdout_file = None
83+
84+
85+
class StreamlitRunner:
86+
def __init__(
87+
self, script_path: os.PathLike, server_port: typing.Optional[int] = None
88+
):
89+
self._process = None
90+
self.server_port = server_port
91+
self.script_path = script_path
92+
93+
def __enter__(self):
94+
self.start()
95+
return self
96+
97+
def __exit__(self, type, value, traceback):
98+
self.stop()
99+
100+
def start(self):
101+
self.server_port = self.server_port or _find_free_port()
102+
self._process = AsyncSubprocess(
103+
[
104+
sys.executable,
105+
"-m",
106+
"streamlit",
107+
"run",
108+
str(self.script_path),
109+
f"--server.port={self.server_port}",
110+
"--server.headless=true",
111+
"--browser.gatherUsageStats=false",
112+
"--global.developmentMode=false",
113+
]
114+
)
115+
self._process.start()
116+
if not self.is_server_running():
117+
self._process.stop()
118+
raise RuntimeError("Application failed to start")
119+
120+
def stop(self):
121+
self._process.stop()
122+
123+
def is_server_running(self, timeout: int = 30):
124+
with requests.Session() as http_session:
125+
start_time = time.time()
126+
print("Start loop: ", start_time)
127+
while True:
128+
with contextlib.suppress(requests.RequestException):
129+
response = http_session.get(self.server_url + "/_stcore/health")
130+
if response.text == "ok":
131+
return True
132+
time.sleep(3)
133+
if time.time() - start_time > 60 * timeout:
134+
return False
135+
136+
@property
137+
def server_url(self):
138+
if not self.server_port:
139+
raise RuntimeError("Unknown server port")
140+
return f"http://localhost:{self.server_port}"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from playwright.sync_api import Page, expect
6+
7+
from e2e_utils import StreamlitRunner
8+
9+
ROOT_DIRECTORY = Path(__file__).parent.parent.absolute()
10+
BASIC_EXAMPLE_FILE = ROOT_DIRECTORY / "my_component" / "example.py"
11+
12+
@pytest.fixture(autouse=True, scope="module")
13+
def streamlit_app():
14+
with StreamlitRunner(BASIC_EXAMPLE_FILE) as runner:
15+
yield runner
16+
17+
18+
@pytest.fixture(autouse=True, scope="function")
19+
def go_to_app(page: Page, streamlit_app: StreamlitRunner):
20+
page.goto(streamlit_app.server_url)
21+
# Wait for app to load
22+
page.get_by_role("img", name="Running...").is_hidden()
23+
24+
25+
def test_should_render_template(page: Page):
26+
frame = page.frame_locator(
27+
'iframe[title="my_component\\.my_component"] >> nth=0'
28+
)
29+
30+
expect(page.get_by_text("You've clicked 0 times!").first).to_be_visible()
31+
32+
frame.get_by_role("button", name="Click me!").click()
33+
34+
expect(page.get_by_text("You've clicked 1 times!").first).to_be_visible()

cookiecutter/{{ cookiecutter.package_name }}/{{ cookiecutter.import_name }}/__init__.py

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
# replace the `url` param with `path`, and point it to the component's
3535
# build directory:
3636
parent_dir = os.path.dirname(os.path.abspath(__file__))
37-
build_dir = os.path.join(parent_dir, "frontend-react/build")
37+
build_dir = os.path.join(parent_dir, "frontend/build")
3838
_component_func = components.declare_component("{{ cookiecutter.import_name }}", path=build_dir)
3939

4040

@@ -75,32 +75,3 @@ def {{ cookiecutter.import_name }}(name, key=None):
7575
# We could modify the value returned from the component if we wanted.
7676
# There's no need to do this in our simple example - but it's an option.
7777
return component_value
78-
79-
80-
# Add some test code to play with the component while it's in development.
81-
# During development, we can run this just as we would any other Streamlit
82-
# app: `$ streamlit run {{ cookiecutter.import_name }}/__init__.py`
83-
if not _RELEASE:
84-
import streamlit as st
85-
86-
st.subheader("Component with constant args")
87-
88-
# Create an instance of our component with a constant `name` arg, and
89-
# print its output value.
90-
num_clicks = {{ cookiecutter.import_name }}("World")
91-
st.markdown("You've clicked %s times!" % int(num_clicks))
92-
93-
st.markdown("---")
94-
st.subheader("Component with variable args")
95-
96-
# Create a second instance of our component whose `name` arg will vary
97-
# based on a text_input widget.
98-
#
99-
# We use the special "key" argument to assign a fixed identity to this
100-
# component instance. By default, when a component's arguments change,
101-
# it is considered a new instance and will be re-mounted on the frontend
102-
# and lose its current state. In this case, we want to vary the component's
103-
# "name" argument without having it get recreated.
104-
name_input = st.text_input("Enter a name", value="Streamlit")
105-
num_clicks = {{ cookiecutter.import_name }}(name_input, key="foo")
106-
st.markdown("You've clicked %s times!" % int(num_clicks))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import streamlit as st
2+
from {{ cookiecutter.import_name }} import {{ cookiecutter.import_name }}
3+
4+
# Add some test code to play with the component while it's in development.
5+
# During development, we can run this just as we would any other Streamlit
6+
# app: `$ streamlit run {{ cookiecutter.import_name }}/example.py`
7+
8+
st.subheader("Component with constant args")
9+
10+
# Create an instance of our component with a constant `name` arg, and
11+
# print its output value.
12+
num_clicks = {{ cookiecutter.import_name }}("World")
13+
st.markdown("You've clicked %s times!" % int(num_clicks))
14+
15+
st.markdown("---")
16+
st.subheader("Component with variable args")
17+
18+
# Create a second instance of our component whose `name` arg will vary
19+
# based on a text_input widget.
20+
#
21+
# We use the special "key" argument to assign a fixed identity to this
22+
# component instance. By default, when a component's arguments change,
23+
# it is considered a new instance and will be re-mounted on the frontend
24+
# and lose its current state. In this case, we want to vary the component's
25+
# "name" argument without having it get recreated.
26+
name_input = st.text_input("Enter a name", value="Streamlit")
27+
num_clicks = {{ cookiecutter.import_name }}(name_input, key="foo")
28+
st.markdown("You've clicked %s times!" % int(num_clicks))

dev.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,20 @@ def cmd_install_browsers(args):
7272

7373
def cmd_all_run_e2e(args):
7474
""""Run e2e tests for all examples and templates"""
75-
for project_dir in EXAMPLE_DIRECTORIES + TEMPLATE_DIRECTORIES:
75+
for project_dir in TEMPLATE_DIRECTORIES:
76+
e2e_dir = next(project_dir.glob("**/e2e/"), None)
77+
if e2e_dir:
78+
with tempfile.TemporaryDirectory() as tmp_dir:
79+
run_verbose(['python', '-m', 'venv', f"{tmp_dir}/venv"])
80+
wheel_files = list(project_dir.glob("dist/*.whl"))
81+
if wheel_files:
82+
wheel_file = wheel_files[0]
83+
run_verbose([f"{tmp_dir}/venv/bin/pip", "install", f"{str(wheel_file)}[devel]"], cwd=str(project_dir))
84+
else:
85+
print(f"No wheel files found in {project_dir}")
86+
run_verbose([f"{tmp_dir}/venv/bin/pytest", "-s", "--browser", "webkit", "--browser", "chromium", "--browser", "firefox", "--reruns", "5", str(e2e_dir)])
87+
88+
for project_dir in EXAMPLE_DIRECTORIES:
7689
e2e_dir = next(project_dir.glob("**/e2e/"), None)
7790
if e2e_dir:
7891
run_verbose(["pytest", "-s", "--browser", "webkit", "--browser", "chromium", "--browser", "firefox", "--reruns", "5", str(e2e_dir)])
Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22

3-
import streamlit as st
43
import pandas as pd
54

65
import streamlit.components.v1 as components
@@ -22,20 +21,3 @@
2221

2322
def custom_dataframe(data, key=None):
2423
return _custom_dataframe(data=data, key=key, default=pd.DataFrame())
25-
26-
27-
# Test code to play with the component while it's in development.
28-
# During development, we can run this just as we would any other Streamlit
29-
# app: `$ streamlit run custom_dataframe/__init__.py`
30-
if not _RELEASE:
31-
raw_data = {
32-
"First Name": ["Jason", "Molly", "Tina", "Jake", "Amy"],
33-
"Last Name": ["Miller", "Jacobson", "Ali", "Milner", "Smith"],
34-
"Age": [42, 52, 36, 24, 73],
35-
}
36-
37-
df = pd.DataFrame(raw_data, columns=["First Name", "Last Name", "Age"])
38-
returned_df = custom_dataframe(df)
39-
40-
if not returned_df.empty:
41-
st.table(returned_df)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import streamlit as st
2+
import pandas as pd
3+
from custom_dataframe import custom_dataframe
4+
5+
# Test code to play with the component while it's in development.
6+
# During development, we can run this just as we would any other Streamlit
7+
# app: `$ streamlit run custom_dataframe/example.py`
8+
raw_data = {
9+
"First Name": ["Jason", "Molly", "Tina", "Jake", "Amy"],
10+
"Last Name": ["Miller", "Jacobson", "Ali", "Milner", "Smith"],
11+
"Age": [42, 52, 36, 24, 73],
12+
}
13+
14+
df = pd.DataFrame(raw_data, columns=["First Name", "Last Name", "Age"])
15+
returned_df = custom_dataframe(df)
16+
17+
if not returned_df.empty:
18+
st.table(returned_df)

0 commit comments

Comments
 (0)