Skip to content

[feat] Add external URL runner in e2e playwright#13678

Merged
sfc-gh-bnisco merged 2 commits intodevelopfrom
01-22-_feat_add_external_url_runner_in_e2e_playwright
Feb 12, 2026
Merged

[feat] Add external URL runner in e2e playwright#13678
sfc-gh-bnisco merged 2 commits intodevelopfrom
01-22-_feat_add_external_url_runner_in_e2e_playwright

Conversation

@sfc-gh-bnisco
Copy link
Copy Markdown
Collaborator

@sfc-gh-bnisco sfc-gh-bnisco commented Jan 22, 2026

Describe your changes

Added support for running Playwright E2E tests against externally hosted Streamlit apps (instead of starting a local app server).

  • External app / host modes
    • --external-app-url (or STREAMLIT_E2E_EXTERNAL_APP_URL) to target an already-running app URL
    • Optional --external-host-url (or STREAMLIT_E2E_EXTERNAL_HOST_URL) for cases where the app is embedded in a host page
    • Optional --external-iframe-selector (or STREAMLIT_E2E_EXTERNAL_IFRAME_SELECTOR, default iframe) to locate the embedded app iframe
  • Authenticated browser sessions
    • --browser-state-path (or STREAMLIT_E2E_BROWSER_STATE_PATH) to load a Playwright storage state JSON file
  • Introduced a new external_test marker (@pytest.mark.external_test) for tests that are compatible with external mode.
  • When external mode is enabled, tests not marked external_test are skipped; if no tests are marked, pytest fails early with a clear usage error to avoid silently skipping the entire suite.
  • Added shared helpers to read configuration from either CLI options or environment variables.
  • Updated app navigation URL construction to preserve existing trailing-slash behavior while handling optional URL fragments cleanly.

Testing Plan

  • The usual CI pipeline validates the existing tests.
  • The changes have been manually tested by running the mega tester app test with the external app URL option.

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 22, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@sfc-gh-bnisco sfc-gh-bnisco added change:feature PR contains new feature or enhancement implementation impact:internal PR changes only affect internal code labels Jan 22, 2026 — with Graphite App
Copy link
Copy Markdown
Collaborator Author

sfc-gh-bnisco commented Jan 22, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 22, 2026

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-13678/streamlit-1.54.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-13678.streamlit.app (☁️ Deploy here if not accessible)

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 9e9dfa9 to 24b4488 Compare January 23, 2026 21:05
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 23, 2026

📉 Frontend coverage change detected

The frontend unit test (vitest) coverage has decreased by 0.0000%

  • Current PR: 86.8200% (13884 lines, 1829 missed)
  • Latest develop: 86.8200% (13884 lines, 1829 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 24b4488 to 07b02c8 Compare January 26, 2026 21:03
@sfc-gh-bnisco sfc-gh-bnisco requested a review from Copilot January 27, 2026 00:38
@sfc-gh-bnisco sfc-gh-bnisco added the ai-review If applied to PR or issue will run AI review workflow label Jan 27, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Jan 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR adds the ability to run E2E Playwright tests against an externally hosted Streamlit app URL instead of spinning up a local server. It introduces:

  • Two new CLI options: --external-app-url and --browser-state-path
  • Corresponding environment variable support: STREAMLIT_E2E_EXTERNAL_APP_URL and STREAMLIT_E2E_BROWSER_STATE_PATH
  • A new iframeTest marker to designate tests compatible with external app execution mode
  • An app_base_url fixture for tests that need to work in both local and external modes
  • Automatic skipping of non-marked tests when running in external app mode

Code Quality

The implementation is clean and follows existing patterns in the codebase. Key observations:

Positives:

  • Type annotations are properly used throughout, including the cast on line 133 with an explanatory comment
  • The _get_config_option_or_env helper function is well-structured and provides a clean abstraction
  • The browser_state_path fixture properly validates the file exists before returning (lines 311-313)
  • The skip mechanism using pytest.skip() is appropriate and follows best practices

Issues:

  1. Marker naming inconsistency (e2e_playwright/conftest.py:104): The new marker iframeTest uses camelCase, which is inconsistent with existing markers in the codebase:

    • no_perf (snake_case)
    • app_hash (snake_case)

    Per PEP 8 and the project's Python guidelines, this should be iframe_test.

  2. Incomplete PR description: The PR template sections for description, testing plan, and changes are empty. This makes it difficult to understand the intent and use cases for this feature.

Test Coverage

  • The change modifies test_no_console_errors in mega_tester_app_test.py to demonstrate the new pattern by:

    • Adding the @pytest.mark.iframeTest marker
    • Switching from app_port: int to app_base_url: str parameter
    • Using goto_app(page, app_base_url) instead of a hardcoded localhost URL
  • Concern: There are no unit tests for the new _get_config_option_or_env helper function. While this is a simple utility, adding a test would ensure the fallback logic between CLI options and environment variables works correctly.

  • The autouse fixture skip_non_iframe_tests_in_external_mode is not directly tested, though its behavior can be verified by running tests with the external URL option.

Backwards Compatibility

This change is fully backwards compatible:

  • All existing tests continue to work without modification
  • The external app mode is entirely opt-in via CLI options or environment variables
  • When not using external mode, the behavior is identical to before

Security & Risk

  • Low risk: The browser_state_path fixture validates the file exists before use, which prevents potential errors
  • The feature is additive and isolated behind explicit opt-in flags
  • No sensitive data handling concerns identified

Recommendations

  1. Rename the marker for consistency - Change iframeTest to iframe_test in both conftest.py (lines 103-105, 351) and mega_tester_app_test.py (line 65):
# In conftest.py
config.addinivalue_line(
    "markers",
    "iframe_test: mark test as compatible with external app execution mode",
)

# In skip fixture
if external_app_url and request.node.get_closest_marker("iframe_test") is None:
  1. Fill in the PR description - Add details about:

    • The use case for this feature (e.g., testing against deployed apps)
    • How to use the new CLI options
    • The testing plan/verification performed
  2. Consider adding a unit test for _get_config_option_or_env to verify:

    • CLI option takes precedence over environment variable
    • Environment variable is used when CLI option is not provided
    • Returns None when neither is set
  3. Minor documentation suggestion - Consider adding a brief comment or docstring update in the module explaining the external app testing workflow for future maintainers.

Verdict

CHANGES REQUESTED: The implementation is solid and the feature is useful, but the marker naming should be changed from iframeTest to iframe_test for consistency with existing markers and Python naming conventions. Additionally, the PR description should be filled in to explain the feature and its use cases.


This is an automated AI review using opus-4.5-thinking. Please verify the feedback and use your judgment.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Jan 27, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for running e2e playwright tests against externally hosted Streamlit apps instead of requiring a local server. This enables testing against production-like environments or remote deployments.

Changes:

  • Added CLI options --external-app-url and --browser-state-path (with corresponding environment variables) to configure external app testing
  • Created app_base_url fixture that returns either the external URL or local URL based on configuration
  • Modified app_server fixture to skip server startup when external mode is active
  • Added auto-skip logic to restrict external mode to tests marked with @pytest.mark.iframeTest
  • Updated test_no_console_errors to use app_base_url and marked it as external-compatible

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.

File Description
e2e_playwright/conftest.py Added fixtures and CLI options for external app URL and browser state, modified app_server to skip startup in external mode, added iframeTest marker and auto-skip logic
e2e_playwright/mega_tester_app_test.py Updated test_no_console_errors to use app_base_url fixture and marked as iframeTest compatible
Comments suppressed due to low confidence (1)

e2e_playwright/conftest.py:408

  • The static_app fixture at line 393 hardcodes http://localhost:{app_port} and doesn't support external app mode. Similar to the app fixture, this should either be updated to support external mode or there should be documentation explaining why it's excluded. Consider whether this fixture should use app_base_url or if static apps inherently can't work with external mode.
@pytest.fixture
def static_app(
    page: Page,
    app_port: int,
    request: pytest.FixtureRequest,
) -> Page:
    """Fixture that opens the app."""
    query_param = request.node.get_closest_marker("query_param")
    query_string = query_param.args[0] if query_param else ""

    # Indicate this is a StaticPage
    page.__class__ = StaticPage

    page.goto(f"http://localhost:{app_port}/{query_string}")
    start_capture_traces(page)
    wait_for_app_loaded(page)
    return page

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 07b02c8 to 212927b Compare January 27, 2026 00:48
@sfc-gh-bnisco sfc-gh-bnisco removed the do-not-merge PR is blocked from merging label Jan 27, 2026
@sfc-gh-bnisco sfc-gh-bnisco requested a review from Copilot January 27, 2026 01:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

Comments suppressed due to low confidence (1)

e2e_playwright/conftest.py:408

  • The static_app fixture uses hardcoded http://localhost:{app_port} instead of the new app_base_url fixture. This will prevent tests using this fixture from working with external apps. Consider updating this fixture to accept and use app_base_url parameter.
@pytest.fixture
def static_app(
    page: Page,
    app_port: int,
    request: pytest.FixtureRequest,
) -> Page:
    """Fixture that opens the app."""
    query_param = request.node.get_closest_marker("query_param")
    query_string = query_param.args[0] if query_param else ""

    # Indicate this is a StaticPage
    page.__class__ = StaticPage

    page.goto(f"http://localhost:{app_port}/{query_string}")
    start_capture_traces(page)
    wait_for_app_loaded(page)
    return page

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 212927b to 6ca20fe Compare January 27, 2026 19:33
@sfc-gh-bnisco sfc-gh-bnisco requested a review from Copilot January 27, 2026 19:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

e2e_playwright/conftest.py:454

  • The static_app fixture still uses app_port directly to construct URLs. For consistency and to support external app testing, consider updating it to use app_base_url instead, similar to how the app fixture was updated. This would allow static_app to work with externally hosted apps.
@pytest.fixture
def static_app(
    page: Page,
    app_port: int,
    request: pytest.FixtureRequest,
) -> Page:
    """Fixture that opens the app."""
    query_param = request.node.get_closest_marker("query_param")
    query_string = query_param.args[0] if query_param else ""

    # Indicate this is a StaticPage
    page.__class__ = StaticPage

    page.goto(f"http://localhost:{app_port}/{query_string}")
    start_capture_traces(page)
    wait_for_app_loaded(page)
    return page

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch 3 times, most recently from 7a22365 to 2099055 Compare January 29, 2026 00:48
@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 2099055 to e0ba6f5 Compare January 29, 2026 01:04
@sfc-gh-bnisco sfc-gh-bnisco requested a review from Copilot January 29, 2026 01:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

e2e_playwright/conftest.py:578

  • The static_app, app_with_query_params, and iframed_app fixtures still hardcode http://localhost:{app_port} URLs instead of using the app_base_url fixture. This means they won't work with external app URLs. If these fixtures should also support external mode, they should be updated to use app_base_url. If they're not meant to work in external mode, tests using them should be automatically skipped in external mode (similar to how tests without the external_test marker are skipped).
@pytest.fixture
def static_app(
    page: Page,
    app_port: int,
    request: pytest.FixtureRequest,
) -> Page:
    """Fixture that opens the app."""
    query_param = request.node.get_closest_marker("query_param")
    query_string = query_param.args[0] if query_param else ""

    # Indicate this is a StaticPage
    page.__class__ = StaticPage

    page.goto(f"http://localhost:{app_port}/{query_string}")
    start_capture_traces(page)
    wait_for_app_loaded(page)
    return page


@pytest.fixture
def app_with_query_params(
    page: Page, app_port: int, request: pytest.FixtureRequest
) -> tuple[Page, dict[str, Any]]:
    """Fixture that opens the app with additional query parameters.
    The query parameters are passed as a dictionary in the 'param' key of the request.
    """
    query_params = request.param
    query_string = parse.urlencode(query_params, doseq=True)
    url = f"http://localhost:{app_port}/?{query_string}"
    page.goto(url)
    wait_for_app_loaded(page)

    return page, query_params

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from 5b7e6e1 to d6871d4 Compare January 29, 2026 19:16
@sfc-gh-bnisco sfc-gh-bnisco marked this pull request as ready for review January 29, 2026 22:06
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 2, 2026

Summary

Adds external app execution support for Playwright e2e tests via new CLI/env options, fixtures to skip local server startup, optional host-page/iframe targeting inputs, and Playwright storage state support, plus documentation updates.

Code Quality

Overall structure is clear and consistent with existing e2e test infrastructure. There are a couple of URL normalization behaviors that can unintentionally alter user-provided external URLs:

  • _build_app_url strips trailing slashes when a fragment is provided, even if the base URL explicitly included one. That can change routing semantics for externally hosted apps that distinguish /app/ from /app, especially when @pytest.mark.app_hash is used. Consider preserving the original path and only normalizing the empty-path case to /.
```200:229:e2e_playwright/conftest.py
def _build_app_url(base_url: str, *, fragment: str | None) -> str:
    split = parse.urlsplit(base_url)
    root_path = "/"

    # `rstrip("/")` normalizes "/" and "" to the empty string, which we treat
    # explicitly below by mapping it back to "/".
    path = split.path.rstrip("/")

    if fragment is not None:
        # For fragment navigation, do *not* force a trailing slash.
        effective_path = path or root_path
        frag = fragment.lstrip("#")
        return parse.urlunsplit(
            (split.scheme, split.netloc, effective_path, split.query, frag)
        )
  • themed_app forces a trailing slash before the query string, which may also change the semantics of external URLs with non-root paths. Consider using a helper that appends the query without modifying the provided path.
```884:889:e2e_playwright/conftest.py
def themed_app(page: Page, app_base_url: str, app_theme: str) -> Page:
    """Fixture that opens the app with the given theme."""
    base = app_base_url.rstrip("/")
    page.goto(f"{base}/?embed_options={app_theme}")

Test Coverage

No new unit or e2e tests were added. This is understandable for infrastructure-level changes, but the external URL behaviors (especially path handling) are untested. A minimal, opt-in test (or a doc-only verified manual step) would help validate external app and host URL flows.

Backwards Compatibility

Local (default) e2e behavior is preserved. External mode introduces URL normalization that may break apps hosted on path-sensitive routes (e.g., /app vs /app/) when fragments or themed URLs are used.

Security & Risk

No direct security concerns identified. Main risk is unintended routing changes for externally hosted apps as noted above.

Recommendations

  1. Preserve user-provided external URL paths when adding fragments or query parameters, only normalizing the empty-path case to /.
  2. Consider adding a small opt-in validation or documentation note for external URL path sensitivity and recommended formats.

Verdict

CHANGES_REQUESTED: Please address the external URL path normalization risks before merge to avoid breaking path-sensitive deployments.


This is an automated AI review using gpt-5.2-codex-high. Please verify the feedback and use your judgment.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Feb 2, 2026
@lukasmasuch lukasmasuch removed the do-not-merge PR is blocked from merging label Feb 2, 2026
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍 But maybe take another look at the path normalization as suggested by ai-review.

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from d6871d4 to bf9998a Compare February 4, 2026 21:34
Comment on lines +203 to +220
def _build_app_url(base_url: str, *, fragment: str | None) -> str:
"""Build an app navigation URL from ``base_url`` and an optional fragment.

This preserves the user-provided path (including any trailing slash) to
avoid altering routing semantics for externally hosted apps.

The only normalization we do is mapping an empty path to ``"/"`` so that
``https://example.com#foo`` and ``https://example.com/#foo`` both navigate
to the root path with the given fragment.
"""
split = parse.urlsplit(base_url)
path = _normalize_empty_path(split.path)

if fragment is not None:
frag = fragment.lstrip("#")
return parse.urlunsplit((split.scheme, split.netloc, path, split.query, frag))

return parse.urlunsplit((split.scheme, split.netloc, path, split.query, ""))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _build_app_url function discards any fragment/hash that exists in the base_url when fragment parameter is None. This will break external app URLs that include a fragment.

What breaks: If a user provides --external-app-url https://example.com/app#section1 and a test doesn't use @pytest.mark.app_hash, the #section1 fragment is lost because line 220 sets the fragment to an empty string "" instead of preserving split.fragment.

Fix:

if fragment is not None:
    frag = fragment.lstrip("#")
    return parse.urlunsplit((split.scheme, split.netloc, path, split.query, frag))

return parse.urlunsplit((split.scheme, split.netloc, path, split.query, split.fragment))
Suggested change
def _build_app_url(base_url: str, *, fragment: str | None) -> str:
"""Build an app navigation URL from ``base_url`` and an optional fragment.
This preserves the user-provided path (including any trailing slash) to
avoid altering routing semantics for externally hosted apps.
The only normalization we do is mapping an empty path to ``"/"`` so that
``https://example.com#foo`` and ``https://example.com/#foo`` both navigate
to the root path with the given fragment.
"""
split = parse.urlsplit(base_url)
path = _normalize_empty_path(split.path)
if fragment is not None:
frag = fragment.lstrip("#")
return parse.urlunsplit((split.scheme, split.netloc, path, split.query, frag))
return parse.urlunsplit((split.scheme, split.netloc, path, split.query, ""))
def _build_app_url(base_url: str, *, fragment: str | None) -> str:
"""Build an app navigation URL from ``base_url`` and an optional fragment.
This preserves the user-provided path (including any trailing slash) to
avoid altering routing semantics for externally hosted apps.
The only normalization we do is mapping an empty path to ``"/"`` so that
``https://example.com#foo`` and ``https://example.com/#foo`` both navigate
to the root path with the given fragment.
"""
split = parse.urlsplit(base_url)
path = _normalize_empty_path(split.path)
if fragment is not None:
frag = fragment.lstrip("#")
return parse.urlunsplit((split.scheme, split.netloc, path, split.query, frag))
return parse.urlunsplit((split.scheme, split.netloc, path, split.query, split.fragment))

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@sfc-gh-bnisco sfc-gh-bnisco force-pushed the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch from bf9998a to 8a999a0 Compare February 12, 2026 00:42
@sfc-gh-bnisco
Copy link
Copy Markdown
Collaborator Author

sfc-gh-bnisco commented Feb 12, 2026

Merge activity

  • Feb 12, 8:25 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Feb 12, 8:25 PM UTC: @sfc-gh-bnisco merged this pull request with Graphite.

@sfc-gh-bnisco sfc-gh-bnisco merged commit 2b9f72d into develop Feb 12, 2026
44 of 45 checks passed
@sfc-gh-bnisco sfc-gh-bnisco deleted the 01-22-_feat_add_external_url_runner_in_e2e_playwright branch February 12, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:feature PR contains new feature or enhancement implementation impact:internal PR changes only affect internal code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants