Skip to content

Commit 30f56cc

Browse files
authored
[Feat] Send client errors to host (#10856)
Final feature PR implements extension of `host_config` endpoint with `blockErrorDialogs`, as well as `CLIENT_ERROR` & `CLIENT_ERROR_DIALOG` messages to the host on following failures: * Websocket connection issues * See #10831 * Custom component load * See #10781 * Media load (audio, video, image, etc) * See #10837 * Error dialogs (Page Not Found, Bad message format, etc.) * See #10760
1 parent c1663ba commit 30f56cc

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1820
-58
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from streamlit_ace import st_ace
16+
17+
import streamlit as st
18+
19+
## Spawn a new Ace editor
20+
st.subheader("Ace Editor Component", divider="blue")
21+
content = st_ace()
22+
st.write(content)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from playwright.sync_api import Page, Route, expect
16+
17+
from e2e_playwright.conftest import wait_for_app_run, wait_until
18+
19+
# The timeout value from ComponentInstance.tsx
20+
COMPONENT_READY_WARNING_TIME_MS = 60000 # 60 seconds
21+
22+
23+
def handle_component_source_failure(route: Route):
24+
"""Handle custom component source request by returning a 404 status."""
25+
route.fulfill(status=404, headers={"Content-Type": "text/plain"}, body="Not Found")
26+
27+
28+
def handle_component_timeout_failure(route: Route):
29+
"""Handle custom component request by aborting the request (trigger catch in fetch)."""
30+
route.abort("failed")
31+
32+
33+
def test_component_source_failure(page: Page, app_port: int):
34+
"""Test that a component source failure is handled correctly."""
35+
# Ensure custom component source requests return a 404 status
36+
page.route(
37+
f"http://localhost:{app_port}/component/**", handle_component_source_failure
38+
)
39+
40+
# Capture console messages
41+
messages = []
42+
page.on("console", lambda msg: messages.append(msg.text))
43+
44+
# Navigate to the app
45+
page.goto(f"http://localhost:{app_port}")
46+
wait_for_app_run(page)
47+
48+
# Expect the iframe to be attached
49+
expect(page.get_by_test_id("stCustomComponentV1")).to_be_attached()
50+
51+
# Wait until the expected error is logged, which indicates CLIENT_ERROR was sent
52+
wait_until(
53+
page,
54+
lambda: any(
55+
"Client Error: Custom Component streamlit_ace.streamlit_ace source error"
56+
in message
57+
for message in messages
58+
),
59+
)
60+
61+
62+
def test_component_timeout_failure(page: Page, app_port: int):
63+
"""Test that a component timeout failure is handled correctly."""
64+
# Ensure custom component requests times out
65+
page.route(
66+
f"http://localhost:{app_port}/component/**", handle_component_timeout_failure
67+
)
68+
69+
# Capture console messages
70+
messages = []
71+
page.on("console", lambda msg: messages.append(msg.text))
72+
73+
# Navigate to the app
74+
page.goto(f"http://localhost:{app_port}")
75+
76+
# Expect the iframe to be attached
77+
expect(page.get_by_test_id("stCustomComponentV1")).to_be_attached()
78+
79+
# Fetch error should be logged
80+
wait_until(
81+
page,
82+
lambda: any(
83+
"Client Error: Custom Component streamlit_ace.streamlit_ace fetch error"
84+
in message
85+
for message in messages
86+
),
87+
)
88+
89+
# Wait for the component to timeout
90+
page.wait_for_timeout(COMPONENT_READY_WARNING_TIME_MS)
91+
92+
wait_until(
93+
page,
94+
lambda: any(
95+
"Client Error: Custom Component streamlit_ace.streamlit_ace timeout error"
96+
in message
97+
for message in messages
98+
),
99+
)
100+
101+
# Wait for the warning to appear and verify
102+
expect(page.get_by_test_id("stAlert")).to_be_visible()

e2e_playwright/host_config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717

1818
import streamlit as st
1919

20+
21+
def page2():
22+
st.header("Page 2")
23+
24+
25+
def page3():
26+
st.header("Page 3")
27+
28+
29+
# Make MPA to use for dialog blocking test
30+
st.navigation(
31+
[
32+
st.Page(page2, title="02_App_Page_2", default=True),
33+
st.Page(page3, title="03_App_Page_3"),
34+
]
35+
)
36+
2037
# always generate the same data
2138
np.random.seed(0)
2239

e2e_playwright/host_config_test.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,18 @@
1414

1515
from playwright.sync_api import Page, Route, expect
1616

17-
from e2e_playwright.conftest import ImageCompareFunction, wait_for_app_loaded
17+
from e2e_playwright.conftest import (
18+
ImageCompareFunction,
19+
wait_for_app_loaded,
20+
wait_until,
21+
)
1822

1923

20-
def handle_route_hostconfig_disable_fullscreen(route: Route) -> None:
24+
def handle_route_hostconfig_disable_fullscreen_and_error_dialogs(route: Route) -> None:
2125
response = route.fetch()
2226
body = response.json()
2327
body["disableFullscreenMode"] = True
28+
body["blockErrorDialogs"] = True
2429
route.fulfill(
2530
# Pass all fields from the response.
2631
response=response,
@@ -33,7 +38,10 @@ def test_disable_fullscreen(
3338
page: Page, app_port: int, assert_snapshot: ImageCompareFunction
3439
):
3540
"""Test that fullscreen mode is disabled for elements when set via host-config."""
36-
page.route("**/_stcore/host-config", handle_route_hostconfig_disable_fullscreen)
41+
page.route(
42+
"**/_stcore/host-config",
43+
handle_route_hostconfig_disable_fullscreen_and_error_dialogs,
44+
)
3745
page.goto(f"http://localhost:{app_port}")
3846
wait_for_app_loaded(page)
3947

@@ -52,3 +60,36 @@ def test_disable_fullscreen(
5260
assert_snapshot(
5361
dataframe_toolbar, name="host_config-dataframe_disabled_fullscreen_mode"
5462
)
63+
64+
65+
def test_block_error_dialogs(page: Page, app_port: int):
66+
"""Test that error dialogs are blocked and sent to host when set via host-config."""
67+
# Need to be more specific about the route to allow for successful redirect
68+
page.route(
69+
f"http://localhost:{app_port}/_stcore/host-config",
70+
handle_route_hostconfig_disable_fullscreen_and_error_dialogs,
71+
)
72+
73+
# Initial load of page
74+
page.goto(f"http://localhost:{app_port}")
75+
wait_for_app_loaded(page)
76+
77+
# Capture console messages
78+
messages = []
79+
page.on("console", lambda msg: messages.append(msg))
80+
81+
# Navigate to a non-existent page to trigger page not found error
82+
page.goto(f"http://localhost:{app_port}/nonexistent_page")
83+
84+
# Wait until the expected error is logged - console should include 2 404 errors (health & host-config) then the page not found error
85+
wait_until(
86+
page,
87+
lambda: any(
88+
"The page that you have requested does not seem to exist. Running the app's main page."
89+
in message.text
90+
for message in messages
91+
),
92+
)
93+
94+
# Verify no error dialog is shown
95+
expect(page.get_by_role("dialog")).not_to_be_attached()

e2e_playwright/multipage_apps_v2/mpa_v2_basics_test.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ImageCompareFunction,
2121
wait_for_app_loaded,
2222
wait_for_app_run,
23+
wait_until,
2324
)
2425
from e2e_playwright.shared.app_utils import (
2526
click_button,
@@ -539,3 +540,35 @@ def test_sidebar_interaction_performance(app: Page):
539540
options = sidebar.locator("li")
540541
for option in options.all():
541542
option.hover()
543+
544+
545+
def test_logo_source_errors(app: Page, app_port: int):
546+
"""Test that logo source errors are logged."""
547+
app.route(
548+
f"http://localhost:{app_port}/media/**",
549+
lambda route: route.fulfill(
550+
status=404, headers={"Content-Type": "text/plain"}, body="Not Found"
551+
),
552+
)
553+
554+
# Capture console messages
555+
messages = []
556+
app.on("console", lambda msg: messages.append(msg.text))
557+
558+
# Navigate to the app
559+
app.goto(f"http://localhost:{app_port}")
560+
561+
# Wait until the expected error is logged, indicating CLIENT_ERROR was sent
562+
# for the logo in the main app area and the sidebar
563+
wait_until(
564+
app,
565+
lambda: any(
566+
"Client Error: Logo source error" in message for message in messages
567+
),
568+
)
569+
wait_until(
570+
app,
571+
lambda: any(
572+
"Client Error: Sidebar Logo source error" in message for message in messages
573+
),
574+
)

e2e_playwright/st_audio_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@
2323
click_checkbox,
2424
)
2525

26+
AUDIO_ELEMENTS_WITH_PATH = 3
27+
AUDIO_ELEMENTS_WITH_URL = 3
28+
29+
30+
def check_audio_source_error_count(messages: list[str], expected_count: int):
31+
"""Check that the expected number of audio source error messages are logged."""
32+
assert (
33+
len(
34+
[
35+
message
36+
for message in messages
37+
if "Client Error: Audio source error" in message
38+
]
39+
)
40+
# when test run on webkit, it will sometimes log extra instances of the error
41+
# for the same source - so we use >= expected_count to avoid flakiness
42+
>= expected_count
43+
)
44+
2645

2746
def test_audio_has_correct_properties(app: Page):
2847
"""Test that `st.audio` renders correct properties."""
@@ -123,3 +142,57 @@ def test_audio_uses_unified_height(
123142
themed_app.wait_for_timeout(1000)
124143

125144
assert_snapshot(audio_element, name="st_audio-unified_height")
145+
146+
147+
# TODO(mgbarnes): Figure out why this test is flaky on Firefox.
148+
@pytest.mark.skip_browser("firefox")
149+
def test_audio_source_error_with_url(app: Page, app_port: int):
150+
"""Test `st.audio` source error when data is a url."""
151+
# Ensure audio source request return a 404 status
152+
app.route(
153+
"https://mdn.github.io/learning-area/html/multimedia-and-embedding/video-and-audio-content/viper.mp3",
154+
lambda route: route.fulfill(
155+
status=404, headers={"Content-Type": "text/plain"}, body="Not Found"
156+
),
157+
)
158+
159+
# Capture console messages
160+
messages = []
161+
app.on("console", lambda msg: messages.append(msg.text))
162+
163+
# Navigate to the app
164+
app.goto(f"http://localhost:{app_port}")
165+
166+
# Wait until the expected error is logged, indicating CLIENT_ERROR was sent
167+
# Should be 3 instances of the error, one for each audio element with url
168+
wait_until(
169+
app,
170+
lambda: check_audio_source_error_count(messages, AUDIO_ELEMENTS_WITH_URL),
171+
)
172+
173+
174+
# TODO(mgbarnes): Figure out why this test is flaky on Firefox.
175+
@pytest.mark.skip_browser("firefox")
176+
def test_audio_source_error_with_path(app: Page, app_port: int):
177+
"""Test `st.audio` source error when data is path (media endpoint)."""
178+
# Ensure audio source request return a 404 status
179+
app.route(
180+
f"http://localhost:{app_port}/media/**",
181+
lambda route: route.fulfill(
182+
status=404, headers={"Content-Type": "text/plain"}, body="Not Found"
183+
),
184+
)
185+
186+
# Capture console messages
187+
messages = []
188+
app.on("console", lambda msg: messages.append(msg.text))
189+
190+
# Navigate to the app
191+
app.goto(f"http://localhost:{app_port}")
192+
193+
# Wait until the expected errors are logged, indicating CLIENT_ERROR was sent
194+
# Should be 3 instances of the error, one for each audio element with path
195+
wait_until(
196+
app,
197+
lambda: check_audio_source_error_count(messages, AUDIO_ELEMENTS_WITH_PATH),
198+
)

0 commit comments

Comments
 (0)