/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
e2e_playwright/st_chat_input_test.py
1 994 строки
73 KB
Nico Bellante
[fix] Increase max width of uploaded files in `st.chat_input` (#13589)
15 янв 2026, 00:10
Не верифицирован
15 янв 2026, 00:10
2baed15
Код
Авторство
О чём код?
# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations from functools import wraps from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlencode, urlparse import pytest from playwright.sync_api import Error, FilePayload, Locator, Page, expect if TYPE_CHECKING: from collections.abc import Callable from typing import Any from e2e_playwright.conftest import ( ImageCompareFunction, rerun_app, wait_for_app_loaded, wait_for_app_run, wait_until, ) from e2e_playwright.shared.app_utils import ( check_top_level_class, click_button, click_toggle, expect_help_tooltip, expect_markdown, expect_prefixed_markdown, get_element_by_key, goto_app, reset_hovering, select_selectbox_option, ) def use_chat_input(key: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to automatically navigate to a specific chat input before running the test. Usage: @use_chat_input("single_file") def test_my_test(app: Page): # Test automatically navigates to the page with ?key=single_file # ... test code """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: # Find the Page object by type - check all positional and keyword arguments page = None # Check positional arguments for arg in args: if isinstance(arg, Page): page = arg break # Check keyword arguments if not found if page is None: for arg in kwargs.values(): if isinstance(arg, Page): page = arg break if page is None: func_name = getattr(func, "__name__", "unknown") raise ValueError( f"Could not find Page object in test {func_name}. " f"Make sure the test has a Page fixture parameter (e.g., app, themed_app)." ) # Navigate to the chat input goto_chat_input(page, key) # Run the test return func(*args, **kwargs) return wrapper return decorator def goto_chat_input(app: Page, key: str) -> None: """Navigate to a specific chat input using query params.""" # Extract port and existing query params from current URL parsed = urlparse(app.url) if parsed.port is None: raise ValueError(f"Could not parse port from URL: {app.url}") # Preserve existing query parameters (especially theme-related ones like embed_options) existing_params = parse_qs(parsed.query) # Flatten the params (parse_qs returns lists as values) params = {k: v[0] for k, v in existing_params.items() if v} # Set/override the key parameter params["key"] = key query_string = urlencode(params) app.goto(f"http://localhost:{parsed.port}/?{query_string}") wait_for_app_loaded(app) def expect_chat_input_value_contains_text(app: Page, key: str, text: str) -> None: """Assert that chat input value's text field contains the expected text. Args: app: Page object key: Chat input key - used to identify the specific output line text: Expected text content """ # Look for the simple format: "<key> - text: <value>" expected_line = f"{key} - text: {text}" expect(app.get_by_text(expected_line)).to_be_visible() def expect_chat_input_value_contains_audio(app: Page, key: str) -> None: """Assert that chat input value has an audio field populated (not None). Args: app: Page object key: Chat input key - used to identify the specific output line Verifies that audio was recorded by checking the output line contains a .wav filename (not "None"). """ # Look for the pattern: "<key> - audio: " followed by a .wav filename # We use a regex pattern since the filename includes a timestamp audio_line_locator = app.get_by_text(f"{key} - audio:", exact=False) expect(audio_line_locator).to_be_visible() # Ensure the audio field is NOT "None" expect(app.get_by_text(f"{key} - audio: None", exact=True)).not_to_be_visible() def expect_chat_input_value_contains_files( app: Page, key: str, file_count: int ) -> None: """Assert that chat input value contains the expected number of uploaded files. Args: app: Page object key: Chat input key - used to identify the specific output line file_count: Expected number of files """ # Look for the simple format: "<key> - files: <N> files" expected_line = f"{key} - files: {file_count} files" expect(app.get_by_text(expected_line)).to_be_visible() def file_upload_helper(app: Page, chat_input: Locator, files: list[FilePayload]): upload_button = chat_input.get_by_test_id("stChatInputFileUploadButton") expect(upload_button).to_be_visible() upload_button.scroll_into_view_if_needed() # Ensure button is ready to be clicked (WebKit specific issue) expect(upload_button).to_be_enabled() # Wait until the upload button is fully enabled, up to 2 seconds expect(upload_button).to_be_enabled(timeout=2000) with app.expect_file_chooser() as fc_info: # Use force=True for WebKit to ensure the click triggers file chooser upload_button.click(force=True) file_chooser = fc_info.value file_chooser.set_files(files=files) # take away hover focus of button app.keyboard.press("Escape") app.get_by_test_id("stApp").click(position={"x": 0, "y": 0}, force=True) wait_for_app_run(app, 500) def directory_upload_helper(app: Page, chat_input: Locator): """Helper function for directory upload tests.""" upload_button = chat_input.get_by_test_id("stChatInputFileUploadButton") expect(upload_button).to_be_visible() upload_button.scroll_into_view_if_needed() # For directory upload, we simulate the interaction without actual files # since we don't want to snapshot test with real directory uploads with app.expect_file_chooser() as fc_info: upload_button.click() file_chooser = fc_info.value # Set directory flag (this would be a directory selection in real usage) file_chooser.set_files(files=[]) # Empty for simulation # Take away hover focus of button app.keyboard.press("Escape") app.get_by_test_id("stApp").click(position={"x": 0, "y": 0}, force=True) wait_for_app_run(app, 500) def grant_microphone_permissions(page: Page) -> None: """Grant microphone permissions where supported.""" try: page.context.grant_permissions(["microphone"]) except Error as e: # It's safe to ignore failure: contexts might not support permissions in all environments. print(f"Could not grant microphone permissions: {e}") def record_audio_in_chat_input( app: Page, chat_input: Locator, duration_ms: int = 1500 ) -> None: """Record audio in chat input for specified duration. Note: Clicking approve automatically submits the chat input. Args: app: Page object chat_input: Locator for the chat input element duration_ms: Duration to record in milliseconds """ # Start recording start_audio_recording(chat_input) # Record for the specified duration (wait_for_timeout is acceptable here) app.wait_for_timeout(duration_ms) # Click approve button - this submits the chat input automatically approve_button = chat_input.get_by_test_id("stChatInputApproveButton") approve_button.click() wait_for_app_run(app) def start_audio_recording(chat_input: Locator) -> None: """Start audio recording without submitting. This helper starts recording and waits for the recording UI to appear, but does not click approve or cancel. Useful for testing recording states. Args: chat_input: Locator for the chat input element """ # Click microphone button to start recording mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() mic_button.click() # Wait for approve button to appear (indicates recording started) approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).to_be_visible() # Also verify cancel button appears cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") expect(cancel_button).to_be_visible() def test_chat_input_rendering(themed_app: Page, assert_snapshot: ImageCompareFunction): """Test that the st.chat_input widgets are correctly rendered via screenshot matching.""" # set taller height to ensure inputs do not overlap themed_app.set_viewport_size({"width": 750, "height": 2000}) goto_chat_input(themed_app, "inline") assert_snapshot( get_element_by_key(themed_app, "inline"), name="st_chat_input-inline" ) goto_chat_input(themed_app, "disabled_with_file") assert_snapshot( get_element_by_key(themed_app, "disabled_with_file"), name="st_chat_input-in_column_disabled", ) goto_chat_input(themed_app, "callback") assert_snapshot( get_element_by_key(themed_app, "callback"), name="st_chat_input-callback" ) goto_chat_input(themed_app, "single_file") assert_snapshot( get_element_by_key(themed_app, "single_file"), name="st_chat_input-single-file" ) goto_chat_input(themed_app, "multiple_files") assert_snapshot( get_element_by_key(themed_app, "multiple_files"), name="st_chat_input-multiple-files", ) goto_chat_input(themed_app, "width_300") assert_snapshot( get_element_by_key(themed_app, "width_300"), name="st_chat_input-width_300px" ) goto_chat_input(themed_app, "width_stretch") assert_snapshot( get_element_by_key(themed_app, "width_stretch"), name="st_chat_input-width_stretch", ) # The bottom chat input appears last in DOM order because st.chat_input() renders at bottom goto_chat_input(themed_app, "bottom_max_chars") assert_snapshot( get_element_by_key(themed_app, "bottom_max_chars"), name="st_chat_input-bottom", ) goto_chat_input(themed_app, "directory") assert_snapshot( get_element_by_key(themed_app, "directory"), name="st_chat_input-directory" ) goto_chat_input(themed_app, "directory_disabled") assert_snapshot( get_element_by_key(themed_app, "directory_disabled"), name="st_chat_input-directory_disabled", ) goto_chat_input(themed_app, "audio_with_files") assert_snapshot( get_element_by_key(themed_app, "audio_with_files"), name="st_chat_input-with_audio", ) goto_chat_input(themed_app, "audio_only") assert_snapshot( get_element_by_key(themed_app, "audio_only"), name="st_chat_input-audio_only" ) goto_chat_input(themed_app, "audio_disabled") assert_snapshot( get_element_by_key(themed_app, "audio_disabled"), name="st_chat_input-audio_disabled", ) goto_chat_input(themed_app, "audio_column") assert_snapshot( get_element_by_key(themed_app, "audio_column_a"), name="st_chat_input-column_audio", ) # Second column audio input in the audio_column section assert_snapshot( get_element_by_key(themed_app, "audio_column_b"), name="st_chat_input-column_audio_with_files", ) @use_chat_input("bottom_max_chars") def test_max_characters_enforced( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that the max_chars parameter is enforced.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) long_text = ( "Lorem ipsum dolor amet, consectetur adipiscing elit. Mauris tristique est at " "tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque " "at. Ut et dui molestie, iaculis magna sed. This text should not appear in the input." ) chat_input = get_element_by_key(themed_app, "bottom_max_chars") chat_input_area = chat_input.locator("textarea").first chat_input_area.type(long_text) expected_text = ( "Lorem ipsum dolor amet, consectetur adipiscing elit. Mauris tristique est at " "tincidunt pul vinar. Nam pulvinar neque sapien, eu pellentesque metus pellentesque " "at. Ut et dui molestie, iaculis magna se" ) expect(chat_input_area).to_have_value(expected_text) assert_snapshot(chat_input, name="st_chat_input-max_chars") def test_embedded_app_with_bottom_chat_input( themed_app: Page, app_port: int, app_theme: str, assert_snapshot: ImageCompareFunction, ): """Test that an embedded app with bottom chat input renders correctly.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) goto_app( themed_app, f"http://localhost:{app_port}/?key=bottom_max_chars&embed=true&embed_options={app_theme}", ) app_view_block = themed_app.get_by_test_id("stMainBlockContainer") # Bottom padding should be 16px (1rem): expect(app_view_block).to_have_css("padding-bottom", "16px") bottom_block = themed_app.get_by_test_id("stBottomBlockContainer") # Bottom padding should be 32px (2rem): expect(bottom_block).to_have_css("padding-bottom", "32px") # Top padding should be 16px (1rem): expect(bottom_block).to_have_css("padding-top", "16px") # There shouldn't be an iframe resizer anchor: expect(themed_app.get_by_test_id("stAppIframeResizerAnchor")).to_be_hidden() # The scroll container should be switched to scroll to bottom: expect(themed_app.get_by_test_id("stAppScrollToBottomContainer")).to_be_attached() assert_snapshot( themed_app.get_by_test_id("stAppViewContainer"), name="st_chat_input-app_embedded_with_bottom", ) @use_chat_input("bottom_max_chars") def test_app_with_bottom_chat_input( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that an app with bottom chat input renders correctly.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) app_view_block = themed_app.get_by_test_id("stMainBlockContainer") # Bottom padding should be 16px (1rem): expect(app_view_block).to_have_css("padding-bottom", "16px") bottom_block = themed_app.get_by_test_id("stBottomBlockContainer") # Bottom padding should be 56px (3.5rem): expect(bottom_block).to_have_css("padding-bottom", "56px") # Top padding should be 16px (1rem): expect(bottom_block).to_have_css("padding-top", "16px") # There shouldn't be an iframe resizer anchor: expect(themed_app.get_by_test_id("stAppIframeResizerAnchor")).to_be_hidden() # The scroll container should be switched to scroll to bottom: expect(themed_app.get_by_test_id("stAppScrollToBottomContainer")).to_be_attached() assert_snapshot( themed_app.get_by_test_id("stBottom"), name="st_chat_input-app_bottom" ) @use_chat_input("bottom_max_chars") def test_submit_hover_state_with_input_value( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test the submit button's hover state when input value is present.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(themed_app, "bottom_max_chars") chat_input_area = chat_input.locator("textarea").first chat_input_area.type("Corgi") submit_button = chat_input.get_by_test_id("stChatInputSubmitButton") submit_button.hover() assert_snapshot(chat_input, name="st_chat_input-submit_hover") @use_chat_input("bottom_max_chars") def test_enter_submits_clears_input(app: Page): """Test that pressing Enter submits and clears the input.""" expect_markdown(app, "bottom_max_chars - value: None") chat_input_area = ( get_element_by_key(app, "bottom_max_chars").locator("textarea").first ) chat_input_area.type("Corgi") chat_input_area.press("Enter") wait_for_app_run(app) expect(chat_input_area).to_have_value("") expect_markdown(app, "bottom_max_chars - value: Corgi") def test_shift_enter_creates_new_line( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that Shift+Enter creates a new line.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) goto_chat_input(themed_app, "bottom_max_chars") chat_input = get_element_by_key(themed_app, "bottom_max_chars") chat_input_area = chat_input.locator("textarea").first chat_input_area.fill("") # Clear the input first chat_input_area.press("Shift+Enter") chat_input_area.type("New Line") assert_snapshot(chat_input, name="st_chat_input-shift_enter_new_line") goto_chat_input(themed_app, "single_file") chat_input = get_element_by_key(themed_app, "single_file") chat_input_area = chat_input.locator("textarea").first chat_input_area.fill("") # Clear the input first chat_input_area.press("Shift+Enter") chat_input_area.type("New Line") assert_snapshot(chat_input, name="st_chat_input-file_upload_shift_enter_new_line") @use_chat_input("inline") def test_click_button_to_submit_clears_input(app: Page): """Test that clicking the button submits and clears the input.""" chat_input = get_element_by_key(app, "inline") submit_button = chat_input.get_by_test_id("stChatInputSubmitButton") chat_input_area = chat_input.locator("textarea").first chat_input_area.type("Corgi") submit_button.click() expect(chat_input_area).to_have_value("") expect_markdown(app, "inline - value: Corgi") @use_chat_input("bottom_max_chars") def test_chat_input_focus_state( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that st.chat_input renders the focus state correctly.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(themed_app, "bottom_max_chars") chat_input_area = chat_input.locator("textarea").first chat_input_area.click() expect(chat_input_area).to_be_focused() assert_snapshot(chat_input, name="st_chat_input-focused") @use_chat_input("bottom_max_chars") def test_grows_shrinks_input_text( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that input grows with long text and shrinks when text is deleted.""" num_backspaces = 20 # Number of backspaces to simulate shrinking the input themed_app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(themed_app, "bottom_max_chars") chat_input_area = chat_input.locator("textarea").first chat_input_area.type( "Lorem ipsum dolor amet, consectetur adipiscing elit. " "Mauris tristique est at tincidunt pul vinar. Nam pulvinar neque sapien, " "eu pellentesque metus pellentesque at. Ut et dui molestie, iaculis magna." ) assert_snapshot(chat_input, name="st_chat_input-grows") backspace_press_delay_ms = 10 for _ in range(num_backspaces): chat_input_area.press("Backspace", delay=backspace_press_delay_ms) assert_snapshot(chat_input, name="st_chat_input-shrinks") @use_chat_input("callback") def test_calls_callback_on_submit(app: Page): """Test that it correctly calls the callback on submit.""" chat_input_area = get_element_by_key(app, "callback").locator("textarea").first chat_input_area.type("hello world") chat_input_area.press("Enter") wait_for_app_run(app) expect_markdown(app, "chat input submitted") expect_markdown(app, "callback - session state value: hello world") expect_markdown(app, "callback - return value: hello world") rerun_app(app) # Expect the callback to not be triggered: expect(app.get_by_text("chat input submitted")).not_to_be_attached() # And the session state value to be reset expect_markdown(app, "callback - session state value: None") # Also expect the return value to be None expect_markdown(app, "callback - return value: None") @use_chat_input("single_file") def test_uploads_and_deletes_single_file( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that it correctly uploads and deletes a single file.""" themed_app.set_viewport_size({"width": 750, "height": 1500}) chat_input = get_element_by_key(themed_app, "single_file") expect(chat_input).to_be_visible() file_name1 = "file1.txt" file1 = FilePayload(name=file_name1, mimeType="text/plain", buffer=b"file1content") file_name2 = "file2.txt" file2 = FilePayload(name=file_name2, mimeType="text/plain", buffer=b"file2content") file_upload_helper(themed_app, chat_input, [file1]) uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files.get_by_text(file_name1)).to_be_visible() uploaded_files.scroll_into_view_if_needed() # Dismiss any tooltips before taking snapshot (WebKit can leave upload tooltip visible) reset_hovering(themed_app) assert_snapshot(uploaded_files, name="st_chat_input-single_file_uploaded") # Upload a second file. This one will replace the first. file_upload_helper(themed_app, chat_input, [file2]) expect(uploaded_files.get_by_text(file_name1)).not_to_be_visible() expect(uploaded_files.get_by_text(file_name2)).to_be_visible() # Delete the uploaded file uploaded_files.get_by_test_id("stChatInputDeleteBtn").first.click() wait_for_app_run(themed_app) # After deletion, the uploaded files container should not be visible expect(chat_input.get_by_test_id("stChatUploadedFiles")).not_to_be_visible() @use_chat_input("multiple_files") def test_uploads_and_deletes_multiple_files( app: Page, assert_snapshot: ImageCompareFunction ): """Test that uploading multiple files at once works correctly.""" chat_input = get_element_by_key(app, "multiple_files") file_name1 = "file1.txt" file_content1 = b"file1content" file_name2 = "file2.txt" file_content2 = b"file2content" files = [ FilePayload(name=file_name1, mimeType="text/plain", buffer=file_content1), FilePayload(name=file_name2, mimeType="text/plain", buffer=file_content2), ] file_upload_helper(app, chat_input, files) uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first # Wait for file names to be visible before taking snapshot expect(uploaded_files.get_by_text(file_name1)).to_be_visible() expect(uploaded_files.get_by_text(file_name2)).to_be_visible() # Dismiss any tooltips before taking snapshot (WebKit can leave upload tooltip visible) reset_hovering(app) assert_snapshot(uploaded_files, name="st_chat_input-multiple_files_uploaded") uploaded_file_names = uploaded_files.get_by_test_id("stChatInputFileName") expect(uploaded_file_names).to_have_count(2) # Delete one uploaded file uploaded_files.get_by_test_id("stChatInputDeleteBtn").first.click() wait_for_app_run(app) uploaded_file_names = uploaded_files.get_by_test_id("stChatInputFileName") expect(uploaded_file_names).to_have_count(1) expect(uploaded_file_names).to_have_text(files[1]["name"], use_inner_text=True) @use_chat_input("single_file") def test_file_upload_error_message_disallowed_files( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that shows error message for disallowed files and retry attributes.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) file_name1 = "file1.json" file1 = FilePayload( name=file_name1, mimeType="application/json", buffer=b"{}", ) file_upload_helper( themed_app, get_element_by_key(themed_app, "single_file"), [file1] ) chat_input = get_element_by_key(themed_app, "single_file") uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files.get_by_text(file_name1)).to_be_visible() # Verify file chip has retry attributes (all errors are retryable) file_chip = uploaded_files.get_by_test_id("stChatInputFile").first expect(file_chip).to_have_attribute("role", "button") expect(file_chip).to_have_attribute("tabindex", "0") expect(file_chip).to_have_attribute("title", "Click to retry upload") # Snapshot the error file chip (without tooltip) assert_snapshot(file_chip, name="st_chat_input-file_uploaded_error") # Verify tooltip is NOT visible before hovering expect(themed_app.get_by_test_id("stTooltipErrorContent")).not_to_be_visible() # Hover to show error tooltip file_chip.hover() error_tooltip = themed_app.get_by_test_id("stTooltipErrorContent").first expect(error_tooltip).to_be_visible() expect(error_tooltip).to_have_text("application/json files are not allowed.") # Snapshot the tooltip content directly (tooltips are portals rendered to <body>) assert_snapshot(error_tooltip, name="st_chat_input-file_uploaded_error_tooltip") @use_chat_input("single_file") @pytest.mark.skip_browser("chromium") def test_file_upload_error_message_file_too_large(app: Page): """Test that shows error message for files exceeding max size limit.""" app.set_viewport_size({"width": 750, "height": 2000}) file_name1 = "large.txt" file1 = FilePayload( name=file_name1, mimeType="text/plain", buffer=b"x" * (2 * 1024 * 1024), # 2MB ) chat_input = get_element_by_key(app, "single_file") expect(chat_input).to_be_visible() file_upload_helper(app, chat_input, [file1]) uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files).to_be_visible() # Verify the file appears in the uploaded files list expect(uploaded_files.get_by_text(file_name1)).to_be_visible() uploaded_file = uploaded_files.get_by_test_id("stChatInputFile").first expect(uploaded_file).to_be_visible() uploaded_files.scroll_into_view_if_needed() # Reset hovering to dismiss any upload tooltips reset_hovering(app) # Verify error message is displayed as a tooltip when hovering file_chip = uploaded_files.get_by_test_id("stChatInputFile").first file_chip.hover() error_tooltip = app.get_by_test_id("stTooltipErrorContent").first expect(error_tooltip).to_be_visible() expect(error_tooltip).to_have_text("File must be 1.0MB or smaller.") @use_chat_input("single_file") def test_single_file_upload_button_tooltip(app: Page): """Test that the single file upload button tooltip renders correctly.""" app.set_viewport_size({"width": 750, "height": 2000}) chat_input_upload_button = get_element_by_key(app, "single_file").get_by_test_id( "stChatInputFileUploadButton" ) expect(chat_input_upload_button).to_be_visible() chat_input_upload_button.scroll_into_view_if_needed() expect_help_tooltip(app, chat_input_upload_button, "Upload or drag and drop a file") # Hover on the tooltip hover target @use_chat_input("multiple_files") def test_multi_file_upload_button_tooltip(app: Page): """Test that the multi file upload button tooltip renders correctly.""" app.set_viewport_size({"width": 750, "height": 2000}) chat_input_upload_button = get_element_by_key(app, "multiple_files").get_by_test_id( "stChatInputFileUploadButton" ) expect(chat_input_upload_button).to_be_visible() chat_input_upload_button.scroll_into_view_if_needed() expect_help_tooltip(app, chat_input_upload_button, "Upload or drag and drop files") @use_chat_input("directory") def test_directory_upload_button_tooltip(app: Page): """Test that the directory upload button tooltip renders correctly.""" app.set_viewport_size({"width": 750, "height": 2000}) chat_input_upload_button = get_element_by_key(app, "directory").get_by_test_id( "stChatInputFileUploadButton" ) expect(chat_input_upload_button).to_be_visible() chat_input_upload_button.scroll_into_view_if_needed() expect_help_tooltip( app, chat_input_upload_button, "Upload or drag and drop a directory" ) @use_chat_input("directory_disabled") def test_directory_upload_disabled_state(app: Page): """Test that disabled directory upload input cannot be interacted with.""" disabled_chat_input = get_element_by_key(app, "directory_disabled") disabled_upload_button = disabled_chat_input.get_by_test_id( "stChatInputFileUploadButton" ) # Check that the upload button has disabled attribute (div elements don't use standard disabled behavior) expect(disabled_upload_button).to_have_attribute("disabled", "") # Check that the text area is also disabled disabled_text_area = disabled_chat_input.locator("textarea") expect(disabled_text_area).to_be_disabled() @use_chat_input("directory") def test_directory_upload_button_interaction(app: Page): """Test directory upload button can be clicked when enabled.""" chat_input = get_element_by_key(app, "directory") upload_button = chat_input.get_by_role("button", name="Upload a directory") expect(upload_button).to_be_visible() expect(upload_button).to_have_accessible_name("Upload a directory") expect(upload_button).to_be_enabled() # Verify that the button is focusable without interacting with file chooser. # Directory uploads require actual directory paths which we can't simulate. upload_button.focus() expect(upload_button).to_be_focused() @use_chat_input("bottom_max_chars") def test_chat_input_adjusts_for_long_placeholder( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that chat input properly adjusts its height for long placeholder text.""" themed_app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(themed_app, "bottom_max_chars") expect(chat_input).to_be_visible() # Take a snapshot of the initial state with the long placeholder assert_snapshot(chat_input, name="st_chat_input-long_placeholder") # Type some text to verify the input maintains proper height chat_input_area = chat_input.locator("textarea") expect(chat_input_area).to_be_visible() chat_input_area.type("Some input text") assert_snapshot(chat_input, name="st_chat_input-long_placeholder_with_text") # Clear the text and verify it returns to placeholder height chat_input_area.fill("") assert_snapshot(chat_input, name="st_chat_input-long_placeholder_after_clear") @use_chat_input("inline") def test_check_top_level_class(app: Page): """Check that the top level class is correctly set.""" check_top_level_class(app, "stChatInput") @use_chat_input("callback") def test_custom_css_class_via_key(app: Page): """Test that the element can have a custom css class via the key argument.""" expect(get_element_by_key(app, "callback")).to_be_visible() @use_chat_input("callback") def test_programmatically_set_value_in_session_state(app: Page): """Test that the value is correctly set in session state.""" chat_input = get_element_by_key(app, "callback") click_button(app, "Set Value") expect(chat_input.locator("textarea")).to_have_value("Hello, world!") # And the session state value should be reset to None after widget execution: expect_markdown( app, "callback - session state value before execution: Hello, world!" ) expect_markdown(app, "callback - session state value: None") # Also expect the return value to be None expect_markdown(app, "callback - return value: None") # Submit value submit_button = chat_input.get_by_test_id("stChatInputSubmitButton") expect(submit_button).to_be_visible() submit_button.click() wait_for_app_run(app) expect_markdown(app, "chat input submitted") expect_markdown( app, "callback - session state value before execution: Hello, world!" ) expect_markdown(app, "callback - session state value: Hello, world!") expect_markdown(app, "callback - return value: Hello, world!") @use_chat_input("inline") def test_height_resets_after_submit( themed_app: Page, assert_snapshot: ImageCompareFunction ): """Test that chat input height resets to compact state after submission.""" chat_input = get_element_by_key(themed_app, "inline") chat_input_area = chat_input.locator("textarea").first assert_snapshot(chat_input, name="st_chat_input-initial_compact_state") multiline_text = ( "This is line one\n" "This is line two\n" "This is line three\n" "This is line four with some longer text to ensure expansion" ) chat_input_area.fill(multiline_text) assert_snapshot(chat_input, name="st_chat_input-expanded_multiline_state") chat_input_area.press("Enter") wait_for_app_run(themed_app) expect(chat_input_area).to_have_value("") # Wait for height to visually reset to compact state (single line) # This ensures React state updates and browser paint have completed def check_compact_height() -> bool: box = chat_input_area.bounding_box() # Compact textarea should be roughly 40-50px (minElementHeight) return box["height"] < 60 if box else False wait_until(themed_app, check_compact_height) assert_snapshot(chat_input, name="st_chat_input-reset_after_submit") @use_chat_input("dynamic") def test_dynamic_chat_input_props( app: Page, assert_snapshot: ImageCompareFunction, browser_name: str ): """Test that the chat input can be updated dynamically while keeping the state.""" dynamic_chat_input = get_element_by_key(app, "dynamic_chat_input_with_key") expect(dynamic_chat_input).to_be_visible() # Initial state (placeholder is rendered as attribute, not visible text) expect(dynamic_chat_input.locator("textarea")).to_have_attribute( "placeholder", "Initial dynamic chat input" ) assert_snapshot(dynamic_chat_input, name="st_chat_input-dynamic_initial") # Type something and submit input_field = dynamic_chat_input.locator("textarea").first input_field.fill("hello") input_field.press("Enter") wait_for_app_run(app) # Ensure the markdown entry is present (prefix match) expect_prefixed_markdown(app, "Initial chat input value:", "hello") # Click the toggle to update the chat input props click_toggle(app, "Update chat input props") # New chat input is rendered with updated placeholder text expect(dynamic_chat_input.locator("textarea")).to_have_attribute( "placeholder", "Updated dynamic chat input" ) dynamic_chat_input.scroll_into_view_if_needed() # Firefox has persistent 1px height variance (40px vs 41px) that causes size mismatch errors # Skip snapshot comparison for Firefox since the visual difference is negligible if browser_name != "firefox": assert_snapshot( dynamic_chat_input, name="st_chat_input-dynamic_updated", ) else: # For Firefox, just verify the element is visible and interactive expect(dynamic_chat_input).to_be_visible() # Ensure we can still interact normally input_field = dynamic_chat_input.locator("textarea").first input_field.fill("world") input_field.press("Enter") wait_for_app_run(app) expect_prefixed_markdown(app, "Updated chat input value:", "world") @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_recording_lifecycle(app: Page): """Test complete audio recording lifecycle: record, approve, verify output.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Verify mic button is visible mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() # Record audio record_audio_in_chat_input(app, chat_input) # Verify audio was submitted successfully expect_chat_input_value_contains_audio(app, "audio_with_files") @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_recording_cancel(app: Page): """Test that canceling audio recording works correctly.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Start recording mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() mic_button.click() # Wait for cancel button to appear (indicates recording started) cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") expect(cancel_button).to_be_visible() # Record for a moment app.wait_for_timeout(500) # Cancel recording cancel_button.click() # Cancel button should disappear (recording stopped) expect(cancel_button).not_to_be_visible() # Mic button should be visible and enabled again expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_with_text_input(app: Page): """Test recording audio along with text input.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Type text textarea = chat_input.locator("textarea").first textarea.fill("Hello world") # Record audio record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Verify both text and audio were submitted successfully expect_chat_input_value_contains_text(app, "audio_with_files", "Hello world") expect_chat_input_value_contains_audio(app, "audio_with_files") @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_with_file_uploads(app: Page): """Test combining audio recording with file uploads.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Upload file first file = FilePayload(name="test.txt", mimeType="text/plain", buffer=b"test content") file_upload_helper(app, chat_input, [file]) # Record audio record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Verify audio and file were submitted successfully expect_chat_input_value_contains_audio(app, "audio_with_files") expect_chat_input_value_contains_files(app, "audio_with_files", 1) @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_only_submission(app: Page): """Test submitting only audio without text or files.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Record audio only (no text or files) record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Verify audio was submitted successfully expect_chat_input_value_contains_audio(app, "audio_with_files") @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_submit_clears_recording(app: Page): """Test that submitting clears the recording state.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Record and submit audio record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Verify st.audio component is displayed audio_elements = app.get_by_test_id("stAudio") expect(audio_elements.first).to_be_visible() # Verify mic button is back to initial state mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Verify approve/cancel buttons are not visible (not recording) approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).not_to_be_visible() cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") expect(cancel_button).not_to_be_visible() @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_error_state_handling(app: Page): """Test error state handling when audio upload fails.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Mock upload failure from playwright.sync_api import Route def handle_route(route: Route): if "upload_file" in route.request.url: route.abort("failed") else: route.continue_() app.route("**/_stcore/upload_file/**", handle_route) # Start recording mic_button = chat_input.get_by_test_id("stChatInputMicButton") mic_button.click() # Wait for approve button to appear approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).to_be_visible() # Record for a moment app.wait_for_timeout(1000) # Approve recording approve_button.click() # Wait for error to appear app.wait_for_timeout(1000) # Verify that no output appears (upload failed, so audio_with_files_value is None and nothing is printed) # With the new format, we only output when audio_with_files_value has a value expect(app.get_by_text("audio_with_files - text:", exact=False)).not_to_be_visible() @use_chat_input("audio_with_files") @pytest.mark.only_browser("chromium") def test_audio_rapid_re_recordings(app: Page): """Test that rapid re-recordings work correctly without race conditions.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Do 3 rapid recordings - each new one should replace the previous for i in range(3): mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() mic_button.click() # Wait for approve button to appear approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).to_be_visible() # Record briefly app.wait_for_timeout(500) # Approve approve_button.click() if i < 2: # Don't wait after last recording # Wait for upload to complete and component to reset before next recording wait_for_app_run(app) # Ensure mic button is ready for next recording expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Wait for the final upload to complete wait_for_app_run(app) # Verify st.audio component is displayed audio_elements = app.get_by_test_id("stAudio") expect(audio_elements.first).to_be_visible(timeout=10000) @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_input_visual_states(app: Page, assert_snapshot: ImageCompareFunction): """Test visual snapshots of all audio input states.""" grant_microphone_permissions(app) app.set_viewport_size({"width": 750, "height": 2000}) # Test 1: Idle state (already captured in test_chat_input_rendering) # Test 2: Recording state snapshot removed due to indeterministic nature # (waveform animation and timing issues cause flaky snapshots) # Test 3: Disabled state (already captured in test_chat_input_rendering as audio_disabled) # Test 4: With uploaded files + audio button visible chat_input_with_files = get_element_by_key(app, "audio_with_files") chat_input_with_files.scroll_into_view_if_needed() file = FilePayload(name="test.txt", mimeType="text/plain", buffer=b"test content") file_upload_helper(app, chat_input_with_files, [file]) # Snapshot: Audio button + uploaded files uploaded_files = chat_input_with_files.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files).to_be_visible() assert_snapshot( chat_input_with_files, name="st_chat_input-audio_with_uploaded_files" ) @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_input_combined_features( app: Page, assert_snapshot: ImageCompareFunction ): """Test visual snapshots of audio combined with other features.""" grant_microphone_permissions(app) app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(app, "audio_only") chat_input.scroll_into_view_if_needed() # Snapshot: Audio + text entered (before recording) textarea = chat_input.locator("textarea").first textarea.fill("Hello with audio") assert_snapshot(chat_input, name="st_chat_input-audio_with_text_entered") # Clear for next test textarea.fill("") # Submit audio and capture cleared state record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Snapshot: After audio submission (cleared state) expect(textarea).to_have_value("") assert_snapshot(chat_input, name="st_chat_input-audio_after_clear") @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_recording_state_transitions(app: Page): """Test exhaustive state machine transitions for audio recording.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_only") chat_input.scroll_into_view_if_needed() # Get element references textarea = chat_input.locator("textarea").first mic_button = chat_input.get_by_test_id("stChatInputMicButton") # State 1: Idle - verify initial state expect(textarea).to_be_visible() expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Verify approve/cancel buttons not visible in idle expect(chat_input.get_by_test_id("stChatInputApproveButton")).not_to_be_visible() expect(chat_input.get_by_test_id("stChatInputCancelButton")).not_to_be_visible() # Transition: idle → recording start_audio_recording(chat_input) # State 2: Recording - verify recording state elements approve_button = chat_input.get_by_test_id("stChatInputApproveButton") cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") expect(approve_button).to_be_visible() expect(cancel_button).to_be_visible() # Verify textarea becomes hidden during recording # Note: The textarea is not removed, but waveform takes over visually # Transition: recording → idle (via cancel) cancel_button.click() # Verify return to idle state expect(approve_button).not_to_be_visible() expect(cancel_button).not_to_be_visible() expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Test full cycle: idle → recording → uploading → idle mic_button.click() expect(approve_button).to_be_visible() app.wait_for_timeout(1000) # Click approve to enter uploading state approve_button.click() # During upload, approve button should show spinner (verify button still exists) # This happens very quickly, so we just verify transition to idle wait_for_app_run(app) # Verify return to idle after upload expect(approve_button).not_to_be_visible() expect(cancel_button).not_to_be_visible() expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Verify textarea is cleared after submission expect(textarea).to_have_value("") @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_keyboard_accessibility(app: Page): """Test keyboard-only interactions with audio input.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_only") chat_input.scroll_into_view_if_needed() # Tab to mic button mic_button = chat_input.get_by_test_id("stChatInputMicButton") # Focus the mic button using keyboard navigation mic_button.focus() expect(mic_button).to_be_focused() # Trigger with Space app.keyboard.press("Space") # Verify recording started approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).to_be_visible() # Try Escape to cancel (if implemented) app.keyboard.press("Escape") # Verify either cancel worked or recording continues # (This depends on implementation - we just verify state consistency) # Clean up - cancel if still recording cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") if cancel_button.is_visible(): cancel_button.click() @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_boundary_conditions(app: Page): """Test edge cases and boundary conditions for audio recording.""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_only") chat_input.scroll_into_view_if_needed() # Test 1: Very short recording (< 1 second) mic_button = chat_input.get_by_test_id("stChatInputMicButton") mic_button.click() approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).to_be_visible() # Record for very short duration app.wait_for_timeout(200) approve_button.click() wait_for_app_run(app) # Verify submission worked despite short duration expect(mic_button).to_be_visible() # Test 2: Rapid click on mic button (shouldn't allow double-start) mic_button.click() expect(approve_button).to_be_visible() # Try clicking mic button again while recording # It should be hidden or disabled during recording # so this shouldn't cause issues # Cancel to reset cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") cancel_button.click() # Test 3: Click approve immediately after starting mic_button.click() expect(approve_button).to_be_visible() # Click approve almost immediately (< 100ms of recording) approve_button.click() wait_for_app_run(app) # Verify state is clean expect(mic_button).to_be_visible() expect(mic_button).to_be_enabled() # Test 4: Multiple cancel/restart cycles for _ in range(3): mic_button.click() expect(approve_button).to_be_visible() app.wait_for_timeout(300) cancel_button.click() expect(mic_button).to_be_visible() @use_chat_input("audio_with_files") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_with_all_features_combined(app: Page): """Test audio with text and files all together (maximum complexity).""" grant_microphone_permissions(app) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Upload files first file1 = FilePayload(name="file1.txt", mimeType="text/plain", buffer=b"content1") file2 = FilePayload(name="file2.txt", mimeType="text/plain", buffer=b"content2") file_upload_helper(app, chat_input, [file1, file2]) # Verify files uploaded uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files.get_by_text("file1.txt")).to_be_visible() expect(uploaded_files.get_by_text("file2.txt")).to_be_visible() # Add text after files are uploaded textarea = chat_input.locator("textarea").first textarea.fill("Message with everything") # Record and submit audio (this submits everything together) record_audio_in_chat_input(app, chat_input, duration_ms=1000) # Verify text, audio, and files were all submitted successfully expect_chat_input_value_contains_text( app, "audio_with_files", "Message with everything" ) expect_chat_input_value_contains_audio(app, "audio_with_files") expect_chat_input_value_contains_files(app, "audio_with_files", 2) # Verify textarea is cleared after submission expect(textarea).to_have_value("") @use_chat_input("audio_column") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_audio_container_contexts(app: Page): """Test audio input in different container contexts.""" grant_microphone_permissions(app) app.set_viewport_size({"width": 750, "height": 2000}) # Audio in columns col_input = get_element_by_key(app, "audio_column_a") col_input.scroll_into_view_if_needed() expect(col_input).to_be_visible() # Verify mic button in column col_mic_button = col_input.get_by_test_id("stChatInputMicButton") expect(col_mic_button).to_be_visible() # Record audio in column to verify functionality record_audio_in_chat_input(app, col_input, duration_ms=800) # Verify it worked - check for audio output in new format expect(app.get_by_text("audio_column_a - audio:", exact=False)).to_be_visible() @use_chat_input("audio_disabled") def test_audio_disabled_states(app: Page): """Test non-interactive verification of disabled audio input.""" chat_input = get_element_by_key(app, "audio_disabled") chat_input.scroll_into_view_if_needed() # Verify mic button is present but disabled mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() # Check disabled attribute on button expect(mic_button).to_have_attribute("disabled", "") # Verify textarea is also disabled textarea = chat_input.locator("textarea") expect(textarea).to_be_disabled() # Try clicking mic button - should not respond mic_button.click(force=True) # Verify recording did not start (approve button should not appear) approve_button = chat_input.get_by_test_id("stChatInputApproveButton") expect(approve_button).not_to_be_visible() # Verify submit button is also disabled submit_button = chat_input.get_by_test_id("stChatInputSubmitButton") expect(submit_button).to_have_attribute("disabled", "") @use_chat_input("audio_with_files") @pytest.mark.only_browser("chromium") # Webkit CI audio issue, Firefox tooltip issue def test_chat_input_permission_denied_error( app_with_microphone_permission_denied: Page, assert_snapshot: ImageCompareFunction ): """Test that permission denied error is displayed in chat input.""" chat_input = get_element_by_key( app_with_microphone_permission_denied, "audio_with_files" ) chat_input.scroll_into_view_if_needed() # Try to click mic without permissions mic_button = chat_input.get_by_test_id("stChatInputMicButton") mic_button.click() # Wait for error state to apply by waiting for the tooltip hover target to appear # Firefox may take longer to trigger permission denied and update React state hover_target = chat_input.get_by_test_id("stTooltipErrorHoverTarget") expect(hover_target).to_be_visible(timeout=10000) # Hover over the tooltip hover target to show tooltip hover_target.hover() # Verify tooltip appears with error message tooltip = app_with_microphone_permission_denied.get_by_test_id( "stTooltipErrorContent" ) expect(tooltip).to_have_text( "Microphone access denied", use_inner_text=True, ) # Take snapshot of error state with tooltip assert_snapshot(chat_input, name="st_chat_input-mic_permission_denied") # Verify error clears when user types textarea = chat_input.locator("textarea").first textarea.fill("Some text") # After typing, tooltip should not appear on hover anymore expect(tooltip).not_to_be_visible() @use_chat_input("audio_with_files") @pytest.mark.only_browser("chromium") # Webkit CI audio issue, Firefox tooltip issue def test_chat_input_recording_error(app: Page, assert_snapshot: ImageCompareFunction): """Test that recording errors are displayed in chat input.""" from playwright.sync_api import Route grant_microphone_permissions(app) # Mock recording failure by intercepting audio upload def handle_route(route: Route): if "upload_file" in route.request.url: route.abort("failed") else: route.continue_() app.route("**/_stcore/upload_file/**", handle_route) chat_input = get_element_by_key(app, "audio_with_files") chat_input.scroll_into_view_if_needed() # Start recording start_audio_recording(chat_input) app.wait_for_timeout(1000) # Try to approve (will fail upload) approve_button = chat_input.get_by_test_id("stChatInputApproveButton") approve_button.click() app.wait_for_timeout(1000) # Verify mic button shows error state mic_button = chat_input.get_by_test_id("stChatInputMicButton") expect(mic_button).to_be_visible() # Hover over the tooltip hover target to show tooltip hover_target = chat_input.get_by_test_id("stTooltipErrorHoverTarget") hover_target.hover() # Verify tooltip appears with error message tooltip = app.get_by_test_id("stTooltipErrorContent") expect(tooltip).to_have_text( "Recording failed", use_inner_text=True, ) # Take snapshot assert_snapshot(chat_input, name="st_chat_input-recording_error") # Verify error clears when user starts typing textarea = chat_input.locator("textarea").first textarea.fill("Error cleared") # After typing, tooltip should not appear on hover anymore expect(tooltip).not_to_be_visible() @use_chat_input("audio_sample_rate") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue @pytest.mark.parametrize( ("option_text", "expected_hz"), [ ("16 kHz (Default)", 16000), ("48 kHz (High quality)", 48000), ("8 kHz (Low quality)", 8000), ], ) def test_audio_sample_rate_validation(app: Page, option_text: str, expected_hz: int): """Test recording audio at various sample rates and validate the output.""" grant_microphone_permissions(app) # Select the specified sample rate from dropdown select_selectbox_option(app, "Select audio sample rate", option_text) # Get the chat input for audio recording chat_input = get_element_by_key(app, "audio_sample_rate_test") chat_input.scroll_into_view_if_needed() # Record audio record_audio_in_chat_input(app, chat_input, duration_ms=2000) # Verify the validation message appears expect( app.get_by_text("Sample rate validation PASSED", exact=False) ).to_be_visible() expect(app.get_by_text(f"Expected {expected_hz} Hz", exact=False)).to_be_visible() expect(app.get_by_text(f"got {expected_hz} Hz", exact=False)).to_be_visible() def upload_single_file_and_snapshot( app: Page, chat_input: Locator, file: FilePayload, snapshot_name: str, assert_snapshot: ImageCompareFunction, ) -> None: """Helper to upload a single file and take a snapshot of just that file chip.""" file_upload_helper(app, chat_input, [file]) uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first file_chip = uploaded_files.get_by_test_id("stChatInputFile").first expect(file_chip).to_be_visible() # Verify title attribute contains full filename (for native tooltip on hover) filename_element = uploaded_files.get_by_test_id("stChatInputFileName").first expect(filename_element).to_have_attribute("title", file["name"]) reset_hovering(app) assert_snapshot(file_chip, name=snapshot_name) # Delete the file to reset for next test uploaded_files.get_by_test_id("stChatInputDeleteBtn").first.click() wait_for_app_run(app, 500) @use_chat_input("multiple_files") def test_file_chip_theming( themed_app: Page, assert_snapshot: ImageCompareFunction, ): """Test file chip theming with one representative file type (light and dark).""" # Use image file type as representative - theme styling is shared across all file types # Uses a real PNG so the image preview thumbnail renders correctly chat_input = get_element_by_key(themed_app, "multiple_files") file = FilePayload(name="photo.png", mimeType="image/png", buffer=SMILEY_PNG) upload_single_file_and_snapshot( themed_app, chat_input, file, "st_chat_input-file_chip_themed", assert_snapshot, ) # Minimal 8x8 smiley face PNG for image preview testing # This is a valid PNG that renders as a yellow smiley face SMILEY_PNG = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x08\x00\x00\x00\x08\x08\x02" b'\x00\x00\x00Km)\xdc\x00\x00\x00"IDATx\xdac\xf8\x7f\x86\x01+\x82Q\x98\x0c\x02' b"\x12\xd8\x8db@\x95\x86p\x890\n\x02\xd0]\x85\x89\x007\xa7`\xd3Z\xff>.\x00\x00" b"\x00\x00IEND\xaeB`\x82" ) # File types to test (excluding image which is tested in test_file_chip_theming) FILE_CHIP_VARIATIONS = [ ("pdf", "document.pdf", "application/pdf", b"fake pdf"), ("spreadsheet", "data.csv", "text/csv", b"a,b,c"), ("text", "readme.txt", "text/plain", b"Hello world"), ("code", "script.py", "text/x-python", b"print('hi')"), ("audio", "song.mp3", "audio/mpeg", b"fake audio"), ("video", "movie.mp4", "video/mp4", b"fake video"), ("archive", "archive.zip", "application/zip", b"fake zip"), ("unknown", "data.unknown", "application/octet-stream", b"mystery"), ( "truncated", "data_analysis_results.csv", "text/csv", b"content", ), ] @use_chat_input("multiple_files") @pytest.mark.parametrize( ("test_id", "filename", "mimetype", "content"), FILE_CHIP_VARIATIONS, ids=[case[0] for case in FILE_CHIP_VARIATIONS], ) def test_file_chip_variations( app: Page, assert_snapshot: ImageCompareFunction, test_id: str, filename: str, mimetype: str, content: bytes, ): """Test file chip rendering for various file types (icon and truncation variations).""" chat_input = get_element_by_key(app, "multiple_files") file = FilePayload(name=filename, mimeType=mimetype, buffer=content) upload_single_file_and_snapshot( app, chat_input, file, f"st_chat_input-file_chip_{test_id}", assert_snapshot, ) @use_chat_input("multiple_files") @pytest.mark.skip_browser("webkit") def test_file_upload_retry_click_success(app: Page): """Test that clicking retry on error chip successfully re-uploads the file.""" from playwright.sync_api import Route app.set_viewport_size({"width": 750, "height": 2000}) chat_input = get_element_by_key(app, "multiple_files") expect(chat_input).to_be_visible() # Track upload request count to fail first request, succeed on retry request_count = {"value": 0} def handle_route(route: Route): request_count["value"] += 1 if request_count["value"] == 1: # First request fails route.abort("failed") else: # Subsequent requests succeed route.continue_() # Set up route interception BEFORE uploading app.route("**/_stcore/upload_file/**", handle_route) file_name = "test_retry.txt" file = FilePayload(name=file_name, mimeType="text/plain", buffer=b"test content") try: file_upload_helper(app, chat_input, [file]) # Wait for error state to appear uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first file_chip = uploaded_files.get_by_test_id("stChatInputFile").first expect(file_chip).to_be_visible() # Verify file is in error state with retry attributes expect(file_chip).to_have_attribute("role", "button") expect(file_chip).to_have_attribute("title", "Click to retry upload") # Verify error message is displayed as a tooltip when hovering file_chip.hover() error_tooltip = app.get_by_test_id("stTooltipErrorContent").first expect(error_tooltip).to_be_visible() # Click to retry - this should succeed since we now allow requests through file_chip.click() # Wait for successful upload - tooltip wrapper should disappear # After successful upload, file chip is no longer wrapped in error tooltip tooltip_wrapper = app.get_by_test_id("stTooltipErrorHoverTarget").first expect(tooltip_wrapper).not_to_be_visible(timeout=5000) # Verify file is now in uploaded state (shows size instead of error) file_size = uploaded_files.get_by_test_id("stChatInputFileName").first expect(file_size).to_be_visible() finally: # Clean up route interception app.unroute("**/_stcore/upload_file/**") @use_chat_input("single_file") def test_upload_button_works_after_upload_and_delete(app: Page): """Test that the upload button still works after uploading a file and deleting it. This tests a react-dropzone bug where the file input becomes unresponsive after files are removed. The fix resets the dropzone state via React key when files are deleted. See: https://github.com/react-dropzone/react-dropzone/issues/972 """ chat_input = get_element_by_key(app, "single_file") expect(chat_input).to_be_visible() # Get the upload button upload_button = chat_input.get_by_test_id("stChatInputFileUploadButton") expect(upload_button).to_be_visible() # Step 1: Upload a file using the button # Use short filenames (max 16 chars) to avoid truncation in the UI first_file_name = "first.txt" first_file = FilePayload( name=first_file_name, mimeType="text/plain", buffer=b"first file content" ) with app.expect_file_chooser() as fc_info: upload_button.click(force=True) file_chooser = fc_info.value file_chooser.set_files(files=[first_file]) wait_for_app_run(app, 500) # Verify the file was uploaded uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files).to_be_visible() expect(uploaded_files.get_by_text(first_file_name)).to_be_visible() # Step 2: Delete the uploaded file uploaded_files.get_by_test_id("stChatInputDeleteBtn").first.click() wait_for_app_run(app, 500) # Verify the file was deleted expect(chat_input.get_by_test_id("stChatUploadedFiles")).not_to_be_visible() # Step 3: Verify the upload button still works after the upload + delete cycle # This is the key assertion - without the fix, the button would be unresponsive expect(upload_button).to_be_visible() expect(upload_button).to_be_enabled() # Upload a new file using the button second_file_name = "second.txt" second_file = FilePayload( name=second_file_name, mimeType="text/plain", buffer=b"second file content" ) with app.expect_file_chooser() as fc_info: upload_button.click(force=True) file_chooser = fc_info.value file_chooser.set_files(files=[second_file]) wait_for_app_run(app, 500) # Verify the new file was uploaded successfully uploaded_files = chat_input.get_by_test_id("stChatUploadedFiles").first expect(uploaded_files).to_be_visible() expect(uploaded_files.get_by_text(second_file_name)).to_be_visible() # Verify the first deleted file doesn't reappear (negative assertion) expect(uploaded_files.get_by_text(first_file_name)).not_to_be_visible() @use_chat_input("inline") def test_dynamic_stacked_layout_transitions( app: Page, assert_snapshot: ImageCompareFunction ): """Test that chat input dynamically transitions between inline and stacked layouts. The layout should: 1. Start in inline mode (buttons and textarea on same row) 2. Switch to stacked mode when text fills available width 3. Stay in stacked mode when text is partially deleted (still has content) 4. Return to inline mode only when all text is cleared """ app.set_viewport_size({"width": 750, "height": 400}) chat_input = get_element_by_key(app, "inline") textarea = chat_input.locator("textarea").first # 1. Type short text - should still be in inline mode short_text = "Hello, this is test" # 19 chars textarea.type(short_text) assert_snapshot(chat_input, name="st_chat_input-layout_inline_short_text") # 2. Type more text to trigger stacked mode long_text = ( " and now I'm adding a lot more text to fill up the available width " "so that the layout switches to stacked mode with buttons below" ) textarea.type(long_text) assert_snapshot(chat_input, name="st_chat_input-layout_stacked_long_text") # 3. Replace with shorter text (but not empty) - should STAY in stacked mode # Use fill() to efficiently replace text without character-by-character deletion partial_text = "Hello, this is test " # 20 chars - still has content textarea.fill(partial_text) assert_snapshot(chat_input, name="st_chat_input-layout_stacked_after_delete") # 4. Clear all text - should return to inline mode # Use select all + backspace for efficient clearing textarea.press( "Meta+a" if app.evaluate("navigator.platform").startswith("Mac") else "Control+a" ) textarea.press("Backspace") assert_snapshot(chat_input, name="st_chat_input-layout_inline_after_clear") # Negative assertion: verify that typing short text does NOT trigger stacked mode textarea.type("Brief") expect(textarea).to_have_value("Brief") # Take a snapshot to verify we're still in inline mode with brief text assert_snapshot(chat_input, name="st_chat_input-layout_inline_brief_text") @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_layout_alignment_after_audio_submission( app: Page, assert_snapshot: ImageCompareFunction ): """Test that chat input alignment is correct after audio recording and submission. Regression test: After recording and submitting audio, the textarea placeholder should be vertically aligned with the buttons (not shifted higher). """ app.set_viewport_size({"width": 750, "height": 400}) chat_input = get_element_by_key(app, "audio_only") # 1. Capture initial alignment state assert_snapshot(chat_input, name="st_chat_input-audio_initial_alignment") # 2. Record and submit audio record_audio_in_chat_input(app, chat_input) # 3. Wait for submission and app rerun wait_for_app_run(app) expect_chat_input_value_contains_audio(app, "audio_only") # 4. Capture alignment after audio submission - should match initial alignment chat_input = get_element_by_key(app, "audio_only") assert_snapshot(chat_input, name="st_chat_input-audio_alignment_after_submit") @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_stacked_layout_triggers_after_audio_submission( app: Page, assert_snapshot: ImageCompareFunction ): """Test that stacked layout mode works correctly after audio submission. Regression test: After recording and submitting audio, typing long text should still trigger the stacked layout mode (textarea above buttons). """ app.set_viewport_size({"width": 750, "height": 400}) chat_input = get_element_by_key(app, "audio_only") # 1. Record and submit audio record_audio_in_chat_input(app, chat_input) wait_for_app_run(app) expect_chat_input_value_contains_audio(app, "audio_only") # 2. Get fresh reference and type long text chat_input = get_element_by_key(app, "audio_only") textarea = chat_input.locator("textarea").first long_text = ( "This is a very long message that should trigger the stacked layout mode " "where the textarea appears above the buttons instead of inline" ) textarea.type(long_text) # 3. Verify stacked layout is triggered assert_snapshot(chat_input, name="st_chat_input-stacked_after_audio_submit") @use_chat_input("audio_only") @pytest.mark.skip_browser("webkit") # Webkit CI audio permission issue def test_layout_after_audio_cancel(app: Page, assert_snapshot: ImageCompareFunction): """Test that layout returns to correct state after canceling audio recording. After canceling a recording, the textarea should: 1. Be visible and properly aligned with buttons 2. Support stacked layout mode when typing long text """ app.set_viewport_size({"width": 750, "height": 400}) chat_input = get_element_by_key(app, "audio_only") # 1. Start recording start_audio_recording(chat_input) # 2. Cancel recording cancel_button = chat_input.get_by_test_id("stChatInputCancelButton") cancel_button.click() # 3. Verify textarea is back and aligned correctly textarea = chat_input.locator("textarea").first expect(textarea).to_be_visible() assert_snapshot(chat_input, name="st_chat_input-alignment_after_audio_cancel") # 4. Verify stacking still works after cancel long_text = ( "A very long message that should trigger stacked mode even after " "canceling an audio recording" ) textarea.type(long_text) assert_snapshot(chat_input, name="st_chat_input-stacked_after_audio_cancel")