Skip to content

Commit 9fccb1d

Browse files
committed
Add test for plotly run cli
1 parent 0a6cfd1 commit 9fccb1d

File tree

2 files changed

+167
-0
lines changed

2 files changed

+167
-0
lines changed

.github/workflows/testing.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
# This output will be 'true' if files in the 'table_related_paths' list changed, 'false' otherwise.
1818
table_paths_changed: ${{ steps.filter.outputs.table_related_paths }}
1919
background_cb_changed: ${{ steps.filter.outputs.background_paths }}
20+
cli_changed: ${{ steps.filter.outputs.cli_paths }}
2021
steps:
2122
- name: Checkout repository
2223
uses: actions/checkout@v4
@@ -35,6 +36,11 @@ jobs:
3536
- 'dash/_callback.py'
3637
- 'dash/_callback_context.py'
3738
- 'requirements/**'
39+
cli_paths:
40+
- 'dash/_cli.py'
41+
- 'dash/__main__.py'
42+
- 'dash/dash.py'
43+
- 'tests/tooling/test_cli.py'
3844
3945
build:
4046
name: Build Dash Package
@@ -135,6 +141,43 @@ jobs:
135141
run: |
136142
cd tests
137143
pytest compliance/test_typing.py
144+
145+
test-run-cli:
146+
name: Test plotly run CLI
147+
runs-on: ubuntu-latest
148+
needs: [build, changes_filter]
149+
if: |
150+
(github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev')) ||
151+
needs.changes_filter.outputs.cli_changed == 'true'
152+
timeout-minutes: 30
153+
strategy:
154+
fail-fast: false
155+
156+
steps:
157+
- name: Checkout code
158+
uses: actions/checkout@v4
159+
160+
- name: Set up Python 3.12
161+
uses: actions/setup-python@v5
162+
with:
163+
python-version: '3.12'
164+
cache: 'pip'
165+
166+
- name: Download built Dash packages
167+
uses: actions/download-artifact@v4
168+
with:
169+
name: dash-packages
170+
path: packages/
171+
172+
- name: Install Dash packages
173+
run: |
174+
python -m pip install --upgrade pip wheel
175+
python -m pip install "setuptools<80.0.0"
176+
find packages -name dash-*.whl -print -exec sh -c 'pip install "{}[ci,testing]"' \;
177+
178+
- name: Run CLI tests
179+
run: |
180+
pytest tests/tooling/test_cli.py
138181
139182
background-callbacks:
140183
name: Run Background Callback Tests (Python ${{ matrix.python-version }})

tests/tooling/test_cli.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import subprocess
2+
import sys
3+
import time
4+
from typing import List
5+
import socket
6+
from pathlib import Path
7+
8+
import requests
9+
import pytest
10+
11+
12+
# This is the content of the dummy Dash app we'll create for the test.
13+
APP_CONTENT = """
14+
from dash import Dash, html
15+
16+
# The unique string we will check for in the test
17+
CUSTOM_INDEX_STRING = "Hello Dash CLI Test World"
18+
19+
app = Dash(__name__)
20+
21+
# Override the default index HTML to include our custom string
22+
app.index_string = f'''
23+
<!DOCTYPE html>
24+
<html>
25+
<head>
26+
{{%metas%}}
27+
<title>{{%title%}}</title>
28+
{{%css%}}
29+
</head>
30+
<body>
31+
<h1>{CUSTOM_INDEX_STRING}</h1>
32+
{{%app_entry%}}
33+
<footer>
34+
{{%config%}}
35+
{{%scripts%}}
36+
{{%renderer%}}
37+
</footer>
38+
</body>
39+
</html>
40+
'''
41+
42+
app.layout = html.Div("This is the app layout.")
43+
"""
44+
45+
46+
# Helper function to find an available network port
47+
def find_free_port():
48+
"""Finds a free port on the local machine."""
49+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
50+
s.bind(("", 0))
51+
return s.getsockname()[1]
52+
53+
54+
@pytest.mark.parametrize("app_path", ["app:app", "app"])
55+
@pytest.mark.parametrize("cmd", [[sys.executable, "-m", "dash"], ["plotly"]])
56+
def test_run_command_serves_app(tmp_path: Path, app_path: str, cmd: List[str]):
57+
"""
58+
Tests that the `run` command successfully serves a Dash app.
59+
"""
60+
# 1. Setup: Create the app in a temporary directory
61+
app_dir = tmp_path / "my_test_app"
62+
app_dir.mkdir()
63+
(app_dir / "app.py").write_text(APP_CONTENT)
64+
65+
port = find_free_port()
66+
url = f"http://127.0.0.1:{port}"
67+
68+
# Command to execute. We run the cli.py script directly with the python
69+
# interpreter that is running pytest. This is more robust than assuming
70+
# an entry point is on the PATH.
71+
command = [
72+
*cmd,
73+
"run",
74+
str(app_path),
75+
"--port",
76+
str(port),
77+
]
78+
79+
process = None
80+
try:
81+
# 2. Execution: Start the CLI command as a background process
82+
# The working directory `cwd` is crucial so that "import app" works.
83+
process = subprocess.Popen( # pylint: disable=consider-using-with
84+
command,
85+
cwd=str(app_dir),
86+
stdout=subprocess.PIPE,
87+
stderr=subprocess.PIPE,
88+
text=True,
89+
)
90+
91+
# Give the server a moment to start up.
92+
time.sleep(3)
93+
94+
# Check if the process terminated unexpectedly
95+
if process.poll() is not None:
96+
stdout, stderr = process.communicate()
97+
pytest.fail(
98+
f"The CLI process terminated prematurely.\n"
99+
f"Exit Code: {process.returncode}\n"
100+
f"STDOUT:\n{stdout}\n"
101+
f"STDERR:\n{stderr}"
102+
)
103+
104+
# 3. Verification: Make a request to the running server
105+
response = requests.get(url, timeout=10)
106+
response.raise_for_status() # Check for HTTP errors like 404 or 500
107+
108+
# 4. Assertion: Check for the custom content from the app
109+
assert "Hello Dash CLI Test World" in response.text
110+
print(f"\nSuccessfully fetched app from {url}")
111+
112+
finally:
113+
# 5. Teardown: Ensure the server process is always terminated
114+
if process:
115+
print(f"\nTerminating server process (PID: {process.pid})")
116+
process.terminate()
117+
# Use communicate() to wait for process to die and get output
118+
try:
119+
stdout, stderr = process.communicate(timeout=5)
120+
print(f"Server process STDOUT:\n{stdout}")
121+
print(f"Server process STDERR:\n{stderr}")
122+
except subprocess.TimeoutExpired:
123+
print("Process did not terminate gracefully, killing.")
124+
process.kill()

0 commit comments

Comments
 (0)