Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 14 additions & 156 deletions pkg-py/tests/playwright/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import TYPE_CHECKING, Any

import pytest
from shiny.pytest import create_app_fixture

# Configure logging for test debugging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -150,82 +151,11 @@ def _create_chat_controller(page: Page, table_name: str) -> ChatControllerType:
return ChatController(page, f"querychat_{table_name}-chat")


def _load_shiny_app(app_path: str) -> Any:
"""
Load a Shiny app from a Python file.

Handles both Shiny Core apps (with explicit `app = App(...)`) and Shiny Express
apps (which use decorators and don't have an explicit app object).

Args:
app_path: Absolute or relative path to the Shiny app Python file.

Returns:
The loaded Shiny App object ready to be served.

Note:
Uses unique module names based on the file path to avoid Python's module
caching, which could cause issues when loading multiple apps in the same
test session.

"""
from shiny.express._is_express import is_express_app
from shiny.express._run import wrap_express_app

path = Path(app_path).resolve()
app_dir = str(path.parent)
app_file = path.name

if is_express_app(app_file, app_dir):
# Express apps don't have an explicit `app` object
return wrap_express_app(path)
else:
# Regular apps have `app = App(...)` at module level
# Use unique module name based on path to avoid caching issues
module_name = f"shiny_app_{path.stem}_{id(path)}"
spec = importlib.util.spec_from_file_location(module_name, str(path))
module = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(module) # type: ignore[union-attr]
return module.app


def _start_shiny_app_threaded(app_path: str, port: int) -> tuple[threading.Thread, Any]:
"""Start a Shiny app in a background thread."""
import uvicorn

app = _load_shiny_app(app_path)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
return thread, server


def _stop_shiny_server(server: Any) -> None:
"""Stop a uvicorn server."""
server.should_exit = True


@pytest.fixture(scope="module")
def app_01_hello() -> Generator[str, None, None]:
"""Start the 01-hello-app.py Shiny server for testing."""
app_path = str(EXAMPLES_DIR / "01-hello-app.py")

def start_factory():
port = _find_free_port()
url = f"http://localhost:{port}"
return url, lambda: _start_shiny_app_threaded(app_path, port)

def shiny_cleanup(_thread, server):
_stop_shiny_server(server)

url, _thread, server = _start_server_with_retry(
start_factory, shiny_cleanup, timeout=30.0
)
try:
yield url
finally:
_stop_shiny_server(server)
# Shiny apps run as subprocesses via shiny.pytest.create_app_fixture.
# Running them in-process (threaded uvicorn) shares Shiny's process-global,
# loop-bound reactive lock across apps, which crashes sessions when apps
# on different event loops contend for it.
app_01_hello = create_app_fixture(EXAMPLES_DIR / "01-hello-app.py", scope="module")


@pytest.fixture
Expand All @@ -234,26 +164,7 @@ def chat_01_hello(page: Page) -> ChatControllerType:
return _create_chat_controller(page, "titanic")


@pytest.fixture(scope="module")
def app_02_prompt() -> Generator[str, None, None]:
"""Start the 02-prompt-app.py Shiny server for testing."""
app_path = str(EXAMPLES_DIR / "02-prompt-app.py")

def start_factory():
port = _find_free_port()
url = f"http://localhost:{port}"
return url, lambda: _start_shiny_app_threaded(app_path, port)

def shiny_cleanup(_thread, server):
_stop_shiny_server(server)

url, _thread, server = _start_server_with_retry(
start_factory, shiny_cleanup, timeout=30.0
)
try:
yield url
finally:
_stop_shiny_server(server)
app_02_prompt = create_app_fixture(EXAMPLES_DIR / "02-prompt-app.py", scope="module")


@pytest.fixture
Expand All @@ -262,26 +173,9 @@ def chat_02_prompt(page: Page) -> ChatControllerType:
return _create_chat_controller(page, "titanic")


@pytest.fixture(scope="module")
def app_03_express() -> Generator[str, None, None]:
"""Start the 03-sidebar-express-app.py Shiny server for testing."""
app_path = str(EXAMPLES_DIR / "03-sidebar-express-app.py")

def start_factory():
port = _find_free_port()
url = f"http://localhost:{port}"
return url, lambda: _start_shiny_app_threaded(app_path, port)

def shiny_cleanup(_thread, server):
_stop_shiny_server(server)

url, _thread, server = _start_server_with_retry(
start_factory, shiny_cleanup, timeout=30.0
)
try:
yield url
finally:
_stop_shiny_server(server)
app_03_express = create_app_fixture(
EXAMPLES_DIR / "03-sidebar-express-app.py", scope="module"
)


@pytest.fixture
Expand All @@ -290,26 +184,9 @@ def chat_03_express(page: Page) -> ChatControllerType:
return _create_chat_controller(page, "titanic")


@pytest.fixture(scope="module")
def app_03_core() -> Generator[str, None, None]:
"""Start the 03-sidebar-core-app.py Shiny server for testing."""
app_path = str(EXAMPLES_DIR / "03-sidebar-core-app.py")

def start_factory():
port = _find_free_port()
url = f"http://localhost:{port}"
return url, lambda: _start_shiny_app_threaded(app_path, port)

def shiny_cleanup(_thread, server):
_stop_shiny_server(server)

url, _thread, server = _start_server_with_retry(
start_factory, shiny_cleanup, timeout=30.0
)
try:
yield url
finally:
_stop_shiny_server(server)
app_03_core = create_app_fixture(
EXAMPLES_DIR / "03-sidebar-core-app.py", scope="module"
)


@pytest.fixture
Expand Down Expand Up @@ -605,26 +482,7 @@ def dash_cleanup(_thread, server):
_stop_dash_server(server)


@pytest.fixture(scope="module")
def app_10_viz() -> Generator[str, None, None]:
"""Start the 10-viz-app.py Shiny server for testing."""
app_path = str(EXAMPLES_DIR / "10-viz-app.py")

def start_factory():
port = _find_free_port()
url = f"http://localhost:{port}"
return url, lambda: _start_shiny_app_threaded(app_path, port)

def shiny_cleanup(_thread, server):
_stop_shiny_server(server)

url, _thread, server = _start_server_with_retry(
start_factory, shiny_cleanup, timeout=30.0
)
try:
yield url
finally:
_stop_shiny_server(server)
app_10_viz = create_app_fixture(EXAMPLES_DIR / "10-viz-app.py", scope="module")


@pytest.fixture
Expand Down
15 changes: 9 additions & 6 deletions pkg-py/tests/playwright/test_01_hello_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

if TYPE_CHECKING:
from playwright.sync_api import Page
from shiny.run import ShinyAppProc
from shinychat.playwright import ChatController


Expand All @@ -23,10 +24,10 @@ class Test01HelloApp:

@pytest.fixture(autouse=True)
def setup(
self, page: Page, app_01_hello: str, chat_01_hello: ChatController
self, page: Page, app_01_hello: ShinyAppProc, chat_01_hello: ChatController
) -> None:
"""Navigate to the app before each test."""
page.goto(app_01_hello)
page.goto(app_01_hello.url)
page.wait_for_selector("table", timeout=10000)
self.page = page
self.chat = chat_01_hello
Expand Down Expand Up @@ -244,7 +245,7 @@ def test_stop_button_appears_during_streaming(self) -> None:
)
self.chat.send_user_input(method="click")

stop_btn = self.page.locator(".shiny-chat-btn-cancel")
stop_btn = self.page.locator('.shiny-chat-btn-send[data-state="cancel"]')
expect(stop_btn).to_be_visible(timeout=30000)

def test_cancel_stops_response(self) -> None:
Expand All @@ -255,7 +256,7 @@ def test_cancel_stops_response(self) -> None:
)
self.chat.send_user_input(method="click")

stop_btn = self.page.locator(".shiny-chat-btn-cancel")
stop_btn = self.page.locator('.shiny-chat-btn-send[data-state="cancel"]')
expect(stop_btn).to_be_visible(timeout=30000)
stop_btn.click()

Expand All @@ -271,7 +272,7 @@ def test_can_send_after_cancel(self) -> None:
)
self.chat.send_user_input(method="click")

stop_btn = self.page.locator(".shiny-chat-btn-cancel")
stop_btn = self.page.locator('.shiny-chat-btn-send[data-state="cancel"]')
expect(stop_btn).to_be_visible(timeout=30000)
stop_btn.click()

Expand All @@ -281,5 +282,7 @@ def test_can_send_after_cancel(self) -> None:
self.chat.set_user_input("How many rows are in the dataset?")
self.chat.send_user_input(method="click")

send_btn = self.page.locator(".shiny-chat-btn-send:not(.shiny-chat-btn-cancel)")
send_btn = self.page.locator(
'.shiny-chat-btn-send:not([data-state="cancel"]):not([data-state="cancelling"])'
)
expect(send_btn).to_be_visible(timeout=60000)
5 changes: 3 additions & 2 deletions pkg-py/tests/playwright/test_02_prompt_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

if TYPE_CHECKING:
from playwright.sync_api import Page
from shiny.run import ShinyAppProc
from shinychat.playwright import ChatController


Expand All @@ -22,10 +23,10 @@ class Test02PromptApp:

@pytest.fixture(autouse=True)
def setup(
self, page: Page, app_02_prompt: str, chat_02_prompt: ChatController
self, page: Page, app_02_prompt: ShinyAppProc, chat_02_prompt: ChatController
) -> None:
"""Navigate to the app before each test."""
page.goto(app_02_prompt)
page.goto(app_02_prompt.url)
page.wait_for_selector("table", timeout=10000)
self.page = page
self.chat = chat_02_prompt
Expand Down
11 changes: 7 additions & 4 deletions pkg-py/tests/playwright/test_03_sidebar_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

if TYPE_CHECKING:
from playwright.sync_api import Page
from shiny.run import ShinyAppProc
from shinychat.playwright import ChatController


Expand All @@ -25,10 +26,10 @@ class Test03SidebarExpress:

@pytest.fixture(autouse=True)
def setup(
self, page: Page, app_03_express: str, chat_03_express: ChatController
self, page: Page, app_03_express: ShinyAppProc, chat_03_express: ChatController
) -> None:
"""Navigate to the app before each test."""
page.goto(app_03_express)
page.goto(app_03_express.url)
# Wait for data table to be visible
page.wait_for_selector("table tbody tr", timeout=15000)
self.page = page
Expand Down Expand Up @@ -114,9 +115,11 @@ class Test03SidebarCore:
"""Tests for 03-sidebar-core-app.py - Shiny Core with sidebar layout."""

@pytest.fixture(autouse=True)
def setup(self, page: Page, app_03_core: str, chat_03_core: ChatController) -> None:
def setup(
self, page: Page, app_03_core: ShinyAppProc, chat_03_core: ChatController
) -> None:
"""Navigate to the app before each test."""
page.goto(app_03_core)
page.goto(app_03_core.url)
# Wait for Shiny data frame to be ready (uses shiny-data-frame custom element)
page.wait_for_selector("shiny-data-frame table", timeout=15000)
self.page = page
Expand Down
Loading
Loading