Skip to content
Merged
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion pyrightconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 61 additions & 10 deletions shiny/pytest/_fixture.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from pathlib import Path, PurePath
from typing import Literal, Union
from typing import Literal

import pytest

Expand Down Expand Up @@ -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
Expand All @@ -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
-------
Expand All @@ -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
4 changes: 3 additions & 1 deletion shiny/pytest/_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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(
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">'
)
),
# 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('<i class="fa-solid fa-star" style="color: gold;"></i>'),
),
# 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)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from shiny.express import input, render, ui

# Add Font Awesome CSS in the head section
ui.head_content(
ui.HTML(
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">'
)
)

# 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('<i class="fa-solid fa-star" style="color: gold;"></i>')
):
"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()}"
Original file line number Diff line number Diff line change
@@ -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')")
Loading