diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e49f0c7b..b2de27906 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* `ui.input_text()`, `ui.input_text_area()`, `ui.input_numeric()` and `ui.input_password()` all gain an `update_on` option. `update_on="change"` is the default and previous behavior, where the input value updates immediately whenever the value changes. With `update_on="blur"`, the input value will update only when the text input loses focus or when the user presses Enter (or Cmd/Ctrl + Enter for `ui.input_text_area()`). (#1874)
+* `shiny.pytest.create_app_fixture(app)` gained support for multiple app file paths when creating your test fixture. If multiple file paths are given, it will behave as a parameterized fixture value and execute the test for each app path. (#1869)
+
### Bug fixes
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 2dd28b423..23f58eacb 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -9,7 +9,8 @@
"_dev",
"docs",
"tests/playwright/deploys/*/app.py",
- "shiny/templates"
+ "shiny/templates",
+ "tests/playwright/shiny/tests_for_ai_generated_apps"
],
"typeCheckingMode": "strict",
"reportImportCycles": "none",
diff --git a/shiny/pytest/_fixture.py b/shiny/pytest/_fixture.py
index e6ed77ddd..385733dd7 100644
--- a/shiny/pytest/_fixture.py
+++ b/shiny/pytest/_fixture.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from pathlib import Path, PurePath
-from typing import Literal, Union
+from typing import Literal
import pytest
@@ -34,20 +34,24 @@
@no_example()
def create_app_fixture(
- app: Union[PurePath, str],
+ app: PurePath | str | list[PurePath | str],
scope: ScopeName = "module",
):
"""
Create a fixture for a local Shiny app directory.
- Creates a fixture for a local Shiny app that is not contained within the same folder. This fixture is used to start the Shiny app process and return the local URL of the app.
+ Creates a fixture for a local Shiny app that is not contained within the same
+ folder. This fixture is used to start the Shiny app process and return the local URL
+ of the app.
- If the app path is located in the same directory as the test file, then `create_app_fixture()` can be skipped and `local_app` test fixture can be used instead.
+ If the app path is located in the same directory as the test file, then
+ `create_app_fixture()` can be skipped and `local_app` test fixture can be used
+ instead.
Parameters
----------
app
- The path to the Shiny app file.
+ The path (or a list of paths) to the Shiny app file.
If `app` is a `Path` or `PurePath` instance and `Path(app).is_file()` returns
`True`, then this value will be used directly. Note, `app`'s file path will be
@@ -58,8 +62,14 @@ def create_app_fixture(
the test function was collected.
To be sure that your `app` path is always relative, supply a `str` value.
+
+ If `app` is a list of path values, then the fixture will be parametrized and each test
+ will be run for each path in the list.
scope
- The scope of the fixture.
+ The scope of the fixture. The default is `module`, which means that the fixture
+ will be created once per module. See [Pytest fixture
+ scopes](https://docs.pytest.org/en/stable/how-to/fixtures.html#fixture-scopes)
+ for more details.
Returns
-------
@@ -85,13 +95,54 @@ def test_app_code(page: Page, app: ShinyAppProc):
# Add test code here
...
```
+
+ ```python
+ from playwright.sync_api import Page
+
+ from shiny.playwright import controller
+ from shiny.pytest import create_app_fixture
+ from shiny.run import ShinyAppProc
+
+ # The variable name `app` MUST match the parameter name in the test function
+ # The tests below will run for each path provided
+ app = create_app_fixture(["relative/path/to/first/app.py", "relative/path/to/second/app.py"])
+
+ def test_app_code(page: Page, app: ShinyAppProc):
+
+ page.goto(app.url)
+ # Add test code here
+ ...
+
+ def test_more_app_code(page: Page, app: ShinyAppProc):
+
+ page.goto(app.url)
+ # Add test code here
+ ...
+ ```
"""
- @pytest.fixture(scope=scope)
- def fixture_func(request: pytest.FixtureRequest):
+ def get_app_path(request: pytest.FixtureRequest, app: PurePath | str):
app_purepath_exists = isinstance(app, PurePath) and Path(app).is_file()
app_path = app if app_purepath_exists else request.path.parent / app
- sa_gen = shiny_app_gen(app_path)
- yield next(sa_gen)
+ return app_path
+
+ if isinstance(app, list):
+
+ # Multiple app values provided
+ # Will display the app value as a parameter in the logs
+ @pytest.fixture(scope=scope, params=app)
+ def fixture_func(request: pytest.FixtureRequest):
+ app_path = get_app_path(request, request.param)
+ sa_gen = shiny_app_gen(app_path)
+ yield next(sa_gen)
+
+ else:
+ # Single app value provided
+ # No indication of the app value in the logs
+ @pytest.fixture(scope=scope)
+ def fixture_func(request: pytest.FixtureRequest):
+ app_path = get_app_path(request, app)
+ sa_gen = shiny_app_gen(app_path)
+ yield next(sa_gen)
return fixture_func
diff --git a/shiny/pytest/_pytest.py b/shiny/pytest/_pytest.py
index 57eea7a10..89535c848 100644
--- a/shiny/pytest/_pytest.py
+++ b/shiny/pytest/_pytest.py
@@ -17,5 +17,7 @@ def local_app(request: pytest.FixtureRequest) -> Generator[ShinyAppProc, None, N
Parameters:
request (pytest.FixtureRequest): The request object for the fixture.
"""
- sa_gen = shiny_app_gen(PurePath(request.path).parent / "app.py")
+ # Get the app_file from the parametrize marker if available
+ app_file = getattr(request, "param", "app.py")
+ sa_gen = shiny_app_gen(PurePath(request.path).parent / app_file)
yield next(sa_gen)
diff --git a/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-core.py b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-core.py
new file mode 100644
index 000000000..071e1d1d3
--- /dev/null
+++ b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-core.py
@@ -0,0 +1,60 @@
+from shiny import App, render, ui
+
+# Define the UI
+app_ui = ui.page_fluid(
+ # Add Font Awesome CSS in the head section
+ ui.tags.head(
+ ui.HTML(
+ ''
+ )
+ ),
+ # Create accordion with panels
+ ui.accordion(
+ # Basic panel
+ ui.accordion_panel(
+ "Panel A", "This is a basic accordion panel with default settings."
+ ),
+ # Panel with custom icon
+ ui.accordion_panel(
+ "Panel B",
+ "This panel has a custom star icon and is open by default.",
+ icon=ui.HTML(''),
+ ),
+ # Basic panel that starts closed
+ ui.accordion_panel(
+ "Panel C", "This is another basic panel that starts closed."
+ ),
+ # Panel with longer content
+ ui.accordion_panel(
+ "Panel D",
+ ui.markdown(
+ """
+ This panel contains longer content to demonstrate scrolling:
+
+ - Item 1
+ - Item 2
+ - Item 3
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
+ """
+ ),
+ ),
+ id="acc_demo",
+ open=["Panel B", "Panel D"],
+ multiple=True,
+ ),
+ # Output for showing which panels are open
+ ui.output_text("selected_panels"),
+)
+
+
+# Define the server
+def server(input, output, session):
+ @render.text
+ def selected_panels():
+ return f"Currently open panels: {input.acc_demo()}"
+
+
+# Create and return the app
+app = App(app_ui, server)
diff --git a/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-express.py b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-express.py
new file mode 100644
index 000000000..db7341b9a
--- /dev/null
+++ b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/app-express.py
@@ -0,0 +1,45 @@
+from shiny.express import input, render, ui
+
+# Add Font Awesome CSS in the head section
+ui.head_content(
+ ui.HTML(
+ ''
+ )
+)
+
+# Create a list of accordion panels with different configurations
+with ui.accordion(id="acc_demo", open=["Panel B", "Panel D"], multiple=True):
+ # Basic panel
+ with ui.accordion_panel("Panel A"):
+ "This is a basic accordion panel with default settings."
+
+ # Panel with custom icon
+ with ui.accordion_panel(
+ "Panel B", icon=ui.HTML('')
+ ):
+ "This panel has a custom star icon and is open by default."
+
+ # Basic panel that starts closed
+ with ui.accordion_panel("Panel C"):
+ "This is another basic panel that starts closed."
+
+ # Panel with longer content
+ with ui.accordion_panel("Panel D"):
+ ui.markdown(
+ """
+ This panel contains longer content to demonstrate scrolling:
+
+ - Item 1
+ - Item 2
+ - Item 3
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
+ """
+ )
+
+
+# Show which panels are currently open
+@render.text
+def selected_panels():
+ return f"Currently open panels: {input.acc_demo()}"
diff --git a/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/test_accordion_core_express.py b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/test_accordion_core_express.py
new file mode 100644
index 000000000..daabc5619
--- /dev/null
+++ b/tests/playwright/shiny/tests_for_ai_generated_apps/accordion/test_accordion_core_express.py
@@ -0,0 +1,69 @@
+from playwright.sync_api import Page
+
+from shiny.playwright import controller
+from shiny.pytest import create_app_fixture
+from shiny.run import ShinyAppProc
+
+app = create_app_fixture(["app-core.py", "app-express.py"])
+
+# For this file, separate the tests to "prove" that the fixture exists for the whole module
+
+
+def test_accordion_demo1(page: Page, app: ShinyAppProc) -> None:
+ page.goto(app.url)
+
+ # Test accordion
+ accordion = controller.Accordion(page, "acc_demo")
+
+ # Test initial state - Panel B and D should be open by default
+ accordion.expect_multiple(True)
+
+ # Test individual panels
+ panel_a = accordion.accordion_panel("Panel A")
+ panel_b = accordion.accordion_panel("Panel B")
+ panel_c = accordion.accordion_panel("Panel C")
+ panel_d = accordion.accordion_panel("Panel D")
+
+ # Test initial states (open/closed)
+ panel_a.expect_open(False)
+ panel_b.expect_open(True) # Should be open by default
+ panel_c.expect_open(False)
+ panel_d.expect_open(True) # Should be open by default
+
+ # Test panel labels
+ panel_a.expect_label("Panel A")
+ panel_b.expect_label("Panel B")
+ panel_c.expect_label("Panel C")
+ panel_d.expect_label("Panel D")
+
+
+def test_accordion_demo2(page: Page, app: ShinyAppProc) -> None:
+ page.goto(app.url)
+
+ # Test accordion
+ accordion = controller.Accordion(page, "acc_demo")
+
+ # Test initial state - Panel B and D should be open by default
+ accordion.expect_multiple(True)
+
+ # Test individual panels
+ panel_a = accordion.accordion_panel("Panel A")
+ panel_b = accordion.accordion_panel("Panel B")
+ panel_c = accordion.accordion_panel("Panel C")
+ # panel_d = accordion.accordion_panel("Panel D")
+
+ # Test panel content
+ panel_a.expect_body("This is a basic accordion panel with default settings.")
+ panel_b.expect_body("This panel has a custom star icon and is open by default.")
+ panel_c.expect_body("This is another basic panel that starts closed.")
+
+ # Test opening and closing panels
+ panel_c.set(True) # Open panel C
+ panel_c.expect_open(True)
+
+ panel_b.set(False) # Close panel B
+ panel_b.expect_open(False)
+
+ # Test the output text showing currently open panels
+ output_text = controller.OutputText(page, "selected_panels")
+ output_text.expect_value("Currently open panels: ('Panel C', 'Panel D')")