diff --git a/pkg-py/tests/playwright/conftest.py b/pkg-py/tests/playwright/conftest.py index 3a2db45d..52666305 100644 --- a/pkg-py/tests/playwright/conftest.py +++ b/pkg-py/tests/playwright/conftest.py @@ -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__) @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/pkg-py/tests/playwright/test_01_hello_app.py b/pkg-py/tests/playwright/test_01_hello_app.py index 28912065..4c7d0985 100644 --- a/pkg-py/tests/playwright/test_01_hello_app.py +++ b/pkg-py/tests/playwright/test_01_hello_app.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from playwright.sync_api import Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -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 @@ -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: @@ -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() @@ -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() @@ -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) diff --git a/pkg-py/tests/playwright/test_02_prompt_app.py b/pkg-py/tests/playwright/test_02_prompt_app.py index 4454b107..c41f4779 100644 --- a/pkg-py/tests/playwright/test_02_prompt_app.py +++ b/pkg-py/tests/playwright/test_02_prompt_app.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from playwright.sync_api import Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -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 diff --git a/pkg-py/tests/playwright/test_03_sidebar_apps.py b/pkg-py/tests/playwright/test_03_sidebar_apps.py index 680e9401..245312da 100644 --- a/pkg-py/tests/playwright/test_03_sidebar_apps.py +++ b/pkg-py/tests/playwright/test_03_sidebar_apps.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from playwright.sync_api import Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -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 @@ -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 diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py index 9c34861b..1489c966 100644 --- a/pkg-py/tests/playwright/test_10_viz_inline.py +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from playwright.sync_api import Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -23,9 +24,11 @@ class TestInlineVisualization: """Tests for inline chart rendering in tool result cards.""" @pytest.fixture(autouse=True) - def setup(self, page: Page, app_10_viz: str, chat_10_viz: ChatController) -> None: + def setup( + self, page: Page, app_10_viz: ShinyAppProc, chat_10_viz: ChatController + ) -> None: """Navigate to the viz app before each test.""" - page.goto(app_10_viz) + page.goto(app_10_viz.url) page.wait_for_selector("shiny-chat-container", timeout=30000) greeting = chat_10_viz.loc.locator(".shiny-chat-greeting") expect(greeting).to_contain_text("Welcome", timeout=30000) @@ -40,7 +43,7 @@ def test_viz_tool_renders_inline_chart(self) -> None: self.chat.send_user_input(method="click") # Wait for a tool result card with full-screen attribute (viz results have it) - tool_card = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") + tool_card = self.page.locator(".shiny-tool-card:has(.querychat-viz-container)") expect(tool_card).to_be_visible(timeout=90000) # The card should contain the viz container (Altair chart via shinywidgets) @@ -55,7 +58,7 @@ def test_fullscreen_button_visible_on_viz_card(self) -> None: self.chat.send_user_input(method="click") # Wait for viz tool result - tool_card = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") + tool_card = self.page.locator(".shiny-tool-card:has(.querychat-viz-container)") expect(tool_card).to_be_visible(timeout=90000) # Fullscreen toggle should be visible @@ -71,7 +74,7 @@ def test_fullscreen_toggle_expands_card(self) -> None: # Wait for viz tool result tool_result = self.page.locator( - ".shiny-tool-result:has(.tool-fullscreen-toggle)" + ".shiny-tool-card:has(.querychat-viz-container)" ) expect(tool_result).to_be_visible(timeout=90000) @@ -80,7 +83,9 @@ def test_fullscreen_toggle_expands_card(self) -> None: fs_button.click() # The .shiny-tool-card inside should now have fullscreen attribute - card = tool_result.locator(".shiny-tool-card[fullscreen]") + card = self.page.locator( + ".shiny-tool-card[fullscreen]:has(.querychat-viz-container)" + ) expect(card).to_be_visible() def test_escape_closes_fullscreen(self) -> None: @@ -92,7 +97,7 @@ def test_escape_closes_fullscreen(self) -> None: # Wait for viz tool result tool_result = self.page.locator( - ".shiny-tool-result:has(.tool-fullscreen-toggle)" + ".shiny-tool-card:has(.querychat-viz-container)" ) expect(tool_result).to_be_visible(timeout=90000) @@ -100,7 +105,9 @@ def test_escape_closes_fullscreen(self) -> None: fs_button = tool_result.locator(".tool-fullscreen-toggle") fs_button.click() - card = tool_result.locator(".shiny-tool-card[fullscreen]") + card = self.page.locator( + ".shiny-tool-card[fullscreen]:has(.querychat-viz-container)" + ) expect(card).to_be_visible() # Press Escape @@ -119,13 +126,15 @@ def test_fullscreen_uses_available_height_in_tall_viewport(self) -> None: self.chat.send_user_input(method="click") tool_result = self.page.locator( - ".shiny-tool-result:has(.tool-fullscreen-toggle)" + ".shiny-tool-card:has(.querychat-viz-container)" ) expect(tool_result).to_be_visible(timeout=90000) tool_result.locator(".tool-fullscreen-toggle").click() - card = tool_result.locator(".shiny-tool-card[fullscreen]") + card = self.page.locator( + ".shiny-tool-card[fullscreen]:has(.querychat-viz-container)" + ) expect(card).to_be_visible() viz_container = card.locator(".querychat-viz-container") @@ -150,13 +159,15 @@ def test_fullscreen_footer_does_not_stretch(self) -> None: self.chat.send_user_input(method="click") tool_result = self.page.locator( - ".shiny-tool-result:has(.tool-fullscreen-toggle)" + ".shiny-tool-card:has(.querychat-viz-container)" ) expect(tool_result).to_be_visible(timeout=90000) tool_result.locator(".tool-fullscreen-toggle").click() - card = tool_result.locator(".shiny-tool-card[fullscreen]") + card = self.page.locator( + ".shiny-tool-card[fullscreen]:has(.querychat-viz-container)" + ) expect(card).to_be_visible() footer = card.locator(":scope > .card-footer") diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index be80a283..408f7f8b 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from playwright.sync_api import Download, Locator, Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -86,9 +87,11 @@ def _download_from_save_menu(page: Page, export_format: str) -> tuple[Download, @pytest.fixture(autouse=True) -def _send_viz_prompt(page: Page, app_10_viz: str, chat_10_viz: ChatController) -> None: +def _send_viz_prompt( + page: Page, app_10_viz: ShinyAppProc, chat_10_viz: ChatController +) -> None: """Navigate to the viz app and trigger a visualization before each test.""" - page.goto(app_10_viz) + page.goto(app_10_viz.url) page.wait_for_selector("shiny-chat-container", timeout=30_000) greeting = chat_10_viz.loc.locator(".shiny-chat-greeting") expect(greeting).to_contain_text("Welcome", timeout=30_000) @@ -96,8 +99,10 @@ def _send_viz_prompt(page: Page, app_10_viz: str, chat_10_viz: ChatController) - chat_10_viz.set_user_input(VIZ_PROMPT) chat_10_viz.send_user_input(method="click") - # Wait for the viz tool result card with fullscreen support - page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)").wait_for( + # Wait for the viz tool result card with fullscreen support. On shinychat + # main, routed tool results render as a .shiny-tool-card inside a + # .shiny-chat-tool-group, not a .shiny-tool-result element. + page.locator(".shiny-tool-card:has(.querychat-viz-container)").wait_for( state="visible", timeout=TOOL_RESULT_TIMEOUT ) # Wait for the footer buttons to appear inside the card diff --git a/pkg-py/tests/playwright/test_12_viz_bookmark.py b/pkg-py/tests/playwright/test_12_viz_bookmark.py index c5b4899e..6f9d4097 100644 --- a/pkg-py/tests/playwright/test_12_viz_bookmark.py +++ b/pkg-py/tests/playwright/test_12_viz_bookmark.py @@ -13,50 +13,26 @@ import pytest from playwright.sync_api import expect +from shiny.pytest import create_app_fixture if TYPE_CHECKING: - from collections.abc import Generator - from playwright.sync_api import BrowserContext, Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController as ChatControllerType import sys # conftest.py is not importable directly; add the test directory to sys.path sys.path.insert(0, str(Path(__file__).parent)) -from conftest import ( - _create_chat_controller, - _find_free_port, - _start_server_with_retry, - _start_shiny_app_threaded, - _stop_shiny_server, -) +from conftest import _create_chat_controller VIZ_PROMPT = "Use the visualize tool to create a scatter plot of age vs fare" TOOL_RESULT_TIMEOUT = 90_000 APPS_DIR = Path(__file__).parent / "apps" - -@pytest.fixture(scope="module") -def app_viz_bookmark() -> Generator[str, None, None]: - """Start the viz bookmark test app with server-side bookmarking.""" - app_path = str(APPS_DIR / "viz_bookmark_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_viz_bookmark = create_app_fixture( + APPS_DIR / "viz_bookmark_app.py", scope="module" +) @pytest.fixture @@ -69,14 +45,17 @@ class TestVizBookmarkRestore: @pytest.fixture(autouse=True) def setup( - self, page: Page, app_viz_bookmark: str, chat_viz_bookmark: ChatControllerType + self, + page: Page, + app_viz_bookmark: ShinyAppProc, + chat_viz_bookmark: ChatControllerType, ) -> None: """Navigate to the viz app and create a viz before each test.""" - self.app_url = app_viz_bookmark + self.app_url = app_viz_bookmark.url self.page = page self.chat = chat_viz_bookmark - page.goto(app_viz_bookmark) + page.goto(app_viz_bookmark.url) page.wait_for_selector("shiny-chat-container", timeout=30_000) greeting = chat_viz_bookmark.loc.locator(".shiny-chat-greeting") expect(greeting).to_contain_text("Welcome", timeout=30_000) @@ -87,8 +66,10 @@ def setup( chat_viz_bookmark.set_user_input(VIZ_PROMPT) chat_viz_bookmark.send_user_input(method="click") - # Wait for the viz tool result to fully render - page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)").wait_for( + # Wait for the viz tool result to fully render. On shinychat main, + # routed tool results render as a .shiny-tool-card inside a + # .shiny-chat-tool-group, not a .shiny-tool-result element. + page.locator(".shiny-tool-card:has(.querychat-viz-container)").wait_for( state="visible", timeout=TOOL_RESULT_TIMEOUT ) page.locator(".querychat-footer-buttons").wait_for( diff --git a/pkg-py/tests/playwright/test_13_attachments.py b/pkg-py/tests/playwright/test_13_attachments.py index 55e2a870..13fdde6c 100644 --- a/pkg-py/tests/playwright/test_13_attachments.py +++ b/pkg-py/tests/playwright/test_13_attachments.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from playwright.sync_api import Page + from shiny.run import ShinyAppProc from shinychat.playwright import ChatController @@ -25,9 +26,9 @@ class TestAttachments: @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: - 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 diff --git a/pyproject.toml b/pyproject.toml index 53ec941c..56c51065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ maintainers = [ dependencies = [ "duckdb", "shiny>=1.6.2", - "shinychat>=0.6.0", + "shinychat @ git+https://github.com/posit-dev/shinychat.git@main", "htmltools", "chatlas>=0.18.0", "narwhals>=2.2.0",