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')")