diff --git a/.github/workflows/conventional-commits.yaml b/.github/workflows/verify-conventional-commits.yaml similarity index 100% rename from .github/workflows/conventional-commits.yaml rename to .github/workflows/verify-conventional-commits.yaml diff --git a/.github/workflows/verify-test-generation-prompts.yaml b/.github/workflows/verify-test-generation-prompts.yaml new file mode 100644 index 000000000..2e1301884 --- /dev/null +++ b/.github/workflows/verify-test-generation-prompts.yaml @@ -0,0 +1,97 @@ +name: Verify test generation prompts + +on: + pull_request: + paths: + - ".github/workflows/verify-test-generation-prompts.yml" + - "shiny/pytest/_generate/**" + workflow_dispatch: + +concurrency: + group: "prompt-test-generation-${{ github.event.pull_request.number || 'dispatch' }}" + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.13" + ATTEMPTS: 3 + PYTHONUNBUFFERED: 1 + +jobs: + verify-test-generation-prompts: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup py-shiny + id: install + uses: ./.github/py-shiny/setup + + - name: Install Test Generator Dependencies + run: | + make ci-install-ai-deps + + - name: Run Evaluation and Tests 3 Times + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + PYTHONUNBUFFERED: 1 + timeout-minutes: 25 + run: | + make run-test-ai-evaluation + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ github.run_id }} + path: | + test-results-inspect-ai/ + retention-days: 7 + + - name: Process Results + timeout-minutes: 2 + run: | + # Results are already averaged by the bash script, just verify they exist + if [ ! -f "test-results-inspect-ai/summary.json" ]; then + echo "No averaged summary found at test-results-inspect-ai/summary.json" + ls -la test-results-inspect-ai/ + exit 1 + else + echo "Using averaged results from all attempts" + cat test-results-inspect-ai/summary.json + fi + + - name: Check Quality Gate + timeout-minutes: 2 + run: | + if [ ! -f "test-results-inspect-ai/summary.json" ]; then + echo "Summary file not found at test-results-inspect-ai/summary.json" + ls -la test-results-inspect-ai/ + exit 1 + else + echo "Found summary file, checking quality gate..." + python tests/inspect-ai/utils/scripts/quality_gate.py test-results-inspect-ai/ + fi + + - name: Prepare Comment Body + if: github.event_name == 'pull_request' + timeout-minutes: 1 + run: | + python tests/inspect-ai/scripts/prepare_comment.py test-results-inspect-ai/summary.json + + - name: Comment PR Results + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: inspect-ai-results + path: comment_body.txt diff --git a/.github/workflows/verify-testing-docs-on-change.yml b/.github/workflows/verify-testing-docs-on-change.yml new file mode 100644 index 000000000..7667a581b --- /dev/null +++ b/.github/workflows/verify-testing-docs-on-change.yml @@ -0,0 +1,93 @@ +name: Verify testing documentation for changes + +on: + pull_request: + paths: + - ".github/workflows/verify-testing-docs-on-change.yml" + - "docs/_quartodoc-testing.yml" + - "shiny/playwright/controller/**" + +permissions: + contents: write + pull-requests: write + +jobs: + verify-testing-docs: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup py-shiny + id: install + uses: ./.github/py-shiny/setup + + - name: Install dependencies + run: | + make ci-install-docs + + - name: Update testing docs and check for changes + id: check-docs-changes + run: | + # Store the current state of the documentation file + cp shiny/pytest/_generate/_data/testing-documentation.json testing-documentation-before.json + + # Run the make command to update testing docs + make update-testing-docs + + if [[ ! -f testing-documentation-before.json || ! -f shiny/pytest/_generate/_data/testing-documentation.json ]]; then + echo "One or both documentation files are missing." + exit 1 + fi + + # Check if the documentation file has changed + if diff -q testing-documentation-before.json shiny/pytest/_generate/_data/testing-documentation.json > /dev/null 2>&1; then + echo "docs_changed=true" >> $GITHUB_OUTPUT + echo "The generated documentation is out of sync with the current controller changes." + echo "\n\n" + diff -q testing-documentation-before.json shiny/pytest/_generate/_data/testing-documentation.json || true + echo "\n\n" + else + echo "docs_changed=false" >> $GITHUB_OUTPUT + echo "Documentation file is up to date" + fi + + - name: Comment on PR about testing docs update + if: steps.check-docs-changes.outputs.docs_changed == 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: testing-docs-update + message: | + 🚨 **Testing Documentation Out of Sync** + + We detected changes in the `shiny/playwright/controller` directory that affect the testing documentation used by the `shiny add test` command. + + **The generated documentation is out of sync with your controller changes. Please run:** + + ```bash + make update-testing-docs + ``` + + **Then commit the updated `shiny/pytest/_generate/_data/testing-documentation.json` file.** + +
Additional details + + The updated documentation file ensures that the AI test generator has access to the latest controller API documentation. + +
+ + ❌ **This check will fail until the documentation is updated and committed.** + + --- + *This comment was automatically generated by the `verify-testing-docs-on-change.yml` workflow.* + + - name: Remove comment when no controller changes or docs are up to date + if: steps.check-docs-changes.outputs.docs_changed == 'false' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: testing-docs-update + delete: true diff --git a/.gitignore b/.gitignore index 3982f5270..84e4e9e33 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,10 @@ shiny_bookmarks/ # setuptools_scm shiny/_version.py + +# Other +tests/inspect-ai/apps/*/test_*.py +test-results.xml +results-inspect-ai/ +test-results-inspect-ai/ +tests/inspect-ai/scripts/test_metadata.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f710cf6d6..822b0af31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features +* Added AI-powered test generator for Shiny applications. Use `shiny add test` to automatically generate comprehensive Playwright tests for your apps using AI models from Anthropic or OpenAI. (#2041) + * `navset_card_*()` now has a `full_screen` option to support `card()`'s existing full-screen functionality. (#1451) * Added `ui.insert_nav_panel()`, `ui.remove_nav_panel()`, and `ui.update_nav_panel()` to support dynamic navigation. (#90) diff --git a/Makefile b/Makefile index ea830e17e..0ed82d6ec 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,35 @@ docs-quartodoc: FORCE @echo "-------- Making quartodoc docs --------" @cd docs && make quartodoc +install-repomix: install-npm FORCE ## Install repomix if not already installed + @echo "-------- Installing repomix if needed --------" + @if ! command -v repomix > /dev/null 2>&1; then \ + echo "Installing repomix..."; \ + npm install -g repomix; \ + else \ + echo "repomix is already installed"; \ + fi + +update-testing-docs-repomix: install-repomix FORCE ## Generate repomix output for testing docs + @echo "-------- Generating repomix output for testing docs --------" + repomix docs/api/testing -o tests/inspect-ai/utils/scripts/repomix-output-testing.xml + +update-testing-docs-process: FORCE ## Process repomix output to generate testing documentation JSON + @echo "-------- Processing testing documentation --------" + python tests/inspect-ai/utils/scripts/process_docs.py --input tests/inspect-ai/utils/scripts/repomix-output-testing.xml --output shiny/pytest/_generate/_data/testing-documentation.json + @echo "-------- Cleaning up temporary files --------" + rm -f tests/inspect-ai/utils/scripts/repomix-output-testing.xml + +update-testing-docs: docs update-testing-docs-repomix update-testing-docs-process FORCE ## Update testing documentation (full pipeline) + @echo "-------- Testing documentation update complete --------" + +ci-install-ai-deps: FORCE + uv pip install -e ".[dev,test,testgen]" + $(MAKE) install-playwright + +run-test-ai-evaluation: FORCE ## Run the AI evaluation script for tests + @echo "-------- Running AI evaluation for tests --------" + bash ./tests/inspect-ai/scripts/run-test-evaluation.sh install-npm: FORCE $(if $(shell which npm), @echo -n, $(error Please install node.js and npm first. See https://nodejs.org/en/download/ for instructions.)) diff --git a/pyproject.toml b/pyproject.toml index f406ec805..c5e39fc0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,13 @@ doc = [ "quartodoc>=0.8.1", "griffe>=1.3.2", ] +testgen = [ + "chatlas[anthropic,openai]", + "openai>=1.104.1", + "anthropic>=0.62.0", + "inspect-ai>=0.3.129", + "pytest-timeout", +] [project.urls] diff --git a/pyrightconfig.json b/pyrightconfig.json index 236aed7fc..722bdae8f 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -10,7 +10,10 @@ "docs", "tests/playwright/deploys/*/app.py", "shiny/templates", - "tests/playwright/ai_generated_apps", + "tests/playwright/ai_generated_apps/*/*/app*.py", + "tests/inspect-ai/apps/*/app*.py", + "shiny/pytest/_generate/_main.py", + "tests/inspect-ai/scripts/evaluation.py" ], "typeCheckingMode": "strict", "reportImportCycles": "none", diff --git a/shiny/_main.py b/shiny/_main.py index 65b083139..f43421a71 100644 --- a/shiny/_main.py +++ b/shiny/_main.py @@ -533,11 +533,10 @@ def add() -> None: @add.command( help="""Add a test file for a specified Shiny app. -Add an empty test file for a specified app. You will be prompted with a destination -folder. If you don't provide a destination folder, it will be added in the current -working directory based on the app name. +Generate a comprehensive test file for a specified app using AI. The generator +will analyze your app code and create appropriate test cases with assertions. -After creating the shiny app file, you can use `pytest` to run the tests: +After creating the test file, you can use `pytest` to run the tests: pytest TEST_FILE """ @@ -546,22 +545,37 @@ def add() -> None: "--app", "-a", type=str, - help="Please provide the path to the app file for which you want to create a test file.", + help="Path to the app file for which you want to generate a test file.", ) @click.option( "--test-file", "-t", type=str, - help="Please provide the name of the test file you want to create. The basename of the test file should start with `test_` and be unique across all test files.", + help="Path for the generated test file. If not provided, will be auto-generated.", +) +@click.option( + "--provider", + type=click.Choice(["anthropic", "openai"]), + default="anthropic", + help="AI provider to use for test generation.", +) +@click.option( + "--model", + type=str, + help="Specific model to use (optional). Examples: haiku3.5, sonnet, gpt-5, gpt-5-mini", ) # Param for app.py, param for test_name def test( - app: Path | None, - test_file: Path | None, + app: str | None, + test_file: str | None, + provider: str, + model: str | None, ) -> None: - from ._main_add_test import add_test_file + from ._main_generate_test import generate_test_file - add_test_file(app_file=app, test_file=test_file) + generate_test_file( + app_file=app, output_file=test_file, provider=provider, model=model + ) @main.command( diff --git a/shiny/_main_add_test.py b/shiny/_main_add_test.py deleted file mode 100644 index 7393054d0..000000000 --- a/shiny/_main_add_test.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import click -import questionary - -from ._main_utils import cli_action, cli_bold, cli_code, path_rel_wd - - -def add_test_file( - *, - app_file: Path | None, - test_file: Path | None, -): - if app_file is None: - - def path_exists(x: Path) -> bool | str: - if not isinstance(x, (str, Path)): - return False - if Path(x).is_dir(): - return "Please provide a file path to your Shiny app" - return Path(x).exists() or f"Shiny app file can not be found: {x}" - - app_file_val = questionary.path( - "Enter the path to the app file:", - default=path_rel_wd("app.py"), - validate=path_exists, - ).ask() - else: - app_file_val = app_file - # User quit early - if app_file_val is None: - sys.exit(1) - app_file = Path(app_file_val) - - if test_file is None: - - def path_does_not_exist(x: Path) -> bool | str: - if not isinstance(x, (str, Path)): - return False - if Path(x).is_dir(): - return "Please provide a file path for your test file." - if Path(x).exists(): - return "Test file already exists. Please provide a new file name." - if not Path(x).name.startswith("test_"): - return "Test file must start with 'test_'" - return True - - test_file_val = questionary.path( - "Enter the path to the test file:", - default=path_rel_wd( - os.path.relpath(app_file.parent / "tests" / "test_app.py", ".") - ), - validate=path_does_not_exist, - ).ask() - else: - test_file_val = test_file - - # User quit early - if test_file_val is None: - sys.exit(1) - test_file = Path(test_file_val) - - # Make sure app file exists - if not app_file.exists(): - raise FileExistsError("App file does not exist: ", test_file) - # Make sure output test file doesn't exist - if test_file.exists(): - raise FileExistsError("Test file already exists: ", test_file) - if not test_file.name.startswith("test_"): - return "Test file must start with 'test_'" - - test_name = test_file.name.replace(".py", "") - rel_path = os.path.relpath(app_file, test_file.parent) - - template = f"""\ -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("{rel_path}") - - -def {test_name}(page: Page, app: ShinyAppProc): - - page.goto(app.url) - # Add test code here -""" - # Make sure test file directory exists - test_file.parent.mkdir(parents=True, exist_ok=True) - - # Write template to test file - test_file.write_text(template) - - # next steps - click.echo() - click.echo(cli_action(cli_bold("Next steps:"))) - click.echo(f"- Run {cli_code('pytest')} in your terminal to run all the tests") diff --git a/shiny/_main_generate_test.py b/shiny/_main_generate_test.py new file mode 100644 index 000000000..bdca96a7b --- /dev/null +++ b/shiny/_main_generate_test.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Callable + +import click +import questionary + +from ._main_utils import cli_action, cli_bold, cli_code, path_rel_wd + + +class ValidationError(Exception): + pass + + +def create_file_validator( + file_type: str, + must_exist: bool = True, + prefix_required: str | None = None, + must_not_exist: bool = False, +) -> Callable[[str], bool | str]: + def validator(path_str: str) -> bool | str: + if not isinstance(path_str, (str, Path)): + return False + + path = Path(path_str) + + if path.is_dir(): + return f"Please provide a file path for your {file_type}" + + if must_exist and not path.exists(): + return f"{file_type.title()} file not found: {path_str}" + + if must_not_exist and path.exists(): + return f"{file_type.title()} file already exists. Please provide a new file name." + + if prefix_required and not path.name.startswith(prefix_required): + return f"{file_type.title()} file must start with '{prefix_required}'" + + return True + + return validator + + +def validate_api_key(provider: str) -> None: + api_configs = { + "anthropic": { + "env_var": "ANTHROPIC_API_KEY", + "url": "https://console.anthropic.com/", + }, + "openai": { + "env_var": "OPENAI_API_KEY", + "url": "https://platform.openai.com/api-keys", + }, + } + + if provider not in api_configs: + raise ValidationError(f"Unsupported provider: {provider}") + + config = api_configs[provider] + if not os.getenv(config["env_var"]): + raise ValidationError( + f"{config['env_var']} environment variable is not set.\n" + f"Please set your {provider.title()} API key:\n" + f" export {config['env_var']}='your-api-key-here'\n\n" + f"Get your API key from: {config['url']}" + ) + + +def get_app_file_path(app_file: str | None) -> Path: + if app_file is not None: + app_path = Path(app_file) + if not app_path.exists(): + raise ValidationError(f"App file does not exist: {app_path}") + return app_path + # Interactive mode + app_file_val = questionary.path( + "Enter the path to the app file:", + default=path_rel_wd("app.py"), + validate=create_file_validator("Shiny app", must_exist=True), + ).ask() + + if app_file_val is None: + sys.exit(1) + + return Path(app_file_val) + + +def get_output_file_path(output_file: str | None, app_path: Path) -> Path: + if output_file is not None: + output_path = Path(output_file) + if output_path.exists(): + raise ValidationError(f"Test file already exists: {output_path}") + if not output_path.name.startswith("test_"): + raise ValidationError("Test file must start with 'test_'") + return output_path + # Interactive mode + suggested_output = app_path.parent / f"test_{app_path.stem}.py" + + output_file_val = questionary.path( + "Enter the path for the generated test file:", + default=str(suggested_output), + validate=create_file_validator( + "test", must_exist=False, prefix_required="test_", must_not_exist=True + ), + ).ask() + + if output_file_val is None: + sys.exit(1) + + return Path(output_file_val) + + +def generate_test_file( + *, + app_file: str | None, + output_file: str | None, + provider: str, + model: str | None, +) -> None: + + try: + validate_api_key(provider) + + app_path = get_app_file_path(app_file) + output_path = get_output_file_path(output_file, app_path) + + try: + from .pytest._generate import ShinyTestGenerator + except ImportError as e: + raise ValidationError( + f"Could not import ShinyTestGenerator: {e}\n" + "Make sure the shiny testing dependencies are installed." + ) + + click.echo(f"🤖 Generating test using {provider} provider...") + if model: + click.echo(f"📝 Using model: {model}") + + generator = ShinyTestGenerator(provider=provider, setup_logging=False) # type: ignore + _, test_file_path = generator.generate_test_from_file( + app_file_path=str(app_path), + model=model, + output_file=str(output_path), + ) + + relative_test_file_path = test_file_path.relative_to(Path.cwd()) + + click.echo(f"✅ Test file generated successfully: {relative_test_file_path}") + click.echo() + click.echo(cli_action(cli_bold("Next steps:"))) + click.echo( + f"- Run {cli_code('pytest ' + str(relative_test_file_path))} to run the generated test" + ) + click.echo("- Review and customize the test as needed") + + except ValidationError as e: + click.echo(f"❌ Error: {e}") + sys.exit(1) + except Exception as e: + click.echo(f"❌ Error generating test: {e}") + sys.exit(1) diff --git a/shiny/pytest/_generate/__init__.py b/shiny/pytest/_generate/__init__.py new file mode 100644 index 000000000..0e544db3f --- /dev/null +++ b/shiny/pytest/_generate/__init__.py @@ -0,0 +1,7 @@ +""" +This module is internal; public-facing imports should not rely on its location. +""" + +from ._main import ShinyTestGenerator + +__all__ = ["ShinyTestGenerator"] diff --git a/shiny/pytest/_generate/_data/testing-SYSTEM_PROMPT.md b/shiny/pytest/_generate/_data/testing-SYSTEM_PROMPT.md new file mode 100644 index 000000000..22fc74297 --- /dev/null +++ b/shiny/pytest/_generate/_data/testing-SYSTEM_PROMPT.md @@ -0,0 +1,211 @@ +# Shiny for Python Playwright Testing Expert + +Generate comprehensive Playwright smoke tests for Shiny for Python applications. + +## Framework Check +For non-Shiny Python code, respond: "This framework is for Shiny for Python only. For [Framework], use the appropriate testing framework (e.g., shinytest2 for Shiny for R)." + +## Core Rules + +1. **Dynamic App File**: When generating code that uses `create_app_fixture`, follow these rules: + - Use the exact filename provided in the prompt. + - ALWAYS make paths relative from the test file directory to the app file. + - For tests in `app_dir/tests` and app in `app_dir/app.py`: + - ✅ `app = create_app_fixture(["../app.py"])` + - ❌ `app = create_app_fixture(["app.py"])` + - For tests in `tests/subdir` and app in `apps/subdir/app.py`: + - ✅ `app = create_app_fixture(["../../apps/subdir/app.py"])` + - NEVER use absolute paths. + - Calculate the correct relative path based on the test file location and app file location. + +2. **Controller Classes Only**: Always use official controllers, never `page.locator()` + - ✅ `controller.InputSlider(page, "my_slider")` + - ❌ `page.locator("#my_slider")` + +3. **String Values**: All assertions use strings + - ✅ `expect_max("15")` + - ❌ `expect_max(15)` + +4. **Test Pattern**: Assert → Act → Assert + - Assert initial state (value, label, linked outputs) + - Act (set, click, etc.) + - Assert final state (re-check input + outputs) + +5. **Scope**: Only test Shiny components with unique IDs. + +6. **Selectize Clear**: Use `set([])` to clear all values in Selectize inputs. + - ✅ `selectize.set([])` + - ❌ `selectize.set("")` + +7. **Skip icons**: Do not test icon functionality i.e. using tests like `expect_icon("icon_name")`. + - ❌ `btn2.expect_icon("fa-solid fa-shield-halved")` + +8. **Skip plots**: Do not test any OutputPlot content or functionality i.e. using `OutputPlot` controller. + - ❌ plot1 = controller.OutputPlot(page, "my_plot_module-plot1") + - ❌ plot1.expect_title("Random Scatter Plot") + +9. **Keyword-Only Args**: Always pass every argument as a keyword for every controller method. + - ✅ `expect_cell(value="0", row=1, col=2)` + - ❌ `expect_cell("0", 1, 2)` + +10. **Newline at End**: Always end files with a newline. + +**11. DataFrames:** OutputDataFrame tests use **zero-based indexing**, so +`data_grid.expect_cell(value="Action Button", row=0, col=0)` +verifies the cell in the first row and first column, not the headers. + +## Examples + +### Checkbox Group +```python +# apps/app_checkbox.py +from shiny.express import input, ui, render +ui.input_checkbox_group("basic", "Choose:", ["A", "B"], selected=["A"]) +@render.text +def output(): return f"Selected: {input.basic()}" + +# apps/test_app_checkbox.py + +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_checkbox.py"]) + +def test_checkbox(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + basic = controller.InputCheckboxGroup(page, "basic") + output = controller.OutputText(page, "output") + + # Assert initial + basic.expect_selected(["A"]) + output.expect_value("Selected: ('A',)") + + # Act + basic.set(["A", "B"]) + + # Assert final + basic.expect_selected(["A", "B"]) + output.expect_value("Selected: ('A', 'B')") +``` + +### Date Input +```python +# app_date.py +from shiny.express import input, ui +ui.input_date("date1", "Date:", value="2024-01-01") + +# tests/test_app_date.py +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_date.py"]) + + +def test_date(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + date1 = controller.InputDate(page, "date1") + + date1.expect_value("2024-01-01") + date1.set("2024-02-01") + date1.expect_value("2024-02-01") +``` + +### Selectize with Updates +```python +# app_selectize.py +from shiny import reactive +from shiny.express import input, ui, render +ui.input_selectize("select1", "State:", {"NY": "New York", "CA": "California"}) +ui.input_action_button("update_btn", "Update") +@render.text +def output(): return f"Selected: {input.select1()}" + +@reactive.effect +@reactive.event(input.update_btn) +def _(): ui.update_selectize("select1", selected="CA") + +# test_app_selectize.py +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_selectize.py"]) + + +def test_selectize(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + select1 = controller.InputSelectize(page, "select1") + output = controller.OutputText(page, "output") + btn = controller.InputActionButton(page, "update_btn") + + # Initial state + select1.expect_selected(["NY"]) + output.expect_value("Selected: NY") + + # Act + btn.click() + + # Final state + select1.expect_selected(["CA"]) + output.expect_value("Selected: CA") +``` + +### Navset Card Pill Navigation +```python +# app_express.py +from shiny.express import input, render, ui + +with ui.navset_card_pill(id="selected_navset_card_pill"): + with ui.nav_panel("A"): + "Panel A content" + + with ui.nav_panel("B"): + "Panel B content" + + with ui.nav_panel("C"): + "Panel C content" + +ui.h5("Selected:") + + +@render.text +def _(): + return input.selected_navset_card_pill() + +# test_app_express.py +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-express.py"]) + + +def test_navset_card_pill(page: Page, app: ShinyAppProc) -> None: + page.goto(app.url) + navset = controller.NavsetCardPill(page, "selected_navset_card_pill") + output_text = controller.OutputText(page, "_") + + # Assert initial state - first panel should be active + navset.expect_value("A") + output_text.expect_value("A") + + # Act - navigate to panel B + navset.set("B") + + # Assert final state + navset.expect_value("B") + output_text.expect_value("B") + + # Act - navigate to panel C + navset.set("C") + + # Assert final state + navset.expect_value("C") + output_text.expect_value("C") +``` diff --git a/shiny/pytest/_generate/_data/testing-documentation.json b/shiny/pytest/_generate/_data/testing-documentation.json new file mode 100644 index 000000000..68ed58fbd --- /dev/null +++ b/shiny/pytest/_generate/_data/testing-documentation.json @@ -0,0 +1,2127 @@ +[ + { + "controller_name": "playwright.controller.Accordion", + "methods": [ + { + "name": "accordion_panel", + "description": "Returns the accordion panel ([](:class:`~shiny.playwright.controls.AccordionPanel`)) with the specified data value.", + "parameters": "data_value (str)" + }, + { + "name": "expect_class", + "description": "Expects the accordion to have the specified class.", + "parameters": "class_name (str), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Expects the accordion to have the specified height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_multiple", + "description": "Expects the accordion to be multiple or not.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_panels", + "description": "Expects the accordion to have the specified panels.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expects the accordion to have the specified width.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the state of the accordion panel.", + "parameters": "open (str \\), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.AccordionPanel", + "methods": [ + { + "name": "expect_body", + "description": "Expects the accordion panel body to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expects the accordion panel icon to exist or not.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expects the accordion panel label to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_open", + "description": "Expects the accordion panel to be open or closed.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.Card", + "methods": [ + { + "name": "expect_body", + "description": "Expect the card body element to have the specified text.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_footer", + "description": "Expects the card footer to have a specific text.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_full_screen", + "description": "Verifies if the full screen mode is currently open.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_full_screen_available", + "description": "Expects whether full screen mode is available for the element.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_header", + "description": "Expects the card header to have a specific text.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Expects the card to have a specific height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_max_height", + "description": "Expects the card to have a specific maximum height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_min_height", + "description": "Expects the card to have a specific minimum height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set_full_screen", + "description": "Sets the element to full screen mode or exits full screen mode.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.Chat", + "methods": [ + { + "name": "expect_latest_message", + "description": "Expects the last message in the chat.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_messages", + "description": "Expects the chat messages.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_user_input", + "description": "Expects the user message in the chat.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "send_user_input", + "description": "Sends the user message in the chat.", + "parameters": "method (Literal\\['enter', 'click'\\]), timeout (Timeout)" + }, + { + "name": "set_user_input", + "description": "Sets the user message in the chat.", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.DownloadButton", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.DownloadLink", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputActionButton", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_disabled", + "description": "Expect the input action button to be disabled.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputActionLink", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputBookmarkButton", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_disabled", + "description": "Expect the input bookmark button to be disabled.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputCheckbox", + "methods": [ + { + "name": "expect_checked", + "description": "Expect the input checkbox to be checked.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the input checkbox.", + "parameters": "value (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputCheckboxGroup", + "methods": [ + { + "name": "expect_choice_labels", + "description": "Expect the labels of the choices.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choices", + "description": "Expect the checkbox choices.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Expect the input to be inline.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_selected", + "description": "Expect the selected checkboxes.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Set the selected checkboxes.", + "parameters": "selected (ListOrTuple\\[str\\]), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputDarkMode", + "methods": [ + { + "name": "click", + "description": "Clicks the input dark mode.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_attribute", + "description": "Expect the attribute named `attribute` of the input dark mode to have a specific value.", + "parameters": "value (str), timeout (Timeout)" + }, + { + "name": "expect_mode", + "description": "Expect the `mode` attribute of the input dark mode to have a specific value.", + "parameters": "value (str), timeout (Timeout)" + }, + { + "name": "expect_page_mode", + "description": "Expect the page to have a specific dark mode value.", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputDate", + "methods": [ + { + "name": "expect_autoclose", + "description": "Asserts that the input element has the expected `data-date-autoclose` attribute value.", + "parameters": "value (Literal\\['true', 'false'\\]), timeout (Timeout)" + }, + { + "name": "expect_datesdisabled", + "description": "Asserts that the input element has the expected `data-date-dates-disabled` attribute value.", + "parameters": "value (list\\[str\\] \\), timeout (Timeout)" + }, + { + "name": "expect_daysofweekdisabled", + "description": "Asserts that the input element has the expected `data-date-days-of-week-disabled` attribute value.", + "parameters": "value (list\\[int\\] \\), timeout (Timeout)" + }, + { + "name": "expect_format", + "description": "Asserts that the input element has the expected `data-date-format` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_language", + "description": "Asserts that the input element has the expected `data-date-language` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_max_date", + "description": "Asserts that the input element has the expected `data-max-date` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_min_date", + "description": "Asserts that the input element has the expected `data-min-date` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_startview", + "description": "Asserts that the input element has the expected `data-date-start-view` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the input element has the expected value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_weekstart", + "description": "Asserts that the input element has the expected `data-date-week-start` attribute value.", + "parameters": "value (int \\), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the text value", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputDateRange", + "methods": [ + { + "name": "expect_autoclose", + "description": "Asserts that the input element has the expected autoclose value.", + "parameters": "value (Literal\\['true', 'false'\\]), timeout (Timeout)" + }, + { + "name": "expect_format", + "description": "Asserts that the input element has the expected format.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_language", + "description": "Asserts that the input element has the expected language.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_max_date", + "description": "Asserts that the input element has the expected maximum date.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_min_date", + "description": "Asserts that the input element has the expected minimum date.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_separator", + "description": "Asserts that the input element has the expected separator.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_startview", + "description": "Asserts that the input element has the expected start view.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the input element has the expected value.", + "parameters": "value (typing.Tuple\\[PatternOrStr, PatternOrStr\\] \\), timeout (Timeout)" + }, + { + "name": "expect_weekstart", + "description": "Asserts that the input element has the expected week start.", + "parameters": "value (int \\), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the value of the input element.", + "parameters": "value (typing.Tuple\\[str \\), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputFile", + "methods": [ + { + "name": "expect_accept", + "description": "Expect the `accept` attribute to have a specific value.", + "parameters": "value (list\\[str\\] \\), timeout (Timeout)" + }, + { + "name": "expect_button_label", + "description": "Expect the button label to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_capture", + "description": "Expect the `capture` attribute to have a specific value.", + "parameters": "value (Literal\\['environment', 'user'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_complete", + "description": "Expect the file upload to be complete.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_multiple", + "description": "Expect the `multiple` attribute to have a specific value.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the width of the input file to have a specific value.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Set the file upload.", + "parameters": "file_path (str \\), timeout (Timeout), expect_complete_timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputNumeric", + "methods": [ + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_max", + "description": "Expect the maximum numeric value to be a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_min", + "description": "Expect the minimum numeric value to be a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_step", + "description": "Expect step value when incrementing/decrementing the numeric input.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expect the value of the text input to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the text value", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputPassword", + "methods": [ + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_placeholder", + "description": "Expect the `placeholder` attribute of the input to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expect the value of the text input to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of the input password to have a specific value.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the text value", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputRadioButtons", + "methods": [ + { + "name": "expect_choice_labels", + "description": "Expect the labels of the choices.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choices", + "description": "Expect the radio button choices.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Expect the input to be inline.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_selected", + "description": "Expect the selected radio button.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Set the selected radio button.", + "parameters": "selected (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputSelect", + "methods": [ + { + "name": "expect_choice_groups", + "description": "Expect the choice groups of the input select to be an exact match.", + "parameters": "choice_groups (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choice_labels", + "description": "Expect the choice labels of the input select to be an exact match.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choices", + "description": "Expect the available options of the input select to be an exact match.", + "parameters": "choices (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_multiple", + "description": "Expect the input selectize to allow multiple selections.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_selected", + "description": "Expect the selected option(s) of the input select to be an exact match.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_size", + "description": "Expect the size attribute of the input select to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the selected option(s) of the input select.", + "parameters": "selected (str \\), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputSelectize", + "methods": [ + { + "name": "expect_choice_groups", + "description": "Expect the choice groups of the input select to be an exact match.", + "parameters": "choice_groups (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choice_labels", + "description": "Expect the choice labels of the input selectize to be an exact match.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_choices", + "description": "Expect the available options of the input selectize to be an exact match.", + "parameters": "choices (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_multiple", + "description": "Expect the input selectize to allow multiple selections.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_selected", + "description": "Expect the selected option(s) of the input select to be an exact match.", + "parameters": "value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the selected option(s) of the input selectize.", + "parameters": "selected (str \\), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputSlider", + "methods": [ + { + "name": "click_pause", + "description": "Click the pause button.", + "parameters": "timeout (Timeout)" + }, + { + "name": "click_play", + "description": "Click the play button.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_animate", + "description": "Expect the animate button to exist.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_drag_range", + "description": "Asserts that the input element has the expected `data-drag-range` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_max", + "description": "Expect the input element to have the expected `max` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_min", + "description": "Expect the input element to have the expected `min` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_post", + "description": "Expect the input element to have the expected `data-post` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_pre", + "description": "Expect the input element to have the expected `data-pre` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_sep", + "description": "Expect the input element to have the expected `data-sep` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_step", + "description": "Expect the input element to have the expected `step` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_tick_labels", + "description": "Expect the tick labels of the input slider.", + "parameters": "value (ListPatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_ticks", + "description": "Expect the input element to have the expected `data-ticks` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_time_format", + "description": "Asserts that the input element has the expected `data-time-format` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_timezone", + "description": "Asserts that the input element has the expected `data-timezone` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the input element has the expected value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Set the value of the slider.", + "parameters": "value (str), max_err_values (int), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputSliderRange", + "methods": [ + { + "name": "click_pause", + "description": "Click the pause button.", + "parameters": "timeout (Timeout)" + }, + { + "name": "click_play", + "description": "Click the play button.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_animate", + "description": "Expect the animate button to exist.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_drag_range", + "description": "Asserts that the input element has the expected `data-drag-range` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_max", + "description": "Expect the input element to have the expected `max` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_min", + "description": "Expect the input element to have the expected `min` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_post", + "description": "Expect the input element to have the expected `data-post` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_pre", + "description": "Expect the input element to have the expected `data-pre` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_sep", + "description": "Expect the input element to have the expected `data-sep` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_step", + "description": "Expect the input element to have the expected `step` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_tick_labels", + "description": "Expect the tick labels of the input slider.", + "parameters": "value (ListPatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_ticks", + "description": "Expect the input element to have the expected `data-ticks` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_time_format", + "description": "Asserts that the input element has the expected `data-time-format` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_timezone", + "description": "Asserts that the input element has the expected `data-timezone` attribute value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the input element has the expected value.", + "parameters": "value (typing.Tuple\\[PatternOrStr, PatternOrStr\\] \\), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Set the value of the slider.", + "parameters": "value (typing.Tuple\\[str, str\\] \\), max_err_values (int), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputSwitch", + "methods": [ + { + "name": "expect_checked", + "description": "Expect the input checkbox to be checked.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the input checkbox.", + "parameters": "value (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputTaskButton", + "methods": [ + { + "name": "click", + "description": "Clicks the input action.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_auto_reset", + "description": "Expect the `auto-reset` attribute of the input task button to have a specific value.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_icon", + "description": "Expect the icon of the input button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input task button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label_busy", + "description": "Expect the label of a busy input task button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label_ready", + "description": "Expect the label of a ready input task button to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_label_state", + "description": "Expect the label of the input task button to have a specific value in a specific state.", + "parameters": "state (str), value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_state", + "description": "Expect the state of the input task button to have a specific value.", + "parameters": "value (Literal\\['ready', 'busy'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputText", + "methods": [ + { + "name": "expect_autocomplete", + "description": "Expect the `autocomplete` attribute of the input to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_placeholder", + "description": "Expect the `placeholder` attribute of the input to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_spellcheck", + "description": "Expect the `spellcheck` attribute of the input to have a specific value.", + "parameters": "value (Literal\\['true', 'false'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expect the value of the text input to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the input element to have a specific width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the text value", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.InputTextArea", + "methods": [ + { + "name": "expect_autocomplete", + "description": "Expect the `autocomplete` attribute of the input to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_autoresize", + "description": "Expect the `autoresize` attribute of the input text area to have a specific value.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_cols", + "description": "Expect the `cols` attribute of the input text area to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Expect the `height` attribute of the input text area to have a specific value.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_label", + "description": "Expect the label of the input to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_placeholder", + "description": "Expect the `placeholder` attribute of the input to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_resize", + "description": "Expect the `resize` attribute of the input text area to have a specific value.", + "parameters": "value (Resize \\), timeout (Timeout)" + }, + { + "name": "expect_rows", + "description": "Expect the `rows` attribute of the input text area to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_spellcheck", + "description": "Expect the `spellcheck` attribute of the input to have a specific value.", + "parameters": "value (Literal\\['true', 'false'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expect the value of the text input to have a specific value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of the input text area to have a specific value.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the text value", + "parameters": "value (str), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.NavItem", + "methods": [ + { + "name": "click", + "description": "Clicks the nav item.", + "parameters": "timeout" + }, + { + "name": "expect_active", + "description": "Expects the nav item to be active or inactive.", + "parameters": "value" + } + ] + }, + { + "controller_name": "playwright.controller.NavPanel", + "methods": [ + { + "name": "click", + "description": "Clicks the nav panel.", + "parameters": "timeout (Timeout)" + }, + { + "name": "expect_active", + "description": "Expects the nav panel to be active or inactive.", + "parameters": "value (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetBar", + "methods": [ + { + "name": "expect_bg", + "description": "Expects the navset bar to have the specified background color.", + "parameters": "bg (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_fluid", + "description": "Expects the navset bar to have a fluid or fixed layout.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_gap", + "description": "Expects the navset bar to have the specified gap.", + "parameters": "gap (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_inverse", + "description": "Expects the navset bar to be light text color if inverse is True", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_position", + "description": "Expects the navset bar to have the specified position.", + "parameters": "position (Literal\\['fixed-top', 'fixed-bottom', 'static-top', 'sticky-top'\\]), timeout (Timeout)" + }, + { + "name": "expect_sidebar", + "description": "Assert whether or not the sidebar exists within the navset.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the navset title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetCardPill", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_placement", + "description": "Expects the navset to have the specified placement.", + "parameters": "location (Literal\\['above', 'below'\\]), timeout (Timeout)" + }, + { + "name": "expect_sidebar", + "description": "Assert whether or not the sidebar exists within the navset.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the navset title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetCardTab", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_sidebar", + "description": "Assert whether or not the sidebar exists within the navset.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the navset title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetCardUnderline", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_placement", + "description": "Expects the navset to have the specified placement.", + "parameters": "location (Literal\\['above', 'below'\\]), timeout (Timeout)" + }, + { + "name": "expect_sidebar", + "description": "Assert whether or not the sidebar exists within the navset.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the navset title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetHidden", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetPill", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetPillList", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_well", + "description": "Expects the navset pill list to have a well.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_widths", + "description": "Expects the navset pill list to have the specified widths.", + "parameters": "value (ListOrTuple\\[int\\]), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetTab", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.NavsetUnderline", + "methods": [ + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputCode", + "methods": [ + { + "name": "expect_has_placeholder", + "description": "Asserts that the code output has the expected placeholder.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the output has the expected value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputDataFrame", + "methods": [ + { + "name": "cell_locator", + "description": "Returns the locator for a specific cell in the data frame.", + "parameters": "row (int), col (int)" + }, + { + "name": "expect_cell", + "description": "Expects the cell in the data frame to have the specified text.", + "parameters": "value (PatternOrStr), row (int), col (int), timeout (Timeout)" + }, + { + "name": "expect_cell_class", + "description": "Expects the class of the cell", + "parameters": "value (str), row (int), col (int), timeout (Timeout)" + }, + { + "name": "expect_cell_title", + "description": "Expects the validation message of the cell in the data frame, which will be in the `title` attribute of the element.", + "parameters": "value (str), row (int), col (int), timeout (Timeout)" + }, + { + "name": "expect_class_state", + "description": "Expects the state of the class in the data frame.", + "parameters": "value (str), row (int), col (int), timeout (Timeout)" + }, + { + "name": "expect_column_labels", + "description": "Expects the column labels in the data frame.", + "parameters": "value (ListPatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_ncol", + "description": "Expects the number of columns in the data frame.", + "parameters": "value (int), timeout (Timeout)" + }, + { + "name": "expect_nrow", + "description": "Expects the number of rows in the data frame.", + "parameters": "value (int), timeout (Timeout)" + }, + { + "name": "expect_selected_num_rows", + "description": "Expects the number of selected rows in the data frame.", + "parameters": "value (int), timeout (Timeout)" + }, + { + "name": "expect_selected_rows", + "description": "Expects the specified rows to be selected.", + "parameters": "rows (list\\[int\\]), timeout (Timeout)" + }, + { + "name": "select_rows", + "description": "Selects the rows in the data frame.", + "parameters": "value (list\\[int\\]), timeout (Timeout)" + }, + { + "name": "set_cell", + "description": "Saves the value of the cell in the data frame.", + "parameters": "text (str), row (int), col (int), finish_key (Literal\\['Enter', 'Shift+Enter', 'Tab', 'Shift+Tab', 'Escape'\\] \\), timeout (Timeout)" + }, + { + "name": "set_filter", + "description": "Set or reset filters for columns in a table or grid component. This method allows setting string filters, numeric range filters, or clearing all filters.", + "parameters": "filter (ColumnFilter \\), timeout (Timeout)" + }, + { + "name": "set_sort", + "description": "Set or modify the sorting of columns in a table or grid component. This method allows setting single or multiple column sorts, or resetting the sort order.", + "parameters": "sort (int \\), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputImage", + "methods": [ + { + "name": "expect_container_tag", + "description": "Asserts that the output has the expected container tag.", + "parameters": "value (Literal\\['span', 'div'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Asserts that the image has the expected height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_img_alt", + "description": "Asserts that the image has the expected alt text.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_height", + "description": "Asserts that the image has the expected height.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_src", + "description": "Asserts that the image has the expected src.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_width", + "description": "Asserts that the image has the expected width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Asserts that the output is inline.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Asserts that the image has the expected width.", + "parameters": "value (StyleValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputPlot", + "methods": [ + { + "name": "expect_container_tag", + "description": "Asserts that the output has the expected container tag.", + "parameters": "value (Literal\\['span', 'div'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Asserts that the image has the expected height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_img_alt", + "description": "Asserts that the image has the expected alt text.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_height", + "description": "Asserts that the image has the expected height.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_src", + "description": "Asserts that the image has the expected src.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_img_width", + "description": "Asserts that the image has the expected width.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Asserts that the output is inline.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Asserts that the image has the expected width.", + "parameters": "value (StyleValue), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputTable", + "methods": [ + { + "name": "expect_cell", + "description": "Asserts that the table cell has the expected text.", + "parameters": "value (PatternOrStr), row (int), col (int), timeout (Timeout)" + }, + { + "name": "expect_column_labels", + "description": "Asserts that the table has the expected column labels.", + "parameters": "value (ListPatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_column_text", + "description": "Asserts that the column has the expected text.", + "parameters": "col (int), value (ListPatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_ncol", + "description": "Asserts that the table has the expected number of columns.", + "parameters": "value (int), timeout (Timeout)" + }, + { + "name": "expect_nrow", + "description": "Asserts that the table has the expected number of rows.", + "parameters": "value (int), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputText", + "methods": [ + { + "name": "expect_container_tag", + "description": "Asserts that the output has the expected container tag.", + "parameters": "value (Literal\\['span', 'div'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Asserts that the output is inline.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the output has the expected value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_value", + "description": "Gets the text value of the output.", + "parameters": "timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputTextVerbatim", + "methods": [ + { + "name": "expect_has_placeholder", + "description": "Asserts that the verbatim text output has the expected placeholder.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Asserts that the output has the expected value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.OutputUi", + "methods": [ + { + "name": "expect_container_tag", + "description": "Asserts that the output has the expected container tag.", + "parameters": "value (Literal\\['span', 'div'\\] \\), timeout (Timeout)" + }, + { + "name": "expect_empty", + "description": "Asserts that the output is empty.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_inline", + "description": "Asserts that the output is inline.", + "parameters": "value (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.PageNavbar", + "methods": [ + { + "name": "expect_bg", + "description": "Expects the navset bar to have the specified background color.", + "parameters": "bg (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_fillable", + "description": "Expects the main content area to be considered a fillable (i.e., flexbox) container", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_fillable_mobile", + "description": "Expects the main content area to be considered a fillable (i.e., flexbox) container on mobile This method will always call `.expect_fillable(True)` first to ensure the fillable property is set", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_fluid", + "description": "Expects the navset bar to have a fluid or fixed layout.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_gap", + "description": "Expects the navset bar to have the specified gap.", + "parameters": "gap (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_inverse", + "description": "Expects the navset bar to be light text color if inverse is True", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_lang", + "description": "Expects the HTML tag to have the specified language.", + "parameters": "lang (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_nav_titles", + "description": "Expects the control to have the specified nav titles.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_nav_values", + "description": "Expects the control to have the specified nav values.", + "parameters": "value (list\\[PatternOrStr\\]), timeout (Timeout)" + }, + { + "name": "expect_position", + "description": "Expects the navset bar to have the specified position.", + "parameters": "position (Literal\\['fixed-top', 'fixed-bottom', 'static-top', 'sticky-top'\\]), timeout (Timeout)" + }, + { + "name": "expect_sidebar", + "description": "Assert whether or not the sidebar exists within the navset.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the navset title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the control to have the specified value.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_window_title", + "description": "Expects the window title to have the specified text.", + "parameters": "title (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_active_content", + "description": "Returns the locator for the active content.", + "parameters": "timeout (Timeout)" + }, + { + "name": "nav_panel", + "description": "Returns the nav panel ([](:class:`~shiny.playwright.controls.NavPanel`)) with the specified value.", + "parameters": "value (str)" + }, + { + "name": "set", + "description": "Sets the state of the control to open or closed.", + "parameters": "value (str)" + } + ] + }, + { + "controller_name": "playwright.controller.Popover", + "methods": [ + { + "name": "expect_active", + "description": "Expects the overlay to be active or inactive.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_body", + "description": "Expects the overlay body to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_placement", + "description": "Expects the overlay to have the specified placement.", + "parameters": "value (str), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the popover title to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "get_loc_overlay_container", + "description": "Returns the locator for the overlay container.", + "parameters": "timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the state of the popover.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.Sidebar", + "methods": [ + { + "name": "expect_bg_color", + "description": "Asserts that the sidebar has the expected background color.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_class", + "description": "Asserts that the sidebar has or does not have a CSS class.", + "parameters": "class_name (str), has_class (bool), timeout (Timeout)" + }, + { + "name": "expect_desktop_state", + "description": "Asserts that the sidebar has the expected state on desktop.", + "parameters": "value (Literal\\['open', 'closed', 'always'\\]), timeout (Timeout)" + }, + { + "name": "expect_gap", + "description": "Asserts that the sidebar has the expected gap.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_handle", + "description": "Asserts that the sidebar handle exists or does not exist.", + "parameters": "exists (bool), timeout (Timeout)" + }, + { + "name": "expect_mobile_max_height", + "description": "Asserts that the sidebar has the expected maximum height on mobile.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_mobile_state", + "description": "Asserts that the sidebar has the expected state on mobile.", + "parameters": "value (Literal\\['open', 'closed', 'always'\\]), timeout (Timeout)" + }, + { + "name": "expect_open", + "description": "Expect the sidebar to be open or closed.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_padding", + "description": "Asserts that the sidebar has the expected padding.", + "parameters": "value (str \\), timeout (Timeout)" + }, + { + "name": "expect_position", + "description": "Asserts that the sidebar is in the expected position.", + "parameters": "value (Literal\\['left', 'right'\\]), timeout (Timeout)" + }, + { + "name": "expect_text", + "description": "Asserts that the sidebar has the expected text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Asserts that the sidebar has the expected title.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Asserts that the sidebar has the expected width.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the sidebar to be open or closed.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.Tooltip", + "methods": [ + { + "name": "expect_active", + "description": "Expects the overlay to be active or inactive.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_body", + "description": "Expects the overlay body to have the specified text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_placement", + "description": "Expects the overlay to have the specified placement.", + "parameters": "value (str), timeout (Timeout)" + }, + { + "name": "get_loc_overlay_container", + "description": "Returns the locator for the overlay container.", + "parameters": "timeout (Timeout)" + }, + { + "name": "set", + "description": "Sets the state of the tooltip.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "playwright.controller.ValueBox", + "methods": [ + { + "name": "expect_body", + "description": "Expects the value box body to have specific text.", + "parameters": "value (PatternOrStr \\), timeout (Timeout)" + }, + { + "name": "expect_full_screen", + "description": "Verifies if the full screen mode is currently open.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_full_screen_available", + "description": "Expects whether full screen mode is available for the element.", + "parameters": "value (bool), timeout (Timeout)" + }, + { + "name": "expect_height", + "description": "Expects the value box to have a specific height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_max_height", + "description": "Expects the value box to have a specific maximum height.", + "parameters": "value (StyleValue), timeout (Timeout)" + }, + { + "name": "expect_title", + "description": "Expects the value box title to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_value", + "description": "Expects the value box value to have a specific text.", + "parameters": "value (PatternOrStr), timeout (Timeout)" + }, + { + "name": "expect_width", + "description": "Expect the `width` attribute of a DOM element to have a specific value.", + "parameters": "value (AttrValue), timeout (Timeout)" + }, + { + "name": "set_full_screen", + "description": "Sets the element to full screen mode or exits full screen mode.", + "parameters": "open (bool), timeout (Timeout)" + } + ] + }, + { + "controller_name": "run.ShinyAppProc", + "methods": [ + { + "name": "close", + "description": "Closes the connection and terminates the process.", + "parameters": "" + }, + { + "name": "wait_until_ready", + "description": "Waits until the shiny app is ready to serve requests.", + "parameters": "timeout_secs (float)" + } + ] + } +] \ No newline at end of file diff --git a/shiny/pytest/_generate/_main.py b/shiny/pytest/_generate/_main.py new file mode 100644 index 000000000..8bf75de8c --- /dev/null +++ b/shiny/pytest/_generate/_main.py @@ -0,0 +1,613 @@ +import importlib.resources +import logging +import os +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Optional, Tuple, Union + +from chatlas import ChatAnthropic, ChatOpenAI, token_usage +from dotenv import load_dotenv + +__all__ = [ + "ShinyTestGenerator", +] + + +@dataclass +class Config: + """Configuration class for ShinyTestGenerator""" + + # Model aliases for both providers + MODEL_ALIASES = { + # Anthropic models + "haiku3.5": "claude-3-5-haiku-20241022", + "sonnet": "claude-sonnet-4-20250514", + # OpenAI models + "gpt-5": "gpt-5-2025-08-07", + "gpt-5-mini": "gpt-5-mini-2025-08-07", + } + + DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514" + DEFAULT_OPENAI_MODEL = "gpt-5-mini-2025-08-07" + DEFAULT_PROVIDER = "anthropic" + + MAX_TOKENS = 8092 + LOG_FILE = "llm_test_generator.log" + COMMON_APP_PATTERNS = ["app.py", "app_*.py"] + + # OpenAI pricing per million tokens: (input, output, cached) + OPENAI_PRICING = { + "gpt-5-2025-08-07": (1.250, 10.000, 0.125), + "gpt-5-mini-2025-08-07": (0.250, 2.000, 0.025), + } + + +class ShinyTestGenerator: + CODE_PATTERN = re.compile(r"```python(.*?)```", re.DOTALL) + + def __init__( + self, + provider: Literal["anthropic", "openai"] = Config.DEFAULT_PROVIDER, + api_key: Optional[str] = None, + log_file: str = Config.LOG_FILE, + setup_logging: bool = True, + ): + """ + Initialize the ShinyTestGenerator. + """ + self.provider = provider + self._client = None + self._documentation = None + self._system_prompt = None + self.api_key = api_key + self.log_file = log_file + + try: + load_dotenv(override=False) + except Exception: + pass + + if setup_logging: + self.setup_logging() + + @property + def client(self) -> Union[ChatAnthropic, ChatOpenAI]: + """Lazy-loaded chat client based on provider""" + if self._client is None: + if not self.api_key: + env_var = ( + "ANTHROPIC_API_KEY" + if self.provider == "anthropic" + else "OPENAI_API_KEY" + ) + self.api_key = os.getenv(env_var) + if not self.api_key: + raise ValueError( + f"Missing API key for provider '{self.provider}'. Set the environment variable " + f"{'ANTHROPIC_API_KEY' if self.provider == 'anthropic' else 'OPENAI_API_KEY'} or pass api_key explicitly." + ) + if self.provider == "anthropic": + self._client = ChatAnthropic(api_key=self.api_key) + elif self.provider == "openai": + self._client = ChatOpenAI(api_key=self.api_key) + else: + raise ValueError(f"Unsupported provider: {self.provider}") + return self._client + + @property + def documentation(self) -> str: + """Lazy-loaded documentation""" + if self._documentation is None: + self._documentation = self._load_documentation() + return self._documentation + + @property + def system_prompt(self) -> str: + """Lazy-loaded system prompt""" + if self._system_prompt is None: + self._system_prompt = self._read_system_prompt() + return self._system_prompt + + @property + def default_model(self) -> str: + """Get default model for current provider""" + if self.provider == "anthropic": + return Config.DEFAULT_ANTHROPIC_MODEL + elif self.provider == "openai": + return Config.DEFAULT_OPENAI_MODEL + else: + raise ValueError(f"Unsupported provider: {self.provider}") + + @staticmethod + def setup_logging(): + load_dotenv() + logging.basicConfig( + filename=Config.LOG_FILE, + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s", + ) + + def _load_documentation(self) -> str: + """Load documentation from package resources""" + try: + doc_path = ( + importlib.resources.files("shiny.pytest._generate") + / "_data" + / "testing-documentation.json" + ) + with doc_path.open("r") as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundError( + "Documentation file not found for app type: testing" + ) + + def _read_system_prompt(self) -> str: + """Read and combine system prompt with documentation""" + try: + prompt_path = ( + importlib.resources.files("shiny.pytest._generate") + / "_data" + / "testing-SYSTEM_PROMPT.md" + ) + with prompt_path.open("r") as f: + system_prompt_file = f.read() + except FileNotFoundError: + raise FileNotFoundError( + "System prompt file not found for app type: testing" + ) + + return f"{system_prompt_file}\n\nHere is the function reference documentation for Shiny for Python: {self.documentation}" + + def _resolve_model(self, model: str) -> str: + """Resolve model alias to actual model name""" + return Config.MODEL_ALIASES.get(model, model) + + def _validate_model_for_provider(self, model: str) -> str: + """Validate that the model is compatible with the current provider""" + resolved_model = self._resolve_model(model) + + if self.provider == "anthropic": + if resolved_model.startswith("gpt-") or resolved_model.startswith("o1-"): + raise ValueError( + f"Model '{model}' is an OpenAI model but provider is set to 'anthropic'. " + f"Either use an Anthropic model or switch provider to 'openai'." + ) + elif self.provider == "openai": + if resolved_model.startswith("claude-"): + raise ValueError( + f"Model '{model}' is an Anthropic model but provider is set to 'openai'. " + f"Either use an OpenAI model or switch provider to 'anthropic'." + ) + + return resolved_model + + def get_llm_response(self, prompt: str, model: Optional[str] = None) -> str: + """Get response from LLM using the configured provider""" + if model is None: + model = self.default_model + else: + model = self._validate_model_for_provider(model) + + try: + if not self.api_key: + env_var = ( + "ANTHROPIC_API_KEY" + if self.provider == "anthropic" + else "OPENAI_API_KEY" + ) + self.api_key = os.getenv(env_var) + if not self.api_key: + raise ValueError( + f"Missing API key for provider '{self.provider}'. Set the environment variable " + f"{'ANTHROPIC_API_KEY' if self.provider == 'anthropic' else 'OPENAI_API_KEY'} or pass api_key." + ) + # Create chat client with the specified model + if self.provider == "anthropic": + chat = ChatAnthropic( + model=model, + system_prompt=self.system_prompt, + max_tokens=Config.MAX_TOKENS, + api_key=self.api_key, + ) + elif self.provider == "openai": + chat = ChatOpenAI( + model=model, + system_prompt=self.system_prompt, + api_key=self.api_key, + ) + else: + raise ValueError(f"Unsupported provider: {self.provider}") + + start_time = time.perf_counter() + response = chat.chat(prompt) + elapsed = time.perf_counter() - start_time + usage = token_usage() + # For Anthropic, token_usage() includes costs. For OpenAI, use chat.get_cost with model pricing. + token_price = None + if self.provider == "openai": + token_price = Config.OPENAI_PRICING.get(model) + try: + # Call to compute and cache costs internally; per-entry cost is computed below + _ = chat.get_cost(options="all", token_price=token_price) + except Exception: + # If cost computation fails, continue without it + pass + + try: + + def _fmt_tokens(n): + try: + n_int = int(n) + except Exception: + return str(n) + if n_int >= 1_000_000: + return f"{n_int / 1_000_000:.1f}M" + if n_int >= 1_000: + return f"{n_int / 1_000:.1f}k" + return str(n_int) + + entries = usage + if isinstance(entries, dict): + entries = [entries] + + if isinstance(entries, (list, tuple)) and entries: + print("LLM token usage and cost:") + for e in entries: + name = e.get("name", "N/A") + model_name = e.get("model", "N/A") + input_tokens = int(e.get("input", 0) or 0) + output_tokens = int(e.get("output", 0) or 0) + if self.provider == "openai": + cached_tokens = 0 + for ck in ("cached", "cache", "cache_read", "cached_read"): + if ck in e and e.get(ck) is not None: + try: + cached_tokens = int(e.get(ck) or 0) + except Exception: + cached_tokens = 0 + break + entry_cost = None + if token_price is not None: + try: + in_p, out_p, cached_p = token_price + entry_cost = ( + (input_tokens * in_p) + + (output_tokens * out_p) + + (cached_tokens * cached_p) + ) / 1_000_000.0 + except Exception: + entry_cost = None + cost_str = ( + f"${entry_cost:.4f}" + if isinstance(entry_cost, (int, float)) + else "$0.0000" + ) + print( + f"OpenAI ({model_name}): {_fmt_tokens(input_tokens)} input, {_fmt_tokens(output_tokens)} output | Cost {cost_str} | Time taken: {elapsed:.2f}s\n" + ) + else: + cost = round(float(e.get("cost", 0.0) or 0.0), 4) + print( + f"{name} ({model_name}): {_fmt_tokens(input_tokens)} input, {_fmt_tokens(output_tokens)} output | Cost ${cost:.4f} | Time taken: {elapsed:.2f}s\n" + ) + + else: + print(f"Token usage: {usage}\n") + print(f"Time taken: {elapsed:.2f}s") + except Exception: + print(f"Token usage: {usage}") + print(f"Time taken: {elapsed:.2f}s") + + if hasattr(response, "content"): + return response.content + elif hasattr(response, "text"): + return response.text + else: + return str(response) + except Exception as e: + logging.error(f"Error getting LLM response from {self.provider}: {e}") + raise + + def extract_test(self, response: str) -> str: + """Extract test code using pre-compiled regex pattern""" + match = self.CODE_PATTERN.search(response) + return match.group(1).strip() if match else "" + + def _compute_relative_app_path( + self, app_file_path: Path, test_file_path: Path + ) -> str: + """Compute POSIX-style relative path from the test file directory to the app file.""" + app_file_abs = app_file_path.resolve() + test_file_abs = test_file_path.resolve() + + rel = os.path.relpath(str(app_file_abs), start=str(test_file_abs.parent)) + return Path(rel).as_posix() + + def _rewrite_fixture_path(self, test_code: str, relative_app_path: str) -> str: + """Rewrite create_app_fixture path to be relative to the test file directory. + + Handles common patterns like: + - create_app_fixture(["app.py"]) -> create_app_fixture(["../app.py"]) (or appropriate) + - create_app_fixture("app.py") -> create_app_fixture("../app.py") + Keeps other arguments intact if present. + """ + logging.debug(f"Rewriting fixture path to: {relative_app_path}") + + if "create_app_fixture" not in test_code: + logging.warning("No create_app_fixture found in generated test code") + return test_code + + pattern_list = re.compile( + r"(create_app_fixture\(\s*\[\s*)(['\"])([^'\"]+)(\2)(\s*)([,\]])", + re.DOTALL, + ) + + def repl_list(m: re.Match) -> str: + logging.debug( + f"Replacing list form: '{m.group(3)}' with '{relative_app_path}'" + ) + return f"{m.group(1)}{m.group(2)}{relative_app_path}{m.group(2)}{m.group(5)}{m.group(6)}" + + new_code, list_count = pattern_list.subn(repl_list, test_code) + + if list_count > 0: + logging.debug(f"Replaced {list_count} list-form fixture path(s)") + + pattern_str = re.compile( + r"(create_app_fixture\(\s*)(['\"])([^'\"]+)(\2)(\s*)([,\)])", + re.DOTALL, + ) + + def repl_str(m: re.Match) -> str: + logging.debug( + f"Replacing string form: '{m.group(3)}' with '{relative_app_path}'" + ) + return f"{m.group(1)}{m.group(2)}{relative_app_path}{m.group(2)}{m.group(5)}{m.group(6)}" + + new_code2, str_count = pattern_str.subn(repl_str, new_code) + + if str_count > 0: + logging.debug(f"Replaced {str_count} string-form fixture path(s)") + + if list_count == 0 and str_count == 0: + logging.warning( + f"Found create_app_fixture but couldn't replace path. Code snippet: {test_code[:200]}..." + ) + + fallback_pattern = re.compile( + r"(create_app_fixture\([^\)]*?['\"])([^'\"]+)(['\"][^\)]*?\))", + re.DOTALL, + ) + + def fallback_repl(m: re.Match) -> str: + logging.debug( + f"Fallback replacement: '{m.group(2)}' with '{relative_app_path}'" + ) + return f"{m.group(1)}{relative_app_path}{m.group(3)}" + + new_code2, fallback_count = fallback_pattern.subn(fallback_repl, new_code) + + if fallback_count > 0: + logging.debug(f"Fallback replaced {fallback_count} fixture path(s)") + + return new_code2 + + def _create_test_prompt(self, app_text: str, app_file_name: str) -> str: + """Create test generation prompt with app file name""" + return ( + f"Given this Shiny for Python app code from file '{app_file_name}':\n{app_text}\n" + "Please only add controllers for components that already have an ID in the shiny app.\n" + "Do not add tests for ones that do not have an existing ids since controllers need IDs to locate elements.\n" + "and server functionality of this app. Include appropriate assertions \\n" + "and test cases to verify the app's behavior.\n\n" + "CRITICAL: In the create_app_fixture call, you MUST pass a RELATIVE path from the test file's directory to the app file.\n" + "For example:\n" + "- If test is in 'tests/test_app.py' and app is in 'app.py', use: '../app.py'\n" + "- If test is in 'tests/subdir/test_app.py' and app is in 'apps/subdir/app.py', use: '../../apps/subdir/app.py'\n" + "- Always compute the correct relative path from the test file to the app file\n" + "- NEVER use absolute paths or paths that aren't relative from the test location\n\n" + "IMPORTANT: Only output the Python test code in a single code block. Do not include any explanation, justification, or extra text." + ) + + def _infer_app_file_path( + self, app_code: Optional[str] = None, app_file_path: Optional[str] = None + ) -> Path: + if app_file_path: + return Path(app_file_path).resolve() + + current_dir = Path.cwd() + + found_files = [] + for pattern in Config.COMMON_APP_PATTERNS: + found_files.extend(current_dir.glob(pattern)) + + if found_files: + return found_files[0].resolve() + + if app_code: + return Path("inferred_app.py").resolve() + + raise FileNotFoundError( + "Could not infer app file path. Please provide app_file_path parameter." + ) + + def _generate_test_file_path( + self, app_file_path: Path, output_dir: Optional[Path] = None + ) -> Path: + output_dir = output_dir or app_file_path.parent + test_file_name = f"test_{app_file_path.stem}.py" + return (output_dir / test_file_name).resolve() + + def _generate_test( + self, + app_code: Optional[str] = None, + app_file_path: Optional[str] = None, + app_name: str = "app", + model: Optional[str] = None, + output_file: Optional[str] = None, + output_dir: Optional[str] = None, + ) -> Tuple[str, Path]: + if app_code and not app_file_path: + inferred_app_path = Path(f"{app_name}.py") + else: + inferred_app_path = self._infer_app_file_path(app_code, app_file_path) + + if app_code is None: + if not inferred_app_path.exists(): + raise FileNotFoundError(f"App file not found: {inferred_app_path}") + app_code = inferred_app_path.read_text(encoding="utf-8") + + user_prompt = self._create_test_prompt(app_code, inferred_app_path.name) + response = self.get_llm_response(user_prompt, model) + test_code = self.extract_test(response) + + if output_file: + test_file_path = Path(output_file).resolve() + else: + output_dir_path = Path(output_dir) if output_dir else None + test_file_path = self._generate_test_file_path( + inferred_app_path, output_dir_path + ) + + try: + logging.info(f"App file path: {inferred_app_path}") + logging.info(f"Test file path: {test_file_path}") + + relative_app_path = self._compute_relative_app_path( + inferred_app_path, test_file_path + ) + + logging.info(f"Computed relative path: {relative_app_path}") + + test_code = self._rewrite_fixture_path(test_code, relative_app_path) + except Exception as e: + logging.error(f"Error computing relative path: {e}") + try: + logging.warning("Falling back to using absolute path in test file") + test_code = self._rewrite_fixture_path( + test_code, str(inferred_app_path.resolve()) + ) + except Exception as e2: + logging.error(f"Error in fallback path handling: {e2}") + + test_file_path.parent.mkdir(parents=True, exist_ok=True) + test_file_path.write_text(test_code, encoding="utf-8") + + return test_code, test_file_path + + def generate_test_from_file( + self, + app_file_path: str, + model: Optional[str] = None, + output_file: Optional[str] = None, + output_dir: Optional[str] = None, + ) -> Tuple[str, Path]: + return self._generate_test( + app_file_path=app_file_path, + model=model, + output_file=output_file, + output_dir=output_dir, + ) + + def generate_test_from_code( + self, + app_code: str, + app_name: str = "app", + model: Optional[str] = None, + output_file: Optional[str] = None, + output_dir: Optional[str] = None, + ) -> Tuple[str, Path]: + return self._generate_test( + app_code=app_code, + app_name=app_name, + model=model, + output_file=output_file, + output_dir=output_dir, + ) + + def switch_provider( + self, provider: Literal["anthropic", "openai"], api_key: Optional[str] = None + ): + self.provider = provider + if api_key: + self.api_key = api_key + self._client = None + + @classmethod + def create_anthropic_generator( + cls, api_key: Optional[str] = None, **kwargs + ) -> "ShinyTestGenerator": + return cls(provider="anthropic", api_key=api_key, **kwargs) + + @classmethod + def create_openai_generator( + cls, api_key: Optional[str] = None, **kwargs + ) -> "ShinyTestGenerator": + return cls(provider="openai", api_key=api_key, **kwargs) + + def get_available_models(self) -> list[str]: + if self.provider == "anthropic": + return [ + model + for model in Config.MODEL_ALIASES.keys() + if not (model.startswith("gpt-") or model.startswith("o1-")) + ] + elif self.provider == "openai": + return [ + model + for model in Config.MODEL_ALIASES.keys() + if (model.startswith("gpt-") or model.startswith("o1-")) + ] + else: + return [] + + +def cli(): + import argparse + + parser = argparse.ArgumentParser(description="Generate Shiny tests using LLM") + parser.add_argument("app_file", help="Path to the Shiny app file") + parser.add_argument( + "--provider", + choices=["anthropic", "openai"], + default=Config.DEFAULT_PROVIDER, + help="LLM provider to use", + ) + parser.add_argument("--model", help="Model to use (optional)") + parser.add_argument("--output-dir", help="Output directory for test files") + parser.add_argument("--api-key", help="API key (optional, can use env vars)") + + args = parser.parse_args() + + app_file_path = Path(args.app_file) + if not app_file_path.is_file(): + print(f"Error: File not found at {app_file_path}") + sys.exit(1) + + try: + generator = ShinyTestGenerator( + provider=args.provider, api_key=args.api_key, setup_logging=False + ) + + test_code, test_file_path = generator.generate_test_from_file( + str(app_file_path), + model=args.model, + output_dir=args.output_dir, + ) + + print(f"✅ Test file generated successfully: {test_file_path}") + print(f"📝 Used provider: {args.provider}") + if args.model: + print(f"🤖 Used model: {args.model}") + + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + cli() diff --git a/tests/inspect-ai/__init__.py b/tests/inspect-ai/__init__.py new file mode 100644 index 000000000..ca5ba6879 --- /dev/null +++ b/tests/inspect-ai/__init__.py @@ -0,0 +1,3 @@ +""" +Contains evaluation apps, scripts, and results for testing the Shiny test generator. +""" diff --git a/tests/inspect-ai/apps/__init__.py b/tests/inspect-ai/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_01_core_basic/__init__.py b/tests/inspect-ai/apps/app_01_core_basic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_01_core_basic/app.py b/tests/inspect-ai/apps/app_01_core_basic/app.py new file mode 100644 index 000000000..84e26dec3 --- /dev/null +++ b/tests/inspect-ai/apps/app_01_core_basic/app.py @@ -0,0 +1,58 @@ +from shiny import App, render, ui + +# Create the UI +app_ui = ui.page_fluid( + # Add Font Awesome CSS in the head + ui.tags.head( + ui.tags.link( + rel="stylesheet", + href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css", + ) + ), + # Main layout + ui.layout_column_wrap( + ui.card( + ui.card_header("Action Button Examples"), + # Basic button with width parameter + ui.input_action_button(id="btn1", label="Basic Button", width="200px"), + ui.br(), # Add spacing + # Button with icon and disabled state + ui.input_action_button( + id="btn2", + label="Disabled Button with Icon", + icon=ui.tags.i(class_="fa-solid fa-shield-halved"), + disabled=True, + ), + ui.br(), # Add spacing + # Button with custom class and style attributes + ui.input_action_button( + id="btn3", + label="Styled Button", + class_="btn-success", + style="margin-top: 20px;", + ), + ), + # Card for displaying results + ui.card( + ui.card_header("Click Counts"), + ui.output_text("click_counts"), + ), + width="100%", + ), +) + + +# Define the server +def server(input, output, session): + @output + @render.text + def click_counts(): + return ( + f"Button 1 clicks: {input.btn1() or 0}\n" + f"Button 2 clicks: {input.btn2() or 0}\n" + f"Button 3 clicks: {input.btn3() or 0}" + ) + + +# Create and return the app +app = App(app_ui, server) diff --git a/tests/inspect-ai/apps/app_02_express_basic/__init__.py b/tests/inspect-ai/apps/app_02_express_basic/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_02_express_basic/app.py b/tests/inspect-ai/apps/app_02_express_basic/app.py new file mode 100644 index 000000000..d65be4267 --- /dev/null +++ b/tests/inspect-ai/apps/app_02_express_basic/app.py @@ -0,0 +1,48 @@ +from shiny.express import input, render, ui + +# Add Font Awesome CSS for icons - this needs to be before any UI elements +ui.head_content( + ui.HTML( + '' + ) +) + +# Create a layout with some spacing +with ui.layout_column_wrap(width="100%"): + with ui.card(): + ui.card_header("Action Button Examples") + + # Basic button with width parameter + ui.input_action_button(id="btn1", label="Basic Button", width="200px") + + ui.br() # Add some spacing + + # Button with icon and disabled state + ui.input_action_button( + id="btn2", + label="Disabled Button with Icon", + icon=ui.tags.i(class_="fa-solid fa-shield-halved"), + disabled=True, + ) + + ui.br() # Add some spacing + + # Button with custom class and style attributes + ui.input_action_button( + id="btn3", + label="Styled Button", + class_="btn-success", + style="margin-top: 20px;", + ) + + # Create another card for displaying results + with ui.card(): + ui.card_header("Click Counts") + + @render.text + def click_counts(): + return ( + f"Button 1 clicks: {input.btn1() or 0}\n" + f"Button 2 clicks: {input.btn2() or 0}\n" + f"Button 3 clicks: {input.btn3() or 0}" + ) diff --git a/tests/inspect-ai/apps/app_03_slider/__init__.py b/tests/inspect-ai/apps/app_03_slider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_03_slider/app.py b/tests/inspect-ai/apps/app_03_slider/app.py new file mode 100644 index 000000000..66f2329b8 --- /dev/null +++ b/tests/inspect-ai/apps/app_03_slider/app.py @@ -0,0 +1,12 @@ +from shiny.express import input, render, ui + +ui.page_opts(title="Slider Parameters Demo", full_width=True) + +with ui.layout_column_wrap(width="400px"): + with ui.card(): + ui.card_header("Basic Numeric Slider") + ui.input_slider("slider1", "Min, max, value", min=0, max=100, value=50) + + @render.text + def value1(): + return f"Value: {input.slider1()}" diff --git a/tests/inspect-ai/apps/app_04_custom_app_name/__init__.py b/tests/inspect-ai/apps/app_04_custom_app_name/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_04_custom_app_name/app_input_checkbox_group.py b/tests/inspect-ai/apps/app_04_custom_app_name/app_input_checkbox_group.py new file mode 100644 index 000000000..a51840354 --- /dev/null +++ b/tests/inspect-ai/apps/app_04_custom_app_name/app_input_checkbox_group.py @@ -0,0 +1,31 @@ +from shiny.express import input, render, ui + +# Create sample choices with HTML formatting for demonstration +choices = { + "red": ui.span("Red", style="color: #FF0000;"), + "green": ui.span("Green", style="color: #00AA00;"), + "blue": ui.span("Blue", style="color: #0000AA;"), +} + +with ui.card(): + ui.card_header("Color Selection Demo") + + # Using input_checkbox_group with all its parameters + ui.input_checkbox_group( + id="colors", # Required: unique identifier + label="Choose colors", # Required: label text + choices=choices, # Required: choices as dict with HTML formatting + selected=["red", "blue"], # Optional: pre-selected values + inline=True, # Optional: display choices inline + width="300px", # Optional: CSS width + ) + + # Add some spacing + ui.hr() + + # Simple output to show selected values + @render.text + def selected_colors(): + if input.colors(): + return f"You selected: {', '.join(input.colors())}" + return "No colors selected" diff --git a/tests/inspect-ai/apps/app_05_streamlit/__init__.py b/tests/inspect-ai/apps/app_05_streamlit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_05_streamlit/app.py b/tests/inspect-ai/apps/app_05_streamlit/app.py new file mode 100644 index 000000000..c20a69e26 --- /dev/null +++ b/tests/inspect-ai/apps/app_05_streamlit/app.py @@ -0,0 +1,11 @@ +import streamlit as st + +st.title("My Simple Streamlit App") + +user_name = st.text_input("Enter your name", "Type your name here...") + +# Add a slider widget +user_age = st.slider("Select your age", 0, 100, 25) + +# Display the user's input +st.write(f"Hello, {user_name}! You are {user_age} years old.") diff --git a/tests/inspect-ai/apps/app_06_R_shiny/__init__.py b/tests/inspect-ai/apps/app_06_R_shiny/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_06_R_shiny/app.R b/tests/inspect-ai/apps/app_06_R_shiny/app.R new file mode 100644 index 000000000..e714ab86d --- /dev/null +++ b/tests/inspect-ai/apps/app_06_R_shiny/app.R @@ -0,0 +1,30 @@ +library(shiny) + +ui <- fluidPage( + # Application title + titlePanel("My First Shiny App"), + + sidebarLayout( + sidebarPanel( + sliderInput( + inputId = "num", + label = "Select a number:", + min = 1, + max = 1000, + value = 500 + ) # Default value + ), + + mainPanel( + textOutput("message") + ) + ) +) + +server <- function(input, output) { + output$message <- renderText({ + paste("You selected:", input$num) + }) +} + +shinyApp(ui = ui, server = server) diff --git a/tests/inspect-ai/apps/app_07_modules/__init__.py b/tests/inspect-ai/apps/app_07_modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_07_modules/app.py b/tests/inspect-ai/apps/app_07_modules/app.py new file mode 100644 index 000000000..2a4886d37 --- /dev/null +++ b/tests/inspect-ai/apps/app_07_modules/app.py @@ -0,0 +1,31 @@ +from shiny import App, module, render, ui + + +@module.ui +def my_module_ui(): + """Defines the UI elements for the module with multiple text inputs.""" + return ui.div( + ui.h2("My Module"), + ui.input_text("text_input_1", "Enter the first text:"), + ui.input_text("text_input_2", "Enter the second text:"), # Second text input + ui.output_text("text_output"), + ) + + +@module.server +def my_module_server(input, output, session): + """Defines the server logic for the module.""" + + @render.text + def text_output(): + return f"You entered: {input.text_input_1()} and {input.text_input_2()}" # Accessing both inputs + + +app_ui = ui.page_fluid(ui.h1("Main Application"), my_module_ui("module_instance_1")) + + +def server(input, output, session): + my_module_server("module_instance_1") + + +app = App(app_ui, server) diff --git a/tests/inspect-ai/apps/app_08_navigation/__init__.py b/tests/inspect-ai/apps/app_08_navigation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_08_navigation/app.py b/tests/inspect-ai/apps/app_08_navigation/app.py new file mode 100644 index 000000000..374f6c5a7 --- /dev/null +++ b/tests/inspect-ai/apps/app_08_navigation/app.py @@ -0,0 +1,67 @@ +# app.py +from shiny import App, render, ui + +# Define the main app UI +app_ui = ui.page_fluid( + ui.h1("Shiny App with Tabs"), + ui.navset_tab( + ui.nav_panel( + "Tab 1: Input & Output", # Tab title + ui.h3("Input and Text Output"), + ui.input_text( + "text_input", "Enter some text:", "Hello Shiny!" + ), # Text input component + ui.output_text("output_text"), + ), + ui.nav_panel( + "Tab 2: Slider & Plot", # Tab title + ui.h3("Slider and Plot Output"), + ui.input_slider( + "n_points", "Number of points:", min=10, max=100, value=50 + ), # Slider input component + ui.output_plot("output_plot"), + ), + ui.nav_panel( + "Tab 3: Button & Message", # Tab title + ui.h3("Action Button and Message Output"), + ui.input_action_button( + "action_button", "Click me!" + ), # Action button component + ui.output_text("output_message"), + ), + id="navset_Tab", + ), +) + + +# Define the main app server function +def server(input, output, session): + + @render.text # Decorator for verbatim text output + def output_text(): + return f"You entered: {input.text_input()}" # Accessing the text input value + + @render.plot # Decorator for rendering plots + def output_plot(): + import matplotlib.pyplot as plt + import numpy as np + + # Generate some data based on the slider input + x = np.linspace(0, 10, input.n_points()) + y = np.sin(x) + + fig, ax = plt.subplots() + ax.plot(x, y) + ax.set_title("Dynamic Sine Wave") + return fig + + @render.text # Decorator for rendering simple text + def output_message(): + # Respond when the action button is clicked + if input.action_button() > 0: + return "Button clicked!" + return "Click the button." + + +# Create the Shiny app instance +app = App(app_ui, server) diff --git a/tests/inspect-ai/apps/app_09_plots/__init__.py b/tests/inspect-ai/apps/app_09_plots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_09_plots/app.py b/tests/inspect-ai/apps/app_09_plots/app.py new file mode 100644 index 000000000..615048dc0 --- /dev/null +++ b/tests/inspect-ai/apps/app_09_plots/app.py @@ -0,0 +1,67 @@ +# app.py +import matplotlib.pyplot as plt +import numpy as np + +from shiny import App, module, render, ui + + +# Define the module UI function +@module.ui +def plot_module_ui(): + """Defines a module with two plots and inputs to control them.""" + return ui.div( + ui.h3("Plot Module"), + ui.input_slider( + "n_points", "Number of points:", min=10, max=100, value=50 + ), # Slider for points + ui.row( # Use ui.row to arrange plots side-by-side + ui.column(6, ui.output_plot("plot1")), # First plot in a column + ui.column(6, ui.output_plot("plot2")), # Second plot in a column + ), + ) + + +# Define the module server function +@module.server +def plot_module_server(input, output, session): + """Defines the server logic for the plot module.""" + + @output + @render.plot # Decorator for rendering plots + def plot1(): + # Generate some data for the first plot + x = np.random.rand(input.n_points()) + y = np.random.rand(input.n_points()) + + fig, ax = plt.subplots() + ax.scatter(x, y) + ax.set_title("Random Scatter Plot") + return fig + + @output + @render.plot # Decorator for rendering plots + def plot2(): + # Generate some data for the second plot + x = np.linspace(0, 10, input.n_points()) + y = np.sin(x) + + fig, ax = plt.subplots() + ax.plot(x, y) + ax.set_title("Sine Wave Plot") + return fig + + +# Define the main app UI +app_ui = ui.page_fluid( + ui.h1("Main Application with Plot Module"), + plot_module_ui("my_plot_module"), # Instantiate the module UI +) + + +# Define the main app server function +def server(input, output, session): + plot_module_server("my_plot_module") # Instantiate the module server + + +# Create the Shiny app instance +app = App(app_ui, server) diff --git a/tests/inspect-ai/apps/app_10_complex_layout/__init__.py b/tests/inspect-ai/apps/app_10_complex_layout/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/apps/app_10_complex_layout/app.py b/tests/inspect-ai/apps/app_10_complex_layout/app.py new file mode 100644 index 000000000..a5ebad554 --- /dev/null +++ b/tests/inspect-ai/apps/app_10_complex_layout/app.py @@ -0,0 +1,70 @@ +import pandas as pd + +from shiny import App, render, ui + +app_ui = ui.page_fluid( + ui.h2("Shiny for Python Demo with Multiple Inputs and Data Grid"), + ui.layout_sidebar( + ui.sidebar( # Use ui.sidebar() + ui.input_action_button("action_button", "Click me!"), + ui.input_checkbox("checkbox", "Check this box"), + ui.input_date("date_selector", "Select a date"), + ui.input_numeric("numeric_input", "Enter a number", 10), + ui.input_radio_buttons( + "radio_buttons", "Choose one:", ["Option A", "Option B", "Option C"] + ), + ui.input_switch("switch", "Turn on/off"), + ), + ui.h3("Output Values"), + ui.output_text("action_button_value"), + ui.output_text("checkbox_value"), + ui.output_text("date_selector_value"), + ui.output_text("numeric_input_value"), + ui.output_text("radio_buttons_value"), + ui.output_text("switch_value"), + ui.h3("Data Grid Output"), + ui.output_data_frame("data_grid"), + ), +) + + +def server(input, output, session): + @render.text + def action_button_value(): + return f"Action Button clicked: {input.action_button()}" + + @render.text + def checkbox_value(): + return f"Checkbox checked: {input.checkbox()}" + + @render.text + def date_selector_value(): + return f"Selected date: {input.date_selector()}" + + @render.text + def numeric_input_value(): + return f"Numeric Input value: {input.numeric_input()}" + + @render.text + def radio_buttons_value(): + return f"Selected Radio Button: {input.radio_buttons()}" + + @render.text + def switch_value(): + return f"Switch status: {input.switch()}" + + @render.data_frame + def data_grid(): + data = { + "Input": [ + "Action Button", + ], + "Value": [ + input.action_button(), + ], + } + df = pd.DataFrame(data) + return render.DataGrid(df) + + +app = App(app_ui, server) diff --git a/tests/inspect-ai/scripts/README.md b/tests/inspect-ai/scripts/README.md new file mode 100644 index 000000000..8223e5176 --- /dev/null +++ b/tests/inspect-ai/scripts/README.md @@ -0,0 +1,7 @@ +# Evals Directory + +This directory contains scripts for evaluating the performance of the Shiny test generator. + +- `create_test_metadata.py`: This script generates metadata for the test cases. This metadata is used by the evaluation script to understand the context of each test. + +- `evaluation.py`: This script runs the evaluation of the generated tests against the test cases. It uses the metadata generated by `create_test_metadata.py` to perform the evaluation. diff --git a/tests/inspect-ai/scripts/__init__.py b/tests/inspect-ai/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/inspect-ai/scripts/create_test_metadata.py b/tests/inspect-ai/scripts/create_test_metadata.py new file mode 100644 index 000000000..c23753e39 --- /dev/null +++ b/tests/inspect-ai/scripts/create_test_metadata.py @@ -0,0 +1,81 @@ +import json +from itertools import islice +from pathlib import Path +from typing import Any, Dict, List, Union, cast + +from shiny.pytest._generate import ShinyTestGenerator + + +def generate_shiny_test_metadata( + apps_dir: Union[str, Path] = "tests/inspect-ai/apps", max_tests: int = 10 +) -> Dict[str, Dict[str, Union[str, Path]]]: + """ + Generate Shiny tests and metadata for apps in the specified directory. + + Args: + apps_dir: Directory containing Shiny apps + max_tests: Maximum number of tests to generate + + Returns: + Dictionary mapping test names to test metadata including code and app info + """ + generator = ShinyTestGenerator() + apps_dir = Path(apps_dir) + + if not apps_dir.exists() and apps_dir.is_relative_to("."): + script_dir = Path(__file__).parent + apps_dir = script_dir.parent / "apps" + if not apps_dir.exists(): + apps_dir = script_dir.parent.parent.parent / "tests" / "inspect-ai" / "apps" + + app_files = islice(apps_dir.glob("*/app*.py"), max_tests) + + test_data: Dict[str, Dict[str, Union[str, Path]]] = {} + + for app_path in app_files: + try: + test_code, test_file_path = generator.generate_test_from_file(str(app_path)) + + test_name = f"test_{app_path.parent.name}_{app_path.stem}" + app_code = app_path.read_text(encoding="utf-8") + + test_data[test_name] = { + "test_code": test_code, + "app_code": app_code, + "app_path": str(app_path), + "test_file_path": test_file_path, + "app_name": app_path.parent.name, + } + + except Exception as e: + print(f"Error generating test for {app_path}: {e}") + continue + + return test_data + + +if __name__ == "__main__": + test_data: Dict[str, Dict[str, Union[str, Path]]] = generate_shiny_test_metadata() + + metadata_file = Path(__file__).parent / "test_metadata.json" + + def convert_paths(obj: Any) -> Any: + """Convert Path objects to strings for JSON serialization.""" + if isinstance(obj, dict): + # Cast to Dict[Any, Any] to avoid type errors + typed_dict = cast(Dict[Any, Any], obj) + return {str(k): convert_paths(v) for k, v in typed_dict.items()} + elif isinstance(obj, Path): + return str(obj) + elif isinstance(obj, list): + # Cast to List[Any] to avoid type errors + typed_list = cast(List[Any], obj) + return [convert_paths(item) for item in typed_list] + else: + return obj + + serializable_test_data: Any = convert_paths(test_data) + with open(metadata_file, "w") as f: + json.dump(serializable_test_data, f, indent=2) + + print(f"Saved test metadata to: {metadata_file}") diff --git a/tests/inspect-ai/scripts/evaluation.py b/tests/inspect-ai/scripts/evaluation.py new file mode 100644 index 000000000..a97071e3b --- /dev/null +++ b/tests/inspect-ai/scripts/evaluation.py @@ -0,0 +1,320 @@ +import json +import re +from pathlib import Path + +from inspect_ai import Task, task +from inspect_ai.dataset import Sample +from inspect_ai.model import get_model +from inspect_ai.scorer import model_graded_qa +from inspect_ai.solver import generate + + +def get_app_specific_instructions(app_name: str) -> str: + """ + Get specific grading instructions for each app based on its unique characteristics. + + Args: + app_name: Name of the Shiny app + + Returns: + App-specific grading instructions + """ + app_instructions = { + "app_09_plots": """ + For this plot app tests, focus on components that exist in the app code: + - Whether the test creates an instance of the InputSlider controller with id "my_plot_module-n_points" + - Ensure that the slider component is verified for its label, min, max, and value attributes. + - Ensure that the test checks by moving the slider to different values and verify the slider values accordingly + + IMPORTANT: Only evaluate based on components and IDs that actually exist in the app code. + """, + "app_07_modules": """ + For this module-based app, focus on components that exist in the app code: + - Whether the test creates instances of the InputText controller with ids "module_instance_1-text_input_1" and "module_instance_1-text_input_2" + - Whether the test creates an instance of the OutputText controller with id "module_instance_1-text_output" + - Ensure that the text inputs are verified for their labels and initial values. + - Ensure that the test checks the text output for correct concatenation of input values. + - Check that the test verifies the module's reactivity by changing input values and checking output + + IMPORTANT: Only evaluate based on components and IDs that actually exist in the app code. + """, + "app_03_slider": """ + For this slider app, focus on components that exist in the app code: + - Whether the test creates an instance of the InputSlider controller with id "slider1" + - Ensure that the slider component is verified for its label, min, max, and value attributes. + - Ensure that the test checks by moving the slider to different values and verify the slider values accordingly. + + IMPORTANT: Only evaluate based on components and IDs that actually exist in the app code. + """, + "app_06_R_shiny": """ + For this app, focus on: + - The test code should be empty since the app code was not a Shiny for Python app. + """, + "app_10_complex_layout": """ + For this app, focus on the components that exist in the app code: + - Whether the test creates an instance of the InputActionButton controller with id "action_button" + - Ensure that the action button component is verified for its label and click functionality. + - Whether the test creates an instance of the InputCheckbox controller with id "checkbox" + - Ensure that the checkbox component is verified for its label and checked state. + - Ensure that the test checks the checkbox state changes and verifies the output text accordingly. + - Whether the test creates an instance of the InputDate controller with id "date_selector" + - Ensure that the date selector component is verified for its label and selected date. + - Ensure that the test checks the date selector state changes and verifies the output text accordingly. + - Whether the test creates an instance of the InputNumeric controller with id "numeric_input" + - Ensure that the numeric input component is verified for its label and value. + - Ensure that the test checks the numeric input state changes and verifies the output text accordingly. + - Whether the test creates an instance of the InputRadioButtons controller with id "radio_buttons" + - Ensure that the radio buttons component is verified for its label, choices, and selected value. + - Ensure that the test checks the radio buttons state changes and verifies the output text accordingly. + - Whether the test creates an instance of the InputSwitch controller with id "switch" + - Ensure that the switch component is verified for its label and state. + - Ensure that the test checks the switch state changes and verifies the output text accordingly. + - Whether the test creates an instance of the OutputText controller with ids "action_button_value", "checkbox_value", "date_selector_value", "numeric_input_value", "radio_buttons_value", and "switch_value" + - Ensure that the output text components are verified for their initial values and updated values based on user interactions. + - Whether the test creates an instance of the OutputDataFrame controller with id "data_grid" + - Ensure that the data grid component is verified for its initial state and updates correctly based on user interactions. + + IMPORTANT: Only evaluate based on components and IDs that actually exist in the app code. The test should only test functionality that is actually present in the app. + """, + "app_02_express_basic": """ + For this shiny express basic app, focus on: + - Ensure that the test creates an instance of the InputActionButton controller with id "btn1" + - Ensure that the action button component is verified for its label and click functionality. + - Ensure that the test checks the action button state changes and verifies the output text accordingly. + - Ensure that the test creates an instance of the OutputText controller with id "click_counts" + - Ensure that the output text component is verified for its initial value and updated values based on button clicks. + - Ensure that the test creates instances of the InputActionButton controller with ids "btn2" and "btn3" + - Ensure that the disabled button with icon is verified for its label and icon. + - Ensure that the styled button is verified for its label and custom styles. + - Ensure that the test checks the click counts for each button and verifies the output text accordingly + """, + "app_08_navigation": """ + For this app, focus on: + - Whether the test creates an instance of the NavsetTab controller with id "navset_Tab" + - Ensure that the navset tab component is verified for its titles and active state. + - Ensure that the test checks the navigation between tabs and verifies the active state of each tab + - Ensure that the test verifies the content of each tab, including input components and output displays + - Ensure that the test checks the functionality of input components in each tab, such as text inputs, sliders, and action buttons + """, + "app_04_custom_app_name": """ + For this app, focus on: + - Ensure that the create_app_fixture is called with the correct app file. In this case, it should be "app_input_checkbox_group.py" + - Ensure that the test creates an instance of the InputCheckboxGroup controller with id "colors" + - Ensure that the checkbox group component is verified for its label, choices, selected values, inline state, and width. + - Ensure that the test checks the checkbox group state changes and verifies the output text accordingly. + - Ensure that the test creates an instance of the OutputText controller with id "selected_colors" + - Ensure that the output text component is verified for its initial value and updated values based on checkbox selections. + """, + "app_01_core_basic": """ + For this app, focus on: + - Ensure that the test creates an instance of the InputActionButton controller with id "btn1" + - Ensure that the action button component is verified for its label and click functionality. + - Ensure that the test checks the action button state changes and verifies the output text accordingly. + - Ensure that the test creates an instance of the OutputText controller with id "click_counts" + - Ensure that the test creates instances of the InputActionButton controller with ids "btn2" and "btn3" + """, + "app_05_streamlit": """ + For this app, focus on: + - The test code should be empty since the app code was not a Shiny for Python app. + """, + } + + return app_instructions.get(app_name, "") + + +def extract_component_ids(app_code: str) -> dict: + """ + Extract component IDs from Shiny app code to ensure evaluation focuses on existing components. + + Args: + app_code: The Shiny app code to analyze + + Returns: + Dictionary with component types as keys and lists of IDs as values + """ + input_ids = set() + output_ids = set() + + # 1. Find input components (ui.input_*) + try: + input_matches = re.findall( + r'ui\.input_\w+\(\s*(?:id\s*=\s*)?["\']([^"\']+)["\']', app_code + ) + input_ids.update(input_matches) + except re.error: + pass + + # 2. Find output components (ui.output_*) + try: + output_matches = re.findall( + r'ui\.output_\w+\(\s*(?:id\s*=\s*)?["\']([^"\']+)["\']', app_code + ) + output_ids.update(output_matches) + except re.error: + pass + + # 3. Find input references (input.name()) + try: + input_refs = re.findall(r"input\.([\w_]+)\(\)", app_code) + input_ids.update(input_refs) + except re.error: + pass + + # 4. Find @render.* definitions + try: + render_defs = re.findall(r"@render\.\w+\s+def\s+([\w_]+)\s*\(", app_code) + output_ids.update(render_defs) + except re.error: + pass + + # 5. Find @output wrapped definitions + try: + output_defs = re.findall(r"@output\s+def\s+([\w_]+)\s*\(", app_code) + output_ids.update(output_defs) + except re.error: + pass + + # 6. Find module instantiations + try: + module_ids = re.findall( + r'\w+_\w+_(?:ui|server)\(\s*["\']([^"\']+)["\']', app_code + ) + input_ids.update(module_ids) + output_ids.update(module_ids) + except re.error: + pass + + # 7. Find navset components + try: + nav_ids = re.findall( + r'ui\.navset_\w+\(.*?id\s*=\s*["\']([^"\']+)["\']', app_code + ) + input_ids.update(nav_ids) + except re.error: + pass + + return {"input": sorted(list(input_ids)), "output": sorted(list(output_ids))} + + +def create_inspect_ai_samples(test_data: dict) -> list[Sample]: + """ + Create Inspect AI samples from the generated test data. + + Args: + test_data: Dictionary containing test metadata for all generated tests + + Returns: + List of Sample objects for Inspect AI evaluation + """ + samples = [] + + for test_name, data in test_data.items(): + app_specific_guidance = get_app_specific_instructions(data["app_name"]) + + component_ids = extract_component_ids(data["app_code"]) + component_ids_str = "\n".join( + [f"{k.title()} IDs: {', '.join(v)}" for k, v in component_ids.items() if v] + ) + + question = f"""Evaluate the quality of this Shiny test code for app {data['app_name']}. + +IMPORTANT: First carefully analyze the App Code below to understand what components and IDs actually exist in the app. +Then evaluate the test code ONLY against components and IDs that actually exist in the app code. + +Actual Component IDs automatically detected in App: +{component_ids_str} + +App Code: +```python +{data['app_code']} +``` + +Test Code to Evaluate: +```python +{data['test_code']} +``` + +Evaluation Instructions: +1. ONLY evaluate components that ACTUALLY EXIST in the app code - the detected IDs above show what's really in the app +2. If a component mentioned in the criteria doesn't exist in the app code, IGNORE that part of the criteria completely +3. If the app uses different IDs than what's in the criteria (e.g., "data_grid" instead of "data_table"), use the actual IDs from the app +4. Check if the test code properly tests all the EXISTING components (creating controllers, verifying attributes, testing interactions, etc.) +5. The test should receive a Complete grade if it adequately tests all components that actually exist in the app""" + + if app_specific_guidance: + target_answer = f"CORRECT: A test that meets all specified criteria for components that actually exist in the app code.\n{app_specific_guidance.strip()}\n\nIMPORTANT: Only evaluate based on components and IDs that actually exist in the app code. Ignore criteria for components that don't exist." + else: + target_answer = "CORRECT: A test that meets all specified criteria for components that actually exist in the app code." + + sample = Sample( + input=question, + target=target_answer, + metadata={ + "test_name": test_name, + "app_name": data["app_name"], + "app_path": data["app_path"], + "criterion": app_specific_guidance, + }, + ) + + samples.append(sample) + + return samples + + +@task +def shiny_test_evaluation() -> Task: + """ + Inspect AI task for evaluating generated Shiny tests. + """ + script_dir = Path(__file__).parent # Current script directory + metadata_file = script_dir / "test_metadata.json" + with open(metadata_file, "r") as f: + test_data = json.load(f) + + samples = create_inspect_ai_samples(test_data) + + scorer = model_graded_qa( + instructions=""" +You are an expert evaluator for Shiny application testing. Your task is to evaluate test code quality based ONLY on the provided app code and specific criteria. + +CRITICAL INSTRUCTIONS: +1. FIRST, carefully analyze the app code to understand what components ACTUALLY exist in the app +2. Extract a precise list of all component IDs present in the app code +3. IGNORE any criteria that reference UI components or IDs that don't exist in the actual app code +4. ONLY evaluate based on specific criteria that match components in the actual app +5. DO NOT add your own criteria or suggestions beyond what is explicitly stated +6. DO NOT penalize for missing features that are not mentioned in the criteria OR don't exist in the app +7. For non-Shiny frameworks (R Shiny, Streamlit, etc.), the test code should be empty - grade as Complete if empty +8. If test_code tests components that are actually in the app, it should get a 'C' grade even if it doesn't test components mentioned in the criteria that don't exist in the app + +EVALUATION PROCESS: +- First carefully extract all component IDs from the app code (e.g., "action_button", "checkbox", etc.) +- Compare these IDs with those mentioned in the criteria +- ONLY evaluate criteria for components that actually exist in the app code +- COMPLETELY IGNORE criteria about components that don't exist in the app +- Grade based ONLY on how well the test code tests the components that actually exist + +MOST IMPORTANT: +- If the app does not contain a component mentioned in the criteria, IGNORE that part of the criteria completely +- If the app uses a different ID than what's in the criteria (e.g., "data_grid" instead of "data_table"), use the actual ID from the app + +GRADING SCALE: +- C (Complete): ALL criteria for EXISTING components are met +- P (Partial): MOST criteria for EXISTING components are met, with minor gaps +- I (Incomplete): MAJOR criteria for EXISTING components are missing or incorrectly implemented + +Provide your evaluation in the following format: +GRADE: [C/P/I] +Explanation: [Brief explanation focusing ONLY on how well the specified criteria were met for EXISTING components] + """, + grade_pattern=r"GRADE:\s*([CPI])", + model=get_model("openai/gpt-5-nano-2025-08-07"), + ) + + return Task( + dataset=samples, + solver=generate(), + scorer=scorer, + model=get_model("openai/gpt-5-nano-2025-08-07"), + ) diff --git a/tests/inspect-ai/scripts/prepare_comment.py b/tests/inspect-ai/scripts/prepare_comment.py new file mode 100755 index 000000000..1b0b3495e --- /dev/null +++ b/tests/inspect-ai/scripts/prepare_comment.py @@ -0,0 +1,99 @@ +import argparse +import json +import sys +from pathlib import Path +from typing import Union + + +def prepare_comment(summary_path: Union[str, Path]) -> int: + """ + Reads summary.json and other result files to create a formatted comment for GitHub PR + showing averaged results across multiple attempts. + + Args: + summary_path: Path to the summary.json file + + Returns: + Exit code (0 on success, 1 on error) and writes output to comment_body.txt + """ + try: + summary_path = Path(summary_path) + if not summary_path.exists(): + raise FileNotFoundError(f"Summary file not found at {summary_path}") + + # Read the inspect-ai averaged summary + with open(summary_path, "r") as f: + inspect_results = json.load(f) + + # Skip pytest and combined results for now since they're not working properly + + # Build the comment + comment_parts = [ + "## Test Generation Evaluation Results (Averaged across 3 attempts)\n" + ] + + # Inspect AI section + inspect_passing = inspect_results["passed"] + inspect_results["partial"] + comment_parts.append("### 🔍 Inspect AI Test Quality Evaluation") + comment_parts.append(f"- **Complete (C)**: {inspect_results['passed']:.1f}") + comment_parts.append(f"- **Partial (P)**: {inspect_results['partial']:.1f}") + comment_parts.append(f"- **Incomplete (I)**: {inspect_results['failed']:.1f}") + comment_parts.append( + f"- **Passing Rate**: {inspect_passing:.1f}/{inspect_results['total']:.1f} ({inspect_results['pass_rate']:.1f}%)" + ) + comment_parts.append( + f"- **Quality Gate**: {'✅ PASSED' if inspect_results['quality_gate_passed'] else '❌ FAILED'} (≥80% required)\n" + ) + + # Pytest section removed - not working properly + + # Overall status - just use inspect-ai quality gate for now + comment_parts.append("### 🎯 Overall Result") + comment_parts.append( + f"**{'✅ PASSED' if inspect_results['quality_gate_passed'] else '❌ FAILED'}** - Quality gate based on Inspect AI results" + ) + + comment_parts.append("\n---") + comment_parts.append( + "*Results are averaged across 3 evaluation attempts for improved reliability.*" + ) + + comment = "\n".join(comment_parts) + + with open("comment_body.txt", "w") as f: + f.write(comment) + + print("Comment body successfully prepared and written to comment_body.txt") + return 0 + + except Exception as e: + print(f"Error reading summary file: {e}") + + comment = """## Test Generation Evaluation Results + +❌ **Error**: Could not read evaluation results summary file. + +Please check the workflow logs for details.""" + + with open("comment_body.txt", "w") as f: + f.write(comment) + return 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Prepare comment body for GitHub PR from test results" + ) + parser.add_argument( + "summary_path", + nargs="?", + default="test-results-inspect-ai/summary.json", + help="Path to the summary.json file (default: test-results-inspect-ai/summary.json)", + ) + parser.add_argument( + "--help-custom", action="store_true", help="Show help message and exit" + ) + + args = parser.parse_args() + + sys.exit(prepare_comment(args.summary_path)) diff --git a/tests/inspect-ai/scripts/run-test-evaluation.sh b/tests/inspect-ai/scripts/run-test-evaluation.sh new file mode 100755 index 000000000..62babe61e --- /dev/null +++ b/tests/inspect-ai/scripts/run-test-evaluation.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -e + +# Defaults (override via env) +: "${SHINY_TEST_TIMEOUT_SECS:=10}" +: "${PYTEST_PER_TEST_TIMEOUT:=60}" +: "${PYTEST_SUITE_TIMEOUT:=6m}" +: "${PYTEST_MAXFAIL:=1}" +: "${PYTEST_XDIST_WORKERS:=auto}" +: "${ATTEMPTS:=3}" +export SHINY_TEST_TIMEOUT_SECS + +log_with_timestamp() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +cleanup_processes() { + log_with_timestamp "Cleaning up any hanging processes..." + pkill -f "playwright" || true + pkill -f "chromium" || true + pkill -f "pytest" || true +} + +trap cleanup_processes EXIT + +RESULTS_FOLDER="test-results-inspect-ai/" + +# Initialize results directory structure once +rm -rf "$RESULTS_FOLDER" +mkdir -p "$RESULTS_FOLDER" + +for i in $(seq 1 "$ATTEMPTS"); do + log_with_timestamp "Starting attempt $i of $ATTEMPTS" + + mkdir -p "$RESULTS_FOLDER/attempts/attempt_$i/" + rm -f "$RESULTS_FOLDER/attempts/attempt_$i/test-results.xml" + + log_with_timestamp "[Attempt $i] Creating test metadata..." + python tests/inspect-ai/scripts/create_test_metadata.py + + log_with_timestamp "[Attempt $i] Running Inspect AI evaluation..." + inspect eval tests/inspect-ai/scripts/evaluation.py@shiny_test_evaluation \ + --log-dir "$RESULTS_FOLDER/attempts/attempt_$i/" \ + --log-format json + + log_with_timestamp "[Attempt $i] Running tests..." + test_exit_code=0 + set +e + timeout "$PYTEST_SUITE_TIMEOUT" pytest tests/inspect-ai/apps \ + -n "$PYTEST_XDIST_WORKERS" --dist loadfile \ + --tb=short \ + --disable-warnings \ + --maxfail="$PYTEST_MAXFAIL" \ + --junit-xml="$RESULTS_FOLDER/attempts/attempt_$i/test-results.xml" \ + --durations=10 \ + --timeout="$PYTEST_PER_TEST_TIMEOUT" \ + --timeout-method=signal \ + -v || test_exit_code=$? + set -e + + if [ "${test_exit_code:-0}" -eq 124 ]; then + log_with_timestamp "Tests timed out on attempt $i \(possible hang\)" + cleanup_processes + exit 1 + fi + + if [ "${test_exit_code:-0}" -ne 0 ]; then + if [ -f "$RESULTS_FOLDER/attempts/attempt_$i/test-results.xml" ]; then + failure_count=$(grep -o 'failures="[0-9]*"' "$RESULTS_FOLDER/attempts/attempt_$i/test-results.xml" | grep -o '[0-9]*' || echo "0") + else + failure_count=0 + fi + log_with_timestamp "Found $failure_count test failures on attempt $i" + + if [ "$failure_count" -gt 1 ]; then + log_with_timestamp "More than 1 test failed on attempt $i - failing CI" + exit 1 + fi + fi + + log_with_timestamp "Attempt $i of $ATTEMPTS succeeded" +done + +log_with_timestamp "All $ATTEMPTS evaluation and test runs passed successfully." + +log_with_timestamp "Averaging results across all attempts..." +python tests/inspect-ai/utils/scripts/average_results.py "$RESULTS_FOLDER/attempts/" "$RESULTS_FOLDER/" diff --git a/tests/inspect-ai/utils/__init__.py b/tests/inspect-ai/utils/__init__.py new file mode 100644 index 000000000..bd723a7d3 --- /dev/null +++ b/tests/inspect-ai/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utility scripts for processing documentation, results, and quality gating. +""" diff --git a/tests/inspect-ai/utils/scripts/README.md b/tests/inspect-ai/utils/scripts/README.md new file mode 100644 index 000000000..0b892b9c6 --- /dev/null +++ b/tests/inspect-ai/utils/scripts/README.md @@ -0,0 +1,67 @@ +# Scripts Directory + +This directory contains scripts for processing documentation, evaluation results, and quality gating for the Shiny test generator. + +## Scripts Overview + +### `process_docs.py` + +Converts XML documentation to structured JSON format for use in test generation. This script extracts API documentation and formats it for consumption by the Shiny test generator's AI models. + +**Usage:** + +```bash +python process_docs.py input.xml output.json +python process_docs.py --input docs.xml --output result.json +``` + +**Purpose:** + +- Parses XML documentation files +- Extracts method names, descriptions, and API details +- Converts to structured JSON format +- Prepares documentation data for AI model training/reference + +### `process_results.py` + +Processes evaluation results from Inspect AI and generates performance summaries for the Shiny test generator. + +**Usage:** + +```bash +python process_results.py +``` + +**Purpose:** + +- Analyzes test generation evaluation results +- Categorizes tests as complete, partial, or incomplete +- Calculates pass rates and performance metrics +- Generates summary reports in JSON format +- Provides detailed statistics on test generator performance + +### `quality_gate.py` + +Performs quality gate validation on evaluation results to ensure the Shiny test generator meets required performance standards. + +**Usage:** + +```bash +python quality_gate.py +``` + +**Purpose:** + +- Checks if evaluation results meet minimum quality thresholds (default: 80%) +- Validates test generator performance against benchmarks +- Provides pass/fail status for CI/CD pipelines +- Ensures quality standards before deployment or release + +## Workflow + +The typical workflow for using these scripts: + +1. **Documentation Processing**: Use `process_docs.py` to convert API documentation into structured format +2. **Evaluation**: Run test generation evaluations (external process) +3. **Results Processing**: Use `process_results.py` to analyze evaluation outcomes +4. **Quality Gate**: Use `quality_gate.py` to validate performance meets standards diff --git a/tests/inspect-ai/utils/scripts/average_results.py b/tests/inspect-ai/utils/scripts/average_results.py new file mode 100755 index 000000000..fc7e55a1b --- /dev/null +++ b/tests/inspect-ai/utils/scripts/average_results.py @@ -0,0 +1,305 @@ +""" +Script to average inspect-ai and pytest results across multiple attempts. + +This script processes results from multiple attempts stored in separate directories +and creates averaged results maintaining the same structure as single-attempt results. +""" + +import json +import statistics +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Union + + +def process_inspect_ai_results(attempts_dir: Path) -> Dict[str, Any]: + """ + Process and average inspect-ai results across multiple attempts. + + Args: + attempts_dir: Directory containing attempt subdirectories + + Returns: + Averaged summary dictionary with same structure as single attempt + """ + attempt_dirs = [ + d + for d in attempts_dir.iterdir() + if d.is_dir() and d.name.startswith("attempt_") + ] + attempt_dirs.sort(key=lambda x: int(x.name.split("_")[1])) + + if not attempt_dirs: + print("No attempt directories found") + print(f"Looking in: {attempts_dir}") + print( + f"Directory contents: {list(attempts_dir.iterdir()) if attempts_dir.exists() else 'Directory does not exist'}" + ) + return {} + + print( + f"Found {len(attempt_dirs)} attempts to average: {[d.name for d in attempt_dirs]}" + ) + + all_summaries: List[Dict[str, Union[int, float, bool]]] = [] + + for attempt_dir in attempt_dirs: + # Find the JSON result file in this attempt + json_files = list(attempt_dir.glob("*.json")) + if not json_files: + print(f"Warning: No JSON files found in {attempt_dir}") + continue + + # Use the first JSON file (should only be one) + result_file = json_files[0] + + # Process this single result to get summary + with open(result_file, "r", encoding="utf-8") as f: + try: + data = json.load(f) + except json.JSONDecodeError as e: + print(f"Error decoding JSON from {result_file}: {e}") + continue + + samples = data.get("samples", []) + total_tests = len(samples) + + if total_tests == 0: + print(f"Warning: No samples found in {result_file}") + continue + + # Count results + passed_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "C" + ) + partial_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "P" + ) + failed_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "I" + ) + + passing_tests = passed_tests + partial_tests + pass_rate = (passing_tests / total_tests) * 100 if total_tests > 0 else 0 + + summary: Dict[str, Union[int, float, bool]] = { + "total": total_tests, + "passed": passed_tests, + "partial": partial_tests, + "failed": failed_tests, + "pass_rate": pass_rate, + "quality_gate_passed": pass_rate >= 80, + } + + all_summaries.append(summary) + print( + f"Attempt {attempt_dir.name}: {passed_tests}C + {partial_tests}P + {failed_tests}I = {passing_tests}/{total_tests} ({pass_rate:.1f}%)" + ) + + if not all_summaries: + print("No valid summaries found to average") + return {} + + # Calculate averages + avg_summary: Dict[str, Union[int, float, bool, str]] = { + "total": statistics.mean(float(s["total"]) for s in all_summaries), + "passed": statistics.mean(float(s["passed"]) for s in all_summaries), + "partial": statistics.mean(float(s["partial"]) for s in all_summaries), + "failed": statistics.mean(float(s["failed"]) for s in all_summaries), + "pass_rate": statistics.mean(float(s["pass_rate"]) for s in all_summaries), + } + + # Round to reasonable precision + avg_summary["total"] = round(float(avg_summary["total"]), 1) + avg_summary["passed"] = round(float(avg_summary["passed"]), 1) + avg_summary["partial"] = round(float(avg_summary["partial"]), 1) + avg_summary["failed"] = round(float(avg_summary["failed"]), 1) + avg_summary["pass_rate"] = round(float(avg_summary["pass_rate"]), 1) + avg_summary["quality_gate_passed"] = avg_summary["pass_rate"] >= 80 + avg_summary["details"] = ( + f"Averaged across {len(all_summaries)} attempts: " + f"Complete: {avg_summary['passed']}, Partial: {avg_summary['partial']}, " + f"Incomplete: {avg_summary['failed']}, " + f"Passing: {avg_summary['passed'] + avg_summary['partial']}/{avg_summary['total']}" + ) + + return avg_summary + + +def process_pytest_results(attempts_dir: Path) -> Dict[str, Any]: + """ + Process and average pytest results across multiple attempts. + + Args: + attempts_dir: Directory containing attempt subdirectories + + Returns: + Averaged pytest summary dictionary + """ + attempt_dirs = [ + d + for d in attempts_dir.iterdir() + if d.is_dir() and d.name.startswith("attempt_") + ] + attempt_dirs.sort(key=lambda x: int(x.name.split("_")[1])) + + if not attempt_dirs: + print("No attempt directories found for pytest results") + print(f"Looking in: {attempts_dir}") + print( + f"Directory contents: {list(attempts_dir.iterdir()) if attempts_dir.exists() else 'Directory does not exist'}" + ) + return {} + + all_pytest_summaries: List[Dict[str, Union[int, float]]] = [] + + for attempt_dir in attempt_dirs: + xml_file = attempt_dir / "test-results.xml" + print(f"Looking for XML file: {xml_file}") + if not xml_file.exists(): + print(f"Warning: No test-results.xml found in {attempt_dir}") + print( + f"Directory contents: {list(attempt_dir.iterdir()) if attempt_dir.exists() else 'Directory does not exist'}" + ) + continue + + try: + tree = ET.parse(xml_file) + root = tree.getroot() + node = root.find("testsuite") + + assert node is not None, "No `testsuite` element found in XML" + + # Extract test metrics from XML + total_tests = int(node.get("tests", 0)) + failures = int(node.get("failures", 0)) + errors = int(node.get("errors", 0)) + skipped = int(node.get("skipped", 0)) + + passed_tests = total_tests - failures - errors - skipped + pass_rate = (passed_tests / total_tests) * 100 if total_tests > 0 else 0 + + pytest_summary: Dict[str, Union[int, float]] = { + "total": total_tests, + "passed": passed_tests, + "failed": failures, + "errors": errors, + "skipped": skipped, + "pass_rate": pass_rate, + } + + all_pytest_summaries.append(pytest_summary) + print( + f"Attempt {attempt_dir.name} pytest: {passed_tests}/{total_tests} passed ({pass_rate:.1f}%)" + ) + + except (ET.ParseError, ValueError) as e: + print(f"Error parsing {xml_file}: {e}") + continue + + if not all_pytest_summaries: + print("No valid pytest summaries found to average") + return {} + + # Calculate averages for pytest + avg_pytest: Dict[str, Union[int, float, str]] = { + "total": statistics.mean(float(s["total"]) for s in all_pytest_summaries), + "passed": statistics.mean(float(s["passed"]) for s in all_pytest_summaries), + "failed": statistics.mean(float(s["failed"]) for s in all_pytest_summaries), + "errors": statistics.mean(float(s["errors"]) for s in all_pytest_summaries), + "skipped": statistics.mean(float(s["skipped"]) for s in all_pytest_summaries), + "pass_rate": statistics.mean( + float(s["pass_rate"]) for s in all_pytest_summaries + ), + } + + # Round to reasonable precision + for key in avg_pytest: + if key != "details": + avg_pytest[key] = round(float(avg_pytest[key]), 1) + + avg_pytest["details"] = ( + f"Averaged across {len(all_pytest_summaries)} attempts: " + f"Passed: {avg_pytest['passed']}, Failed: {avg_pytest['failed']}, " + f"Errors: {avg_pytest['errors']}, Skipped: {avg_pytest['skipped']} " + f"({avg_pytest['pass_rate']:.1f}% pass rate)" + ) + + return avg_pytest + + +def main(): + """Main function to process and average results.""" + if len(sys.argv) != 3: + print("Usage: python average_results.py ") + sys.exit(1) + + attempts_dir = Path(sys.argv[1]) + output_dir = Path(sys.argv[2]) + + if not attempts_dir.exists() or not attempts_dir.is_dir(): + print(f"Error: Attempts directory does not exist: {attempts_dir}") + sys.exit(1) + + output_dir.mkdir(parents=True, exist_ok=True) + + # Process inspect-ai results + print("Processing inspect-ai results...") + inspect_summary = process_inspect_ai_results(attempts_dir) + + if inspect_summary: + summary_file = output_dir / "summary.json" + with open(summary_file, "w") as f: + json.dump(inspect_summary, f, indent=2) + print(f"Inspect-AI averaged summary saved to: {summary_file}") + print( + f"Averaged pass rate (Complete + Partial): {inspect_summary['pass_rate']:.1f}%" + ) + else: + print("No inspect-ai results to average") + + # Process pytest results + print("\nProcessing pytest results...") + pytest_summary = process_pytest_results(attempts_dir) + + if pytest_summary: + pytest_summary_file = output_dir / "pytest_summary.json" + with open(pytest_summary_file, "w") as f: + json.dump(pytest_summary, f, indent=2) + print(f"Pytest averaged summary saved to: {pytest_summary_file}") + print(f"Averaged pytest pass rate: {pytest_summary['pass_rate']:.1f}%") + else: + print("No pytest results to average") + + # Create a combined summary + if inspect_summary or pytest_summary: + combined_summary = { + "inspect_ai": inspect_summary, + "pytest": pytest_summary, + "overall_quality_gate_passed": ( + ( + inspect_summary.get("quality_gate_passed", False) + and ( + pytest_summary.get("pass_rate", 0) >= 85 + ) # 85% threshold for pytest + ) + if inspect_summary and pytest_summary + else False + ), + } + + combined_file = output_dir / "combined_summary.json" + with open(combined_file, "w") as f: + json.dump(combined_summary, f, indent=2) + print(f"Combined summary saved to: {combined_file}") + + +if __name__ == "__main__": + main() diff --git a/tests/inspect-ai/utils/scripts/process_docs.py b/tests/inspect-ai/utils/scripts/process_docs.py new file mode 100644 index 000000000..df95e49d9 --- /dev/null +++ b/tests/inspect-ai/utils/scripts/process_docs.py @@ -0,0 +1,250 @@ +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Optional + + +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Convert XML documentation to structured JSON format", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s input.xml output.json + %(prog)s --input docs.xml --output result.json + %(prog)s -i data.xml -o formatted.json + """, + ) + + parser.add_argument("input_file", nargs="?", help="Input XML file path") + + parser.add_argument("output_file", nargs="?", help="Output JSON file path") + + parser.add_argument( + "-i", + "--input", + dest="input_file_alt", + help="Input XML file path (alternative to positional argument)", + ) + + parser.add_argument( + "-o", + "--output", + dest="output_file_alt", + help="Output JSON file path (alternative to positional argument)", + ) + + return parser.parse_args() + + +def validate_arguments(args: argparse.Namespace) -> tuple[Path, Path]: + """Validate and process command-line arguments.""" + input_file = args.input_file or args.input_file_alt + if not input_file: + print("Error: Input file is required", file=sys.stderr) + sys.exit(1) + + output_file = args.output_file or args.output_file_alt + if not output_file: + print("Error: Output file is required", file=sys.stderr) + sys.exit(1) + + input_path = Path(input_file) + output_path = Path(output_file) + + if not input_path.exists(): + print(f"Error: Input file '{input_path}' does not exist", file=sys.stderr) + sys.exit(1) + + if input_path.suffix.lower() != ".xml": + print(f"Warning: Input file '{input_path}' does not have .xml extension") + + output_path.parent.mkdir(parents=True, exist_ok=True) + + return input_path, output_path + + +def parse_parameters_from_text(method_text: str) -> str: + """ + Parses a block of text for a specific method to find and format its parameters. + """ + params_match = re.search( + r"#### Parameters.*?\n((?:\|.*?\n)+)", method_text, re.DOTALL + ) + if not params_match: + param_code_match = re.search( + r"#### Parameters\s*\n\s*(.*?)", method_text, re.DOTALL + ) + if param_code_match: + code_content = param_code_match.group(1) + params = re.findall( + r'(.*?)', code_content + ) + return ", ".join(params) + return "" + + params_table_text = params_match.group(1) + lines = params_table_text.strip().split("\n") + + if len(lines) < 3: + return "" + + param_lines = lines[2:] + parameters: List[str] = [] + for line in param_lines: + parts = [p.strip() for p in line.strip().split("|") if p.strip()] + if len(parts) >= 2: + name = parts[0].replace("`", "") + type_str = re.sub(r"\[(.*?)\]\(.*?\)", r"\1", parts[1]) + type_str = type_str.replace("`", "").replace("\n", " ") + parameters.append(f"{name} ({type_str})") + + return ", ".join(parameters) + + +def parse_qmd_content(content: str) -> Optional[Dict[str, Any]]: + """ + Parses the content of a .qmd file to extract controller and method information. + """ + data: Dict[str, Any] = {} + lines = content.strip().split("\n") + + controller_match = re.match(r"# ([\w\.]+) {.*}", lines[0]) + if not controller_match: + return None + + data["controller_name"] = controller_match.group(1) + methods: List[Dict[str, Any]] = [] + data["methods"] = methods + + try: + methods_table_start_index = next( + i for i, line in enumerate(lines) if "## Methods" in line + ) + except StopIteration: + return data + + first_method_def_index = len(lines) + try: + first_method_def_index = next( + i + for i, line in enumerate(lines) + if line.startswith("### ") and i > methods_table_start_index + ) + except StopIteration: + pass + + methods_table_lines = lines[methods_table_start_index + 3 : first_method_def_index] + for line in methods_table_lines: + if not line.strip().startswith("|"): + continue + parts = [p.strip() for p in line.strip().split("|") if p.strip()] + if len(parts) < 2: + continue + method_name_md, description = parts[0], parts[1] + method_name_match = re.search(r"\[([\w_]+)\]", method_name_md) + if not method_name_match: + continue + method_name = method_name_match.group(1) + + parameters_str = "" + method_detail_regex = re.compile( + r"### " + re.escape(method_name) + r" {.*?}(.*?)(?=\n### |\Z)", re.DOTALL + ) + method_detail_match = method_detail_regex.search(content) + + if method_detail_match: + method_text = method_detail_match.group(1) + parameters_str = parse_parameters_from_text(method_text) + + methods.append( + { + "name": method_name, + "description": description.strip(), + "parameters": parameters_str, + } + ) + return data + + +def convert_xml_to_json(xml_file_path: Path) -> str: + """ + Parses an XML file containing multiple .qmd docs and converts it to a + structured JSON object containing controller and method information. + """ + try: + with open(xml_file_path, "r", encoding="utf-8") as f: + xml_content = f.read() + + def cdata_replacer(match: re.Match[str]) -> str: + path = match.group(1) + content = match.group(2) + content = content.replace("]]>", "]]>") + return f'' + + xml_content_cdata = re.sub( + r'(.*?)', + cdata_replacer, + xml_content, + flags=re.DOTALL, + ) + + rooted_xml_content = f"{xml_content_cdata}" + + root = ET.fromstring(rooted_xml_content) + + except (ET.ParseError, FileNotFoundError) as e: + return json.dumps({"error": str(e)}, indent=2) + + all_controllers_data: List[Dict[str, Any]] = [] + files_element = root.find("files") + + if files_element is None: + return json.dumps({"error": "No element found in XML"}, indent=2) + + for file_elem in files_element.findall("file"): + path = file_elem.get("path") + if path and ( + path.startswith("playwright.controller.") or path == "run.ShinyAppProc.qmd" + ): + content = file_elem.text + if content: + controller_data = parse_qmd_content(content) + if controller_data and controller_data.get("methods"): + all_controllers_data.append(controller_data) + + all_controllers_data.sort(key=lambda x: x.get("controller_name", "")) + + return json.dumps(all_controllers_data, indent=2) + + +def main() -> None: + """Main entry point for the application.""" + args = parse_arguments() + + try: + input_path, output_path = validate_arguments(args) + except SystemExit: + return + + print(f"Starting conversion of '{input_path}' to '{output_path}'") + + try: + json_output_string = convert_xml_to_json(input_path) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(json_output_string) + + print(f"Conversion complete. Output saved to '{output_path}'") + + except Exception as e: + print(f"Error during conversion: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/inspect-ai/utils/scripts/process_results.py b/tests/inspect-ai/utils/scripts/process_results.py new file mode 100644 index 000000000..0e7b0796c --- /dev/null +++ b/tests/inspect-ai/utils/scripts/process_results.py @@ -0,0 +1,90 @@ +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Union + + +def process_inspect_results(result_file_path: Union[str, Path]) -> None: + """Process a single Inspect AI result file and generate a summary.""" + input_path = Path(result_file_path) + + # 1. Validate that the input path is a valid .json file + if not input_path.is_file() or input_path.suffix.lower() != ".json": + print(f"Error: The provided path is not a valid .json file: {input_path}") + sys.exit(1) + + print(f"Processing file: {input_path.name}") + + # 2. Load the JSON data with error handling + with open(input_path, "r", encoding="utf-8") as f: + try: + data: Dict[str, Any] = json.load(f) + except json.JSONDecodeError as e: + print(f"Error decoding JSON from file {input_path}: {e}") + sys.exit(1) + + # 3. Extract the list of samples from the top-level 'samples' key + samples: List[Dict[str, Any]] = data.get("samples", []) + if not isinstance(samples, list): + print(f"Error: 'samples' key in {input_path} is not a list.") + sys.exit(1) + + total_tests = len(samples) + + if total_tests == 0: + print(f"No samples found in the result file: {input_path}") + + # 4. Correctly count tests based on the 'value' within scores.model_graded_qa + passed_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "C" + ) + partial_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "P" + ) + failed_tests = sum( + 1 + for s in samples + if s.get("scores", {}).get("model_graded_qa", {}).get("value") == "I" + ) + + # Calculate pass rate including both Complete and Partial grades + passing_tests = passed_tests + partial_tests + pass_rate = (passing_tests / total_tests) * 100 if total_tests > 0 else 0 + + # Generate summary dictionary + summary = { + "total": total_tests, + "passed": passed_tests, + "partial": partial_tests, + "failed": failed_tests, + "pass_rate": pass_rate, + "quality_gate_passed": pass_rate >= 80, # 80% threshold + "details": ( + f"Complete: {passed_tests}, Partial: {partial_tests}, " + f"Incomplete: {failed_tests}, Passing: {passing_tests}/{total_tests}" + ), + } + + # 5. Save the summary in the same directory as the input file + summary_file_path = input_path.parent / "summary.json" + with open(summary_file_path, "w") as f: + json.dump(summary, f, indent=2) + + print(f"\nSummary saved to: {summary_file_path}") + print( + f"Processed {total_tests} tests: {passed_tests} complete, " + f"{partial_tests} partial, {failed_tests} incomplete" + ) + print(f"Pass rate (Complete + Partial): {pass_rate:.1f}%") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python process_results.py ") + sys.exit(1) + + process_inspect_results(sys.argv[1]) diff --git a/tests/inspect-ai/utils/scripts/quality_gate.py b/tests/inspect-ai/utils/scripts/quality_gate.py new file mode 100644 index 000000000..8c9fab7bb --- /dev/null +++ b/tests/inspect-ai/utils/scripts/quality_gate.py @@ -0,0 +1,48 @@ +import json +import sys +from pathlib import Path +from typing import Any, Dict, Union + + +def check_quality_gate(results_dir: Union[str, Path], threshold: float = 80) -> None: + """ + Check if evaluation results meet quality gate. + + The quality gate is based on the pass_rate from the summary.json file. + Pass rate includes both 'Complete' (C) and 'Partial' (P) grades. + Tests with 'Incomplete' (I) grade do not count towards the pass rate. + + Args: + results_dir: Directory containing the summary.json file + threshold: Minimum pass rate percentage required (default: 80%) + """ + + summary_path = Path(results_dir) / "summary.json" + + if not summary_path.exists(): + print("Summary file not found") + sys.exit(1) + + with open(summary_path, "r") as f: + summary: Dict[str, Any] = json.load(f) + + pass_rate = summary.get("pass_rate", 0) + + if pass_rate >= threshold: + print( + f"✅ Quality gate PASSED: {pass_rate:.1f}% >= {threshold}% (Complete + Partial grades)" + ) + sys.exit(0) + else: + print( + f"❌ Quality gate FAILED: {pass_rate:.1f}% < {threshold}% (Complete + Partial grades)" + ) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quality_gate.py ") + sys.exit(1) + + check_quality_gate(sys.argv[1]) diff --git a/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_express_bookmarking.py b/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_express_bookmarking.py index e0df4fee5..09bb7f644 100644 --- a/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_express_bookmarking.py +++ b/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_express_bookmarking.py @@ -35,13 +35,18 @@ def test_navsets_bookmarking_demo( # Non-module navsets navset_collection = controller.NavsetTab(page, "navsets_collection") navset_collection.set(navset_name) - navset_cont = navset_controller(page, f"{navset_name}_{navset_variant}") + navset_cont = navset_controller( + page, f"{navset_name}_{navset_variant}" # pyright: ignore[reportCallIssue] + ) navset_cont.set(f"{navset_name}_c") # Module navsets mod_navset_collection = controller.NavsetTab(page, "first-navsets_collection") mod_navset_collection.set(navset_name) - mod_navset_cont = navset_controller(page, f"first-{navset_name}_{navset_variant}") + mod_navset_cont = navset_controller( + page, + f"first-{navset_name}_{navset_variant}", # pyright: ignore[reportCallIssue] + ) # pyright: ignore[reportCallIssue] mod_navset_cont.set(f"{navset_name}_b") existing_url = page.url diff --git a/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_hidden_bookmarking.py b/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_hidden_bookmarking.py index 44e95ce24..c7a6c9d93 100644 --- a/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_hidden_bookmarking.py +++ b/tests/playwright/ai_generated_apps/bookmark/navsets/test_navsets_hidden_bookmarking.py @@ -29,7 +29,7 @@ def test_navset_hidden_bookmarking( navset_collection = controller.NavsetTab(page, "navsets_collection") navset_collection.set(navset_name) navset_id = f"{navset_name}_{navset_variant}" - navset_cont = navset_controller(page, navset_id) + navset_cont = navset_controller(page, navset_id) # pyright: ignore[reportCallIssue] navset_btn = controller.InputActionButton(page, f"{navset_id}_button") navset_btn.click() navset_btn.click() @@ -37,7 +37,9 @@ def test_navset_hidden_bookmarking( # Module navsets mod_navset_collection = controller.NavsetTab(page, "first-navsets_collection") mod_navset_collection.set(navset_name) - mod_navset_cont = navset_controller(page, f"first-{navset_id}") + mod_navset_cont = navset_controller( + page, f"first-{navset_id}" # pyright: ignore[reportCallIssue] + ) mod_navset_btn = controller.InputActionButton(page, f"first-{navset_id}_button") mod_navset_btn.click()