import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { ChatMessage } from '../chat/ChatMessage' import { ChatThread } from '../chat/ChatThread' import { ChatComposer } from '../chat/ChatComposer' import type { ChatMessageData } from '../chat/types' beforeAll(() => { // jsdom has no layout; stub scrollIntoView used by autoscroll Element.prototype.scrollIntoView = jest.fn() }) describe('ChatMessage', () => { it('renders text content and tags the role', () => { const { container } = render( ) expect(screen.getByText('Hello there')).toBeInTheDocument() expect(container.querySelector('[data-role="user"]')).toBeInTheDocument() }) it('marks a streaming assistant message as busy', () => { const { container } = render( ) const el = container.querySelector('[data-status="streaming"]') expect(el).toBeInTheDocument() expect(el).toHaveAttribute('aria-busy', 'true') }) it('renders a tool/status message with its tool name', () => { render( ) expect(screen.getByText(/generateSlide/i)).toBeInTheDocument() expect(screen.getByText('Generating slide 1…')).toBeInTheDocument() }) it('renders message attachment chips (image thumbnail + file chip)', () => { render( ) // image attachment renders an thumbnail with its filename as alt const img = screen.getByAltText('logo.png') as HTMLImageElement expect(img).toBeInTheDocument() expect(img.src).toContain('logo.png') // non-image attachment renders its filename as text expect(screen.getByText('deck.pdf')).toBeInTheDocument() }) }) describe('ChatThread', () => { const messages: ChatMessageData[] = [ { id: 'a', role: 'user', content: 'First' }, { id: 'b', role: 'assistant', content: 'Second' }, { id: 'c', role: 'assistant', content: 'Third' }, ] it('renders all messages in order', () => { render() expect(screen.getByText('First')).toBeInTheDocument() expect(screen.getByText('Second')).toBeInTheDocument() expect(screen.getByText('Third')).toBeInTheDocument() }) it('shows an empty state placeholder when there are no messages', () => { render(Start chatting} />) expect(screen.getByText('Start chatting')).toBeInTheDocument() }) it('autoscrolls to the bottom when messages change', () => { const { rerender } = render() rerender() expect(Element.prototype.scrollIntoView).toHaveBeenCalled() }) it('shows an animated working indicator when busy (startsim-ku8o)', () => { render() expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument() expect(screen.getByText('Working on your deck…')).toBeInTheDocument() }) it('hides the working indicator when not busy', () => { render() expect(screen.queryByTestId('chat-working-indicator')).not.toBeInTheDocument() }) it('shows the working indicator even when the thread is empty (busy overrides empty state)', () => { render(Start chatting} />) expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument() expect(screen.queryByText('Start chatting')).not.toBeInTheDocument() }) }) describe('ChatComposer', () => { it('submits trimmed text on Enter and clears (uncontrolled)', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: ' paste my outline ' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(onSubmit).toHaveBeenCalledWith('paste my outline') }) it('does NOT submit on Shift+Enter (newline)', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'line one' } }) fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }) expect(onSubmit).not.toHaveBeenCalled() }) it('does NOT submit on bare Enter when the content is multi-line (pasted outline stays intact)', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem\nSlide 2: Solution' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(onSubmit).not.toHaveBeenCalled() }) it('submits multi-line content on Cmd/Ctrl+Enter', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem' } }) fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true }) expect(onSubmit).toHaveBeenCalledWith('My Deck\nSlide 1: Problem') }) it('does not submit empty/whitespace', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: ' ' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(onSubmit).not.toHaveBeenCalled() }) it('submits via the send button', () => { const onSubmit = jest.fn() render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'go' } }) fireEvent.click(screen.getByRole('button', { name: /send/i })) expect(onSubmit).toHaveBeenCalledWith('go') }) it('disables submission while busy', () => { const onSubmit = jest.fn() render() const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'hi' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(onSubmit).not.toHaveBeenCalled() }) describe('attachments', () => { function makeFile(name: string, type = 'image/png') { return new File(['data'], name, { type }) } it('fires onAttachFiles when files are picked via the attach button input', () => { const onAttachFiles = jest.fn() render() // The attach button is present and labelled expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument() const input = screen.getByTestId('chat-attach-input') as HTMLInputElement fireEvent.change(input, { target: { files: [makeFile('a.png'), makeFile('b.pdf', 'application/pdf')] } }) expect(onAttachFiles).toHaveBeenCalledTimes(1) expect(onAttachFiles.mock.calls[0][0]).toHaveLength(2) expect(onAttachFiles.mock.calls[0][0][0].name).toBe('a.png') }) it('does NOT render an attach button when onAttachFiles is omitted', () => { render() expect(screen.queryByRole('button', { name: /attach/i })).not.toBeInTheDocument() }) it('renders chips for controlled attachments with a remove control', () => { const onRemoveAttachment = jest.fn() render( ) expect(screen.getByText('notes.pdf')).toBeInTheDocument() // image chip uses thumbnail with the name as alt expect(screen.getByAltText('brand.png')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /remove brand\.png/i })) expect(onRemoveAttachment).toHaveBeenCalledWith('p1') }) it('shows upload progress while a chip is uploading and an error state on failure', () => { render( ) const uploading = screen.getByTestId('chat-attachment-u1') expect(uploading).toHaveAttribute('data-status', 'uploading') expect(uploading.querySelector('[role="progressbar"]')).toBeInTheDocument() const errored = screen.getByTestId('chat-attachment-e1') expect(errored).toHaveAttribute('data-status', 'error') }) it('still submits text with attachments present (attachments are presentation-only)', () => { const onSubmit = jest.fn() render( ) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use this' } }) fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) expect(onSubmit).toHaveBeenCalledWith('use this') }) it('routes dropped files to onAttachFiles', () => { const onAttachFiles = jest.fn() const { container } = render( ) const root = container.firstChild as HTMLElement fireEvent.drop(root, { dataTransfer: { files: [makeFile('dropped.png')] } }) expect(onAttachFiles).toHaveBeenCalledTimes(1) expect(onAttachFiles.mock.calls[0][0][0].name).toBe('dropped.png') }) it('routes pasted image/file data to onAttachFiles', () => { const onAttachFiles = jest.fn() render() const textarea = screen.getByRole('textbox') const file = makeFile('pasted.png') fireEvent.paste(textarea, { clipboardData: { files: [file], items: [{ kind: 'file', type: 'image/png' }] }, }) expect(onAttachFiles).toHaveBeenCalledTimes(1) expect(onAttachFiles.mock.calls[0][0][0].name).toBe('pasted.png') }) describe('self-managed uploader mode (startsim-dd9o)', () => { beforeAll(() => { if (!('createObjectURL' in URL)) { // @ts-expect-error test shim URL.createObjectURL = jest.fn(() => 'blob:preview') } if (!('revokeObjectURL' in URL)) { // @ts-expect-error test shim URL.revokeObjectURL = jest.fn() } }) function fakeUploader() { return { uploadAttachment: jest.fn(async (_id: string, file: File) => ({ id: `srv-${file.name}`, fileUrl: `https://cdn/${file.name}`, })), deleteAttachment: jest.fn(async () => undefined), } } it('uploads picked files through the uploader and shows a ready chip — no controlled props needed', async () => { const uploader = fakeUploader() render() const input = screen.getByTestId('chat-attach-input') as HTMLInputElement fireEvent.change(input, { target: { files: [makeFile('hero.png')] } }) await waitFor(() => expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object)) ) await waitFor(() => { const chips = screen.getByTestId('chat-attachment-chips') expect(chips.querySelector('[data-status="ready"]')).toBeInTheDocument() }) }) it('shows the attach affordance even without onAttachFiles when an uploader is present', () => { render() expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument() }) it('still submits text in self-managed mode', () => { const onSubmit = jest.fn() render() fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use these' } }) fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' }) expect(onSubmit).toHaveBeenCalledWith('use these') }) }) }) })