Skip to content

Commit

Permalink
Migrate iframe_resizer e2e test from Cypress to Playwright (streamlit…
Browse files Browse the repository at this point in the history
…#9433)

## Describe your changes

Migrate the existing test to Playwright.

Update the convenience function for loading a Streamlit app in an iframe
to take additional arguments to make it configurable. In this case,
allow
- injecting the iframe resizer script
- add query params to the source
- add id to iframe html element

Also, exclude our `e2e_playwright/test_assets/` folder from the license
check.

## GitHub Issue Link (if applicable)

## Testing Plan

- Explanation of why no additional tests are needed
- Unit Tests (JS and/or Python)
- E2E Tests
  - Migrate the existing e2e test
- Any manual testing needed?

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.
  • Loading branch information
raethlein authored Sep 10, 2024
1 parent d07ba7a commit d8f2901
Show file tree
Hide file tree
Showing 32 changed files with 333 additions and 173 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ repos:
|^component-lib/declarations/apache-arrow
|^frontend/app/src/assets/css/variables\.scss
|^lib/tests/streamlit/elements/test_html\.js
|^e2e_playwright/test_assets/
- id: insert-license
name: Add license for all Proto files
files: \.proto$
Expand Down
92 changes: 0 additions & 92 deletions e2e/specs/iframe_resizer.spec.js

This file was deleted.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
204 changes: 137 additions & 67 deletions e2e_playwright/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@
import pytest
import requests
from PIL import Image
from playwright.sync_api import (
ElementHandle,
FrameLocator,
Locator,
Page,
Response,
Route,
)
from pytest import FixtureRequest

if TYPE_CHECKING:
from types import ModuleType

from playwright.sync_api import (
ElementHandle,
FrameLocator,
Locator,
Page,
Response,
Route,
)


def reorder_early_fixtures(metafunc: pytest.Metafunc):
"""Put fixtures with `pytest.mark.early` first during execution
Expand Down Expand Up @@ -274,80 +273,136 @@ def app_with_query_params(
return page, query_params


@dataclass
class IframedPageAttrs:
# id attribute added to the iframe html tag
element_id: str | None = None
# query params to be appended to the iframe src URL
src_query_params: dict[str, str] | None = None
# additional HTML body
additional_html_head: str | None = None


@dataclass
class IframedPage:
# the page to configure
page: Page
# opens the configured page via the iframe URL and returns the frame_locator pointing to the iframe
open_app: Callable[[], FrameLocator]
# opens the configured page via the iframe URL and returns the frame_locator
# pointing to the iframe
open_app: Callable[[IframedPageAttrs | None], FrameLocator]


@pytest.fixture(scope="function")
def iframed_app(page: Page, app_port: int) -> IframedPage:
"""Fixture that returns an IframedPage.
The page object can be used to configure additional routes, for example to override the host-config.
The open_app function triggers the opening of the app in an iframe.
The page object can be used to configure additional routes, for example to override
the host-config. The open_app function triggers the opening of the app in an iframe.
"""
# we are going to intercept the request, so the address is arbitrarily chose and does not have to exist
# we are going to intercept the request, so the address and html-file is arbitrarily
# chosen and does not even exist
fake_iframe_server_origin = "http://localhost:1345"
fake_iframe_server_route = f"{fake_iframe_server_origin}/iframed_app.html"
# the url where the Streamlit server is reachable
app_url = f"http://localhost:{app_port}"
# the CSP header returned for the Streamlit index.html loaded in the iframe. This is similar to a common CSP we have seen in the wild.
app_csp_header = f"default-src 'none'; worker-src blob:; form-action 'none'; connect-src ws://localhost:{app_port}/_stcore/stream http://localhost:{app_port}/_stcore/allowed-message-origins http://localhost:{app_port}/_stcore/host-config http://localhost:{app_port}/_stcore/health; script-src 'unsafe-inline' 'unsafe-eval' {app_url}/static/js/; style-src 'unsafe-inline' {app_url}/static/css/; img-src data: {app_url}/favicon.png {app_url}/favicon.ico; font-src {app_url}/static/fonts/ {app_url}/static/media/; frame-ancestors {fake_iframe_server_origin};"

def fulfill_iframe_request(route: Route) -> None:
"""Return as response an iframe that loads the actual Streamlit app."""

browser = page.context.browser
# webkit requires the iframe's parent to have "blob:" set, for example if we want to download a CSV via the blob: url
# chrome seems to be more lax
frame_src_blob = ""
if browser is not None and (
browser.browser_type.name == "webkit"
or browser.browser_type.name == "firefox"
):
frame_src_blob = "blob:"

route.fulfill(
status=200,
body=f"""
<iframe
src="{app_url}"
title="Iframed Streamlit App"
allow="clipboard-write;"
sandbox="allow-popups allow-same-origin allow-scripts allow-downloads"
width="100%"
height="100%">
</iframe>
""",
headers={
"Content-Type": "text/html",
"Content-Security-Policy": f"frame-src {frame_src_blob} {app_url};",
},
)
# the CSP header returned for the Streamlit index.html loaded in the iframe. This is
# similar to a common CSP we have seen in the wild.
app_csp_header = (
f"default-src 'none'; worker-src blob:; form-action 'none'; "
f"connect-src ws://localhost:{app_port}/_stcore/stream "
f"http://localhost:{app_port}/_stcore/allowed-message-origins "
f"http://localhost:{app_port}/_stcore/host-config "
f"http://localhost:{app_port}/_stcore/health; script-src 'unsafe-inline' "
f"'unsafe-eval' {app_url}/static/js/; style-src 'unsafe-inline' "
f"{app_url}/static/css/; img-src data: {app_url}/favicon.png "
f"{app_url}/favicon.ico; font-src {app_url}/static/fonts/ "
f"{app_url}/static/media/; frame-ancestors {fake_iframe_server_origin};"
)

# intercept all requests to the fake iframe server and fullfil the request in playwright
page.route(fake_iframe_server_route, fulfill_iframe_request)
def _open_app(iframe_element_attrs: IframedPageAttrs | None = None) -> FrameLocator:
_iframe_element_attrs = iframe_element_attrs
if _iframe_element_attrs is None:
_iframe_element_attrs = IframedPageAttrs()

def fullfill_streamlit_app_request(route: Route) -> None:
response = route.fetch()
route.fulfill(
body=response.body(),
headers={**response.headers, "Content-Security-Policy": app_csp_header},
query_params = ""
if _iframe_element_attrs.src_query_params:
query_params = "?" + parse.urlencode(_iframe_element_attrs.src_query_params)

src = f"{app_url}/{query_params}"
additional_html_head = (
_iframe_element_attrs.additional_html_head
if _iframe_element_attrs.additional_html_head
else ""
)
_iframed_body = f"""
<!DOCTYPE html>
<html style="height: 100%;">
<head>
<meta charset="UTF-8">
<title>Iframed Streamlit App</title>
{additional_html_head}
</head>
<body style="height: 100%;">
<iframe
src={src}
id={_iframe_element_attrs.element_id
if _iframe_element_attrs.element_id
else ""}
title="Iframed Streamlit App"
allow="clipboard-write;"
sandbox="allow-popups allow-same-origin allow-scripts allow-downloads"
width="100%"
>
</iframe>
</body>
</html>
"""

def fulfill_iframe_request(route: Route) -> None:
"""Return as response an iframe that loads the actual Streamlit app."""

browser = page.context.browser
# webkit requires the iframe's parent to have "blob:" set, for example if we
# want to download a CSV via the blob: url; Chrome seems to be more lax
frame_src_blob = ""
if browser is not None and (
browser.browser_type.name == "webkit"
or browser.browser_type.name == "firefox"
):
frame_src_blob = "blob:"

route.fulfill(
status=200,
body=_iframed_body,
headers={
"Content-Type": "text/html",
"Content-Security-Policy": f"frame-src {frame_src_blob} {app_url};",
},
)

# intercept all requests to the fake iframe server and fullfil the request in
# playwright
page.route(fake_iframe_server_route, fulfill_iframe_request)

def fullfill_streamlit_app_request(route: Route) -> None:
"""Get the actual Streamlit app and return it's content."""
response = route.fetch()
route.fulfill(
body=response.body(),
headers={**response.headers, "Content-Security-Policy": app_csp_header},
)

page.route(f"{app_url}/", fullfill_streamlit_app_request)
# this will route the request to the actual Streamlit app
page.route(src, fullfill_streamlit_app_request)

def _open_app() -> FrameLocator:
def _expect_streamlit_app_loaded_in_iframe_with_added_header(
response: Response,
) -> bool:
"""Ensure that the routing-interception worked and that Streamlit app is indeed loaded with the CSP header we expect"""
"""Ensure that the routing-interception worked and that Streamlit app is
indeed loaded with the CSP header we expect"""

return (
response.url == f"{app_url}/"
response.url == src
and response.headers["content-security-policy"] == app_csp_header
)

Expand Down Expand Up @@ -444,7 +499,7 @@ def __call__(

@pytest.fixture(scope="session")
def output_folder(pytestconfig: Any) -> Path:
"""Fixture that returns the directory that is used for all test failures information.
"""Fixture returning the directory that is used for all test failures information.
This includes:
- snapshot-tests-failures: This directory contains all the snapshots that did not
Expand Down Expand Up @@ -596,7 +651,8 @@ def compare(
img_b.save(f"{test_failures_dir}/expected_{snapshot_file_name}{file_extension}")

pytest.fail(
f"Snapshot mismatch for {snapshot_file_name} ({mismatch} pixels difference; {mismatch/total_pixels * 100:.2f}%)"
f"Snapshot mismatch for {snapshot_file_name} ({mismatch} pixels difference;"
f" {mismatch/total_pixels * 100:.2f}%)"
)

yield compare
Expand All @@ -608,21 +664,35 @@ def compare(
# Public utility methods:


def wait_for_app_run(page: Page, wait_delay: int = 100):
def wait_for_app_run(
page_or_locator: Page | Locator | FrameLocator, wait_delay: int = 100
):
"""Wait for the given page to finish running."""
# Add a little timeout to wait for eventual debounce timeouts used in some widgets.

page = None
if isinstance(page_or_locator, Page):
page = page_or_locator
elif isinstance(page_or_locator, Locator):
page = page_or_locator.page
elif isinstance(page_or_locator, FrameLocator):
page = page_or_locator.owner.page

# if isinstance(page, Page):
page.wait_for_timeout(155)
# Make sure that the websocket connection is established.
page.wait_for_selector(
"[data-testid='stApp'][data-test-connection-state='CONNECTED']",
page_or_locator.locator(
"[data-testid='stApp'][data-test-connection-state='CONNECTED']"
).wait_for(
timeout=20000,
state="attached",
)
# Wait until we know the script has started. We determine this by checking
# whether the app is in notRunning state. (The data-test-connection-state attribute
# goes through the sequence "initial" -> "running" -> "notRunning").
page.wait_for_selector(
"[data-testid='stApp'][data-test-script-state='notRunning']",
page_or_locator.locator(
"[data-testid='stApp'][data-test-script-state='notRunning']"
).wait_for(
timeout=20000,
state="attached",
)
Expand Down Expand Up @@ -657,7 +727,7 @@ def rerun_app(page: Page):
wait_for_app_run(page)


def wait_until(page: Page, fn: callable, timeout: int = 5000, interval: int = 100):
def wait_until(page: Page, fn: Callable, timeout: int = 5000, interval: int = 100):
"""Run a test function in a loop until it evaluates to True
or times out.
Expand All @@ -668,7 +738,7 @@ def wait_until(page: Page, fn: callable, timeout: int = 5000, interval: int = 10
----------
page : playwright.sync_api.Page
Playwright page
fn : callable
fn : Callable
Callback
timeout : int, optional
Total timeout in milliseconds, by default 5000
Expand Down
Loading

0 comments on commit d8f2901

Please sign in to comment.