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')
})
})
})
})