/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/ChatInput/ChatInput.test.tsx
859 строк
28 KB
Nico Bellante
[fix] Shorten `st.chat_input` file chip display to adhere to designs (#13555)
12 янв 2026, 19:44
Не верифицирован
12 янв 2026, 19:44
ad83318
Код
Авторство
О чём код?
/** * 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. */ import { act, screen, waitFor } from "@testing-library/react" import { userEvent } from "@testing-library/user-event" import { ChatInput as ChatInputProto, FileURLs as FileURLsProto, IChatInputValue, } from "@streamlit/protobuf" import type { RecordingState, WaveformController, } from "~lib/components/audio/core/types" import * as UseResizeObserver from "~lib/hooks/useResizeObserver" import { createDirectoryFiles, createFileWithPath, render, } from "~lib/test_util" import { WidgetStateManager } from "~lib/WidgetStateManager" import ChatInput, { Props } from "./ChatInput" const getProps = ( elementProps: Partial<ChatInputProto> = {}, widgetProps: Partial<Props> = {} ): Props => ({ element: ChatInputProto.create({ id: "123", placeholder: "Enter Text Here", disabled: false, default: "", acceptFile: ChatInputProto.AcceptFile.NONE, ...elementProps, }), width: 300, disabled: elementProps.disabled ?? false, widgetMgr: new WidgetStateManager({ sendRerunBackMsg: vi.fn(), formsDataChanged: vi.fn(), }), // @ts-expect-error uploadClient: { uploadFile: vi.fn().mockImplementation(() => { return Promise.resolve() }), fetchFileURLs: vi.fn().mockImplementation((acceptedFiles: File[]) => { return Promise.resolve( acceptedFiles.map(file => { return new FileURLsProto({ fileId: file.name, uploadUrl: file.name, deleteUrl: file.name, }) }) ) }), deleteFile: vi.fn().mockResolvedValue(undefined), }, ...widgetProps, }) const mockChatInputValue = (text: string): IChatInputValue => { return { data: text, fileUploaderState: { uploadedFileInfo: [], }, } } describe("ChatInput widget", () => { afterEach(() => { vi.restoreAllMocks() }) beforeEach(() => { vi.spyOn(UseResizeObserver, "useResizeObserver").mockReturnValue({ elementRef: { current: null }, values: [250], }) }) it("renders without crashing", () => { const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toBeInTheDocument() }) it("shows a placeholder", () => { const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toHaveAttribute("placeholder", props.element.placeholder) }) it("sets the aria label to the placeholder", () => { const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toHaveAttribute("aria-label", props.element.placeholder) }) it("sets the value initially to the element default", () => { const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toHaveTextContent(props.element.default) }) it("sets the value when values are typed in", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "Sample text") expect(chatInput).toHaveTextContent("Sample text") }) it("does not increase text value when maxChars is set", async () => { const user = userEvent.setup() const props = getProps({ maxChars: 10 }) render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890") expect(chatInput).toHaveTextContent("1234567890") await user.type(chatInput, "1") expect(chatInput).toHaveTextContent("1234567890") }) it("sends and resets the value on enter", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890{enter}") expect(spy).toHaveBeenCalledWith( props.element, mockChatInputValue("1234567890"), { fromUi: true, }, undefined ) expect(chatInput).toHaveTextContent("") }) it("ensures chat input has focus on submit by keyboard", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890{enter}") expect(chatInput).toHaveFocus() }) it("ensures chat input has focus on submit by button click", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") const chatButton = screen.getByTestId("stChatInputSubmitButton") await user.type(chatInput, "1234567890") await user.click(chatButton) expect(chatInput).toHaveFocus() }) it("can set fragmentId when sending value", async () => { const user = userEvent.setup() const props = getProps(undefined, { fragmentId: "myFragmentId" }) const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890{enter}") expect(spy).toHaveBeenCalledWith( props.element, mockChatInputValue("1234567890"), { fromUi: true, }, "myFragmentId" ) }) it("will not send an empty value on enter if empty", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "{enter}") expect(spy).not.toHaveBeenCalledWith(props.element, "", { fromUi: true, }) expect(chatInput).toHaveTextContent("") }) it("will not show instructions when the text has changed", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") const instructions = screen.queryByTestId("InputInstructions") expect(instructions).not.toBeInTheDocument() await user.type(chatInput, "1234567890") expect(screen.queryByTestId("InputInstructions")).not.toBeInTheDocument() }) it("does not send/clear on shift + enter", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890") expect(chatInput).toHaveTextContent("1234567890") await user.type(chatInput, "{shift>}{enter}{/shift}") expect(chatInput).not.toHaveTextContent("") expect(spy).not.toHaveBeenCalled() }) it("does not send/clear on ctrl + enter", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890") expect(chatInput).toHaveTextContent("1234567890") await user.keyboard("{Control>}{Enter}{/Control}") // We cannot test the value to be changed cause that is essentially a // change event. expect(screen.getByTestId("stChatInputTextArea")).not.toHaveTextContent("") expect(spy).not.toHaveBeenCalled() }) it("does not send/clear on meta + enter", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "1234567890") expect(chatInput).toHaveTextContent("1234567890") await user.type(chatInput, "{meta>}{enter}{/meta}") expect(chatInput).not.toHaveTextContent("") expect(spy).not.toHaveBeenCalled() }) it("does sets the value if specified from protobuf to set it", () => { const props = getProps({ value: "12345", setValue: true }) render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toHaveTextContent("12345") }) it("does not set the value if protobuf does not specify to set it", () => { const props = getProps({ value: "12345", setValue: false }) render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toHaveTextContent("") }) it("disables the textarea and button", () => { const props = getProps({ disabled: true, acceptFile: ChatInputProto.AcceptFile.SINGLE, }) render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toBeDisabled() screen.getAllByRole("button").forEach(button => { expect(button).toBeDisabled() }) }) it("not disable the textarea by default", () => { const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).not.toBeDisabled() const button = screen.getByRole("button") expect(button).toBeDisabled() }) it("disables the send button by default since there's no text", () => { const props = getProps() render(<ChatInput {...props} />) const button = screen.getByRole("button") expect(button).toBeDisabled() }) it("enables the send button when text is set, disables it when removed", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "Sample text") const button = screen.getByRole("button") expect(button).not.toBeDisabled() await user.clear(chatInput) expect(button).toBeDisabled() }) describe("dirty state behavior", () => { it("disables submit button when there are no files and no text", () => { const props = getProps() render(<ChatInput {...props} />) const button = screen.getByTestId("stChatInputSubmitButton") expect(button).toBeDisabled() }) it("enables submit button when there is text", async () => { const user = userEvent.setup() const props = getProps() render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "Hello") const button = screen.getByTestId("stChatInputSubmitButton") expect(button).not.toBeDisabled() }) it("disables submit button when files are uploading", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.SINGLE, maxUploadSizeMb: 1, }) // Mock the uploadClient to simulate an uploading file props.uploadClient.uploadFile = vi.fn().mockImplementation(() => { return new Promise(() => {}) // Never resolves to simulate ongoing upload }) render(<ChatInput {...props} />) // Add text to make the button enabled const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "Text with uploading file") // Verify button is enabled before file upload const submitButton = screen.getByTestId("stChatInputSubmitButton") expect(submitButton).not.toBeDisabled() // Simulate file upload const file = new File(["file content"], "test.txt", { type: "text/plain", }) const fileUploadButton = screen.getByTestId( "stChatInputFileUploadButton" ) // The `input` element isn't accessible, so we need to access it directly via the DOM const fileUploadInput = fileUploadButton.querySelector("input") if (!fileUploadInput) { throw new Error("File upload input not found") } await user.upload(fileUploadInput, file) // Button should be disabled during upload - no need to wait for upload to finish // since we're specifically testing the in-between state expect(submitButton).toBeDisabled() // Verify the upload was attempted expect(props.uploadClient.uploadFile).toHaveBeenCalled() }) it("does not submit when dirty is false", async () => { const user = userEvent.setup() const props = getProps() const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) const chatInput = screen.getByTestId("stChatInputTextArea") const button = screen.getByTestId("stChatInputSubmitButton") // Button should be disabled initially expect(button).toBeDisabled() // Try to submit by clicking the button await user.click(button) expect(spy).not.toHaveBeenCalled() // Try to submit by pressing Enter await user.type(chatInput, "{enter}") expect(spy).not.toHaveBeenCalled() }) }) it("displays directory upload button when directory upload is enabled", () => { const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, fileType: ["txt", "py", "md"], }) render(<ChatInput {...props} />) // Check that file upload button is visible for directory uploads const uploadButton = screen.getByTestId("stChatInputFileUploadButton") expect(uploadButton).toBeVisible() // Verify aria labels and accessibility const chatInput = screen.getByTestId("stChatInputTextArea") expect(chatInput).toBeInTheDocument() }) it("handles directory upload with multiple files", async () => { const user = userEvent.setup() const mockSetChatInputValue = vi.fn() const mockWidgetMgr = new WidgetStateManager({ sendRerunBackMsg: vi.fn(), formsDataChanged: vi.fn(), }) mockWidgetMgr.setChatInputValue = mockSetChatInputValue const props = getProps( { acceptFile: ChatInputProto.AcceptFile.DIRECTORY, fileType: ["txt", "py", "md"], }, { widgetMgr: mockWidgetMgr, } ) render(<ChatInput {...props} />) // Since we can't easily simulate file upload in unit tests, // let's verify the component is set up correctly for directory uploads const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileInput = fileUploadButton.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute("webkitdirectory") expect(fileInput).toHaveAttribute("multiple") // Verify the component is configured to accept directory uploads expect(props.element.acceptFile).toBe(ChatInputProto.AcceptFile.DIRECTORY) // Verify we can still type messages const textarea = screen.getByTestId("stChatInputTextArea") await user.type(textarea, "Test message") // Submit the chat input const submitButton = screen.getByTestId("stChatInputSubmitButton") await user.click(submitButton) await waitFor(() => { expect(mockSetChatInputValue).toHaveBeenCalledWith( props.element, expect.objectContaining({ data: "Test message", fileUploaderState: expect.any(Object), }), { fromUi: true }, undefined ) }) // Verify input is cleared after submission expect(textarea).toHaveTextContent("") }) it("filters directory files by allowed types", () => { const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, fileType: ["txt"], // Only allow .txt files }) render(<ChatInput {...props} />) // Verify the component is set up correctly for directory uploads with file type filtering const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileInput = fileUploadButton.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute("webkitdirectory") expect(fileInput).toHaveAttribute("multiple") // Verify the component is configured with file type restrictions expect(props.element.fileType).toEqual(["txt"]) }) it("handles empty directory upload", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, }) render(<ChatInput {...props} />) // Verify the component is set up correctly for directory uploads const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileInput = fileUploadButton.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute("webkitdirectory") expect(fileInput).toHaveAttribute("multiple") // Should still be able to type and submit message const textarea = screen.getByTestId("stChatInputTextArea") await user.type(textarea, "No files to share") const submitButton = screen.getByTestId("stChatInputSubmitButton") expect(submitButton).toBeEnabled() }) it("displays directory upload instructions correctly", () => { const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, placeholder: "Upload a directory", }) render(<ChatInput {...props} />) // Check for directory-specific UI elements const uploadButton = screen.getByTestId("stChatInputFileUploadButton") expect(uploadButton).toBeInTheDocument() // Verify file input has directory attributes for directory upload const fileInput = uploadButton.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute("webkitdirectory") expect(fileInput).toHaveAttribute("multiple") // Verify placeholder text is displayed const textarea = screen.getByTestId("stChatInputTextArea") expect(textarea).toHaveAttribute("placeholder", "Upload a directory") // The tooltip hover target should be present for directory upload const tooltipTarget = uploadButton.querySelector(".stTooltipHoverTarget") expect(tooltipTarget).toBeInTheDocument() }) it("removes directory files when deleted individually", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, maxUploadSizeMb: 50, }) render(<ChatInput {...props} />) // Create test files with directory structure const directoryFiles = createDirectoryFiles([ { content: "content1", path: "folder/file1.txt" }, { content: "content2", path: "folder/file2.txt" }, ]) // Upload the files const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileInput = fileUploadButton.querySelector( "input" ) as HTMLInputElement await user.upload(fileInput, directoryFiles) // Wait for files to be displayed (order-agnostic check) await waitFor(() => { const fileNames = screen.getAllByTestId("stChatInputFileName") expect(fileNames).toHaveLength(2) // Check that both files are present using title attribute (full filename) const fileTitles = Array.from(fileNames).map(el => el.getAttribute("title") ) expect(fileTitles).toContain("folder/file1.txt") expect(fileTitles).toContain("folder/file2.txt") }) // Find and delete file1 const deleteButtons = screen.getAllByTestId("stChatInputDeleteBtn") expect(deleteButtons).toHaveLength(2) // Find which delete button corresponds to file1 using title attribute const fileNames = screen.getAllByTestId("stChatInputFileName") const file1Index = Array.from(fileNames).findIndex( el => el.getAttribute("title") === "folder/file1.txt" ) // Click the actual button inside the delete button wrapper for file1 const file1DeleteButton = deleteButtons[file1Index].querySelector("button") expect(file1DeleteButton).toBeTruthy() await user.click(file1DeleteButton as HTMLButtonElement) // Verify only file2 remains (check title for full filename) await waitFor(() => { const remainingFileNames = screen.getAllByTestId("stChatInputFileName") expect(remainingFileNames).toHaveLength(1) expect(remainingFileNames[0]).toHaveAttribute( "title", "folder/file2.txt" ) }) // Delete the remaining file const remainingDeleteButtons = screen.getAllByTestId( "stChatInputDeleteBtn" ) const remainingDeleteButton = remainingDeleteButtons[0].querySelector("button") expect(remainingDeleteButton).toBeTruthy() await user.click(remainingDeleteButton as HTMLButtonElement) // Verify all files are removed await waitFor(() => { const fileNames = screen.queryAllByTestId("stChatInputFileName") expect(fileNames).toHaveLength(0) }) }) it("handles directory upload with multiple files and preserves paths", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, maxUploadSizeMb: 50, }) const spy = vi.spyOn(props.widgetMgr, "setChatInputValue") render(<ChatInput {...props} />) // Add some text const chatInput = screen.getByTestId("stChatInputTextArea") await user.type(chatInput, "Here are the project files") // Mock directory files with relative paths const directoryFiles = createDirectoryFiles([ { content: "main content", path: "project/main.py" }, { content: "test content", path: "project/tests/test.py" }, ]) const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileUploadInput = fileUploadButton.querySelector( "input" ) as HTMLInputElement // Simulate file selection await user.upload(fileUploadInput, directoryFiles) // Wait for file processing await waitFor(() => { expect(props.uploadClient.uploadFile).toHaveBeenCalledTimes(2) }) // Submit the chat const submitButton = screen.getByTestId("stChatInputSubmitButton") await user.click(submitButton) // Verify the widget value was set with text and files expect(spy).toHaveBeenCalled() const callArgs = spy.mock.calls[0] const chatInputValue = callArgs[1] // Check the text content expect(chatInputValue.data).toBe("Here are the project files") // Check that files were uploaded expect(chatInputValue.fileUploaderState?.uploadedFileInfo).toHaveLength(2) expect(chatInputValue.fileUploaderState?.uploadedFileInfo?.[0].name).toBe( "project/main.py" ) expect(chatInputValue.fileUploaderState?.uploadedFileInfo?.[1].name).toBe( "project/tests/test.py" ) }) it("shows directory structure preservation in file names", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, maxUploadSizeMb: 50, }) render(<ChatInput {...props} />) // Files with webkitRelativePath to simulate directory structure const directoryFile = createFileWithPath( "content", "readme.md", "docs/readme.md" ) const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileUploadInput = fileUploadButton.querySelector( "input" ) as HTMLInputElement await user.upload(fileUploadInput, directoryFile) // Wait for file to be displayed await waitFor(() => { const fileName = screen.getByTestId("stChatInputFileName") expect(fileName).toHaveTextContent("docs/readme.md") }) }) it("enables submit button when directory files are uploaded", async () => { const user = userEvent.setup() const props = getProps({ acceptFile: ChatInputProto.AcceptFile.DIRECTORY, maxUploadSizeMb: 50, }) render(<ChatInput {...props} />) // Initially submit button should be disabled (no text, no files) const submitButton = screen.getByTestId("stChatInputSubmitButton") expect(submitButton).toBeDisabled() // Upload a file const file = new File(["content"], "file.txt", { type: "text/plain" }) const fileUploadButton = screen.getByTestId("stChatInputFileUploadButton") const fileUploadInput = fileUploadButton.querySelector( "input" ) as HTMLInputElement await user.upload(fileUploadInput, file) // Wait for upload to complete await waitFor(() => { expect(submitButton).not.toBeDisabled() }) }) it("shows spinner and disables buttons during audio upload", async () => { // Mock uploadFile to return a controllable promise let resolveUpload: (() => void) | undefined const uploadPromise = new Promise<void>(resolve => { resolveUpload = resolve }) const props = getProps({ acceptAudio: true, }) // Override uploadFile to use our controllable promise props.uploadClient.uploadFile = vi.fn().mockReturnValue(uploadPromise) // Mock the waveform controller with proper typing const mockController: WaveformController = { state: "idle" as RecordingState, isPlaybackPlaying: false, mountRef: { current: null }, playback: { isPlaying: vi.fn().mockReturnValue(false), play: vi.fn().mockResolvedValue(undefined), pause: vi.fn().mockReturnValue(undefined), load: vi.fn().mockResolvedValue(undefined), getDurationMs: vi.fn().mockReturnValue(5000), getCurrentTimeMs: vi.fn().mockReturnValue(0), }, start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue({ blob: new Blob(["audio data"], { type: "audio/wav" }), meta: { durationMs: 5000, sampleRate: 44100, mimeType: "audio/wav", size: 1024, }, }), approve: vi.fn().mockResolvedValue(undefined), cancel: vi.fn().mockReturnValue(undefined), destroy: vi.fn().mockReturnValue(undefined), setEventHandlers: vi.fn().mockReturnValue(undefined), } // Mock useWaveformController const useWaveformController = await import("~lib/components/audio") vi.spyOn(useWaveformController, "useWaveformController").mockReturnValue( mockController ) render(<ChatInput {...props} />) // Get the approve button (it won't be visible initially since we're not recording) // We need to trigger the recording flow and get the approve callback // Instead, let's directly test by triggering the onApprove event from the mock // Find the calls to useWaveformController and get the onApprove callback const mockCalls = ( useWaveformController.useWaveformController as ReturnType<typeof vi.fn> ).mock.calls const lastCallArgs = mockCalls[mockCalls.length - 1] const { events } = lastCallArgs[0] const onApprove = events?.onApprove // Create a mock audio blob const audioBlob = new Blob(["audio data"], { type: "audio/wav" }) // Trigger the approve event (this starts the upload) let approvePromise: Promise<void> | undefined await act(async () => { approvePromise = onApprove?.(audioBlob) // Wait a tick to let state updates propagate await Promise.resolve() }) // Check that buttons are disabled during upload await waitFor(() => { const submitButton = screen.getByTestId("stChatInputSubmitButton") expect(submitButton).toBeDisabled() }) // Resolve the upload await act(async () => { resolveUpload?.() if (approvePromise) { await approvePromise } }) // After upload completes, verify upload was called await waitFor(() => { expect(props.uploadClient.uploadFile).toHaveBeenCalled() }) }) })