import { renderComponent } from '@/__tests__/render'; import { fireEvent, waitFor, within } from '@testing-library/vue'; import { mockedStore } from '@/__tests__/utils'; import LogsPanel from '@/features/logs/components/LogsPanel.vue'; import { createTestingPinia, type TestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { createRouter, createWebHistory } from 'vue-router'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { computed, h, nextTick, ref } from 'vue'; import { aiAgentNode, aiChatExecutionResponse, aiChatWorkflow, aiManualExecutionResponse, aiManualWorkflow, chatTriggerNode, nodeTypes, } from '../__test__/data'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { IN_PROGRESS_EXECUTION_ID } from '@/constants'; import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useNDVStore } from '@/stores/ndv.store'; import { deepCopy } from 'n8n-workflow'; import { createTestTaskData } from '@/__tests__/mocks'; import { useLogsStore } from '@/stores/logs.store'; import { useUIStore } from '@/stores/ui.store'; import { LOGS_PANEL_STATE } from '../logs.constants'; import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants'; import { userEvent } from '@testing-library/user-event'; import type { ChatMessage } from '@n8n/chat/types'; import * as useChatMessaging from '@/features/logs/composables/useChatMessaging'; import { chatEventBus } from '@n8n/chat/event-buses'; import { useToast } from '@/composables/useToast'; vi.mock('@/composables/useToast', () => { const showMessage = vi.fn(); const showError = vi.fn(); return { useToast: () => { return { showMessage, showError, clearAllStickyNotifications: vi.fn(), }; }, }; }); vi.mock('@/stores/pushConnection.store', () => ({ usePushConnectionStore: vi.fn().mockReturnValue({ isConnected: true, }), })); describe('LogsPanel', () => { const VIEWPORT_HEIGHT = 800; let pinia: TestingPinia; let workflowsStore: ReturnType>; let nodeTypeStore: ReturnType>; let logsStore: ReturnType>; let ndvStore: ReturnType>; let uiStore: ReturnType>; function render() { return renderComponent(LogsPanel, { global: { provide: { [ChatSymbol as symbol]: {}, [ChatOptionsSymbol as symbol]: {}, }, plugins: [ createRouter({ history: createWebHistory(), routes: [{ path: '/', component: () => h('div') }], }), pinia, ], }, }); } beforeEach(() => { pinia = createTestingPinia({ stubActions: false, fakeApp: true }); setActivePinia(pinia); workflowsStore = mockedStore(useWorkflowsStore); workflowsStore.setWorkflowExecutionData(null); logsStore = mockedStore(useLogsStore); logsStore.toggleOpen(false); nodeTypeStore = mockedStore(useNodeTypesStore); nodeTypeStore.setNodeTypes(nodeTypes); ndvStore = mockedStore(useNDVStore); uiStore = mockedStore(useUIStore); Object.defineProperty(document.body, 'offsetHeight', { configurable: true, get() { return VIEWPORT_HEIGHT; }, }); vi.spyOn(document.body, 'getBoundingClientRect').mockReturnValue({ y: 0, height: VIEWPORT_HEIGHT, } as DOMRect); localStorage.clear(); }); afterEach(() => { vi.clearAllMocks(); }); it('should render collapsed panel by default', async () => { const rendered = render(); expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument(); }); it('should only render logs panel if the workflow has no chat trigger', async () => { workflowsStore.setWorkflow(aiManualWorkflow); const rendered = render(); expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument(); expect(rendered.queryByTestId('chat-header')).not.toBeInTheDocument(); }); it('should render chat panel and logs panel if the workflow has chat trigger', async () => { workflowsStore.setWorkflow(aiChatWorkflow); const rendered = render(); expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument(); expect(await rendered.findByTestId('chat-header')).toBeInTheDocument(); }); it('should render only output panel of selected node by default', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiManualWorkflow); workflowsStore.setWorkflowExecutionData(aiManualExecutionResponse); const rendered = render(); expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Agent'); expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); }); it('should render both input and output panel of selected node by default if it is sub node', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); expect(rendered.queryByTestId('log-details-header')).toHaveTextContent('AI Model'); expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); }); it('opens collapsed panel when clicked', async () => { workflowsStore.setWorkflow(aiChatWorkflow); const rendered = render(); await fireEvent.click(await rendered.findByTestId('logs-overview-header')); expect(await rendered.findByTestId('logs-overview-empty')).toBeInTheDocument(); }); it('should toggle panel when chevron icon button in the overview panel is clicked', async () => { workflowsStore.setWorkflow(aiChatWorkflow); const rendered = render(); const overviewPanel = await rendered.findByTestId('logs-overview-header'); await fireEvent.click(within(overviewPanel).getByLabelText('Open panel')); expect(rendered.getByTestId('logs-overview-empty')).toBeInTheDocument(); await fireEvent.click(within(overviewPanel).getByLabelText('Collapse panel')); expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument(); }); it('should open log details panel when a log entry is clicked in the logs overview panel', async () => { workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); await fireEvent.click(await rendered.findByTestId('logs-overview-header')); await fireEvent.click(await rendered.findByText('AI Agent')); expect(rendered.getByTestId('log-details')).toBeInTheDocument(); // Click again to close the panel await fireEvent.click( await within(rendered.getByTestId('logs-overview-body')).findByText('AI Agent'), ); expect(rendered.queryByTestId('log-details')).not.toBeInTheDocument(); }); it("should show the button to toggle panel in the header of log details panel when it's opened", async () => { workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); await fireEvent.click(await rendered.findByTestId('logs-overview-header')); await fireEvent.click(await rendered.findByText('AI Agent')); // Click the toggle button to close the panel await fireEvent.click( within(rendered.getByTestId('log-details')).getByLabelText('Collapse panel'), ); expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument(); expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument(); // Click again to open the panel await fireEvent.click( within(rendered.getByTestId('logs-overview')).getByLabelText('Open panel'), ); expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument(); expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument(); }); it('should open itself by pulling up the resizer', async () => { logsStore.toggleOpen(false); const rendered = render(); expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED); expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument(); await fireEvent.mouseDown(rendered.getByTestId('resize-handle')); window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 })); window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 })); await waitFor(() => { expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED); expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument(); }); }); it('should close itself by pulling down the resizer', async () => { logsStore.toggleOpen(true); const rendered = render(); expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED); expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument(); await fireEvent.mouseDown(rendered.getByTestId('resize-handle')); window.dispatchEvent( new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }), ); window.dispatchEvent( new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }), ); await waitFor(() => { expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED); expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument(); }); }); it('should reflect changes to execution data in workflow store if execution is in progress', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, id: IN_PROGRESS_EXECUTION_ID, status: 'running', finished: false, startedAt: new Date('2025-04-20T12:34:50.000Z'), stoppedAt: undefined, data: { resultData: { runData: { Chat: [createTestTaskData()] } }, }, }); const rendered = render(); await fireEvent.click(rendered.getByText('Overview')); expect(rendered.getByText(/Running/)).toBeInTheDocument(); expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument(); workflowsStore.addNodeExecutionStartedData({ nodeName: 'AI Agent', executionId: '567', data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [] }, }); const lastTreeItem = await waitFor(() => { const items = rendered.getAllByRole('treeitem'); expect(items).toHaveLength(2); return within(items[1]); }); expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument(); expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument(); workflowsStore.updateNodeExecutionData({ nodeName: 'AI Agent', executionId: '567', data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [], executionTime: 33, executionStatus: 'success', }, }); expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument(); expect(await lastTreeItem.findByText('Success')).toBeInTheDocument(); expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument(); workflowsStore.setWorkflowExecutionData({ ...workflowsStore.workflowExecutionData!, id: '1234', status: 'success', finished: true, startedAt: new Date('2025-04-20T12:34:50.000Z'), stoppedAt: new Date('2025-04-20T12:34:56.000Z'), }); expect(await rendered.findByText('Success in 6s')).toBeInTheDocument(); expect(rendered.queryByText('AI Agent')).toBeInTheDocument(); }); it('should still show logs for a removed node', async () => { const operations = useCanvasOperations(); logsStore.toggleOpen(true); workflowsStore.setWorkflow(deepCopy(aiChatWorkflow)); workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, id: '2345', status: 'success', finished: true, startedAt: new Date('2025-04-20T12:34:50.000Z'), stoppedAt: new Date('2025-04-20T12:34:56.000Z'), }); const rendered = render(); expect(await rendered.findByText('AI Agent')).toBeInTheDocument(); operations.deleteNode(aiAgentNode.id); await nextTick(); expect(workflowsStore.nodesByName['AI Agent']).toBeUndefined(); expect(rendered.queryByText('AI Agent')).toBeInTheDocument(); }); it('should open NDV if the button is clicked', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0]; expect(ndvStore.activeNodeName).toBe(null); expect(ndvStore.output.run).toBe(undefined); await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]); await waitFor(() => { expect(ndvStore.activeNodeName).toBe('AI Agent'); expect(ndvStore.output.run).toBe(0); }); }); it('should toggle subtree when chevron icon button is pressed', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); const overview = within(rendered.getByTestId('logs-overview')); await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2)); expect(overview.queryByText('AI Agent')).toBeInTheDocument(); expect(overview.queryByText('AI Model')).toBeInTheDocument(); // Close subtree of AI Agent await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]); await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(1)); expect(overview.queryByText('AI Agent')).toBeInTheDocument(); expect(overview.queryByText('AI Model')).not.toBeInTheDocument(); // Re-open subtree of AI Agent await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]); await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2)); expect(overview.queryByText('AI Agent')).toBeInTheDocument(); expect(overview.queryByText('AI Model')).toBeInTheDocument(); }); it('should toggle input and output panel when the button is clicked', async () => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); const rendered = render(); const header = within(rendered.getByTestId('log-details-header')); expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); await fireEvent.click(header.getByText('Input')); expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument(); await fireEvent.click(header.getByText('Output')); expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument(); expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument(); }); describe('selection', () => { beforeEach(() => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse); }); it('should allow to select previous and next row via keyboard shortcut', async () => { const { getByTestId, findByRole } = render(); const overview = getByTestId('logs-overview'); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); await fireEvent.keyDown(overview, { key: 'K' }); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/); await fireEvent.keyDown(overview, { key: 'J' }); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); }); it('should not select a log for the selected node on canvas if sync is disabled', async () => { logsStore.toggleLogSelectionSync(false); const { findByRole, rerender } = render(); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); uiStore.lastSelectedNode = 'AI Agent'; await rerender({}); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); }); it('should automatically select a log for the selected node on canvas if sync is enabled', async () => { logsStore.toggleLogSelectionSync(true); const { rerender, findByRole } = render(); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); uiStore.lastSelectedNode = 'AI Agent'; await rerender({}); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/); }); it('should automatically expand and select a log for the selected node on canvas if the log entry is collapsed', async () => { logsStore.toggleLogSelectionSync(true); const { rerender, findByRole, getByLabelText, findByText, queryByText } = render(); await fireEvent.click(await findByText('AI Agent')); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/); await fireEvent.click(getByLabelText('Toggle row')); await rerender({}); expect(queryByText(/AI Model/)).not.toBeInTheDocument(); uiStore.lastSelectedNode = 'AI Model'; await rerender({}); expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/); }); }); describe('chat', () => { beforeEach(() => { logsStore.toggleOpen(true); workflowsStore.setWorkflow(aiChatWorkflow); }); describe('rendering', () => { it('should render chat when panel is open', () => { const { getByTestId } = render(); expect(getByTestId('canvas-chat-body')).toBeInTheDocument(); }); it('should not render chat when panel is closed', async () => { const { queryByTestId } = render(); logsStore.toggleOpen(false); await waitFor(() => expect(queryByTestId('canvas-chat-body')).not.toBeInTheDocument()); }); it('should show correct input placeholder', async () => { const { findByTestId } = render(); expect(await findByTestId('chat-input')).toBeInTheDocument(); }); }); describe('message handling', () => { beforeEach(() => { vi.spyOn(chatEventBus, 'emit'); workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' }); }); it('should send message and show response', async () => { const { findByTestId, findByText, getByText } = render(); // Send message const input = await findByTestId('chat-input'); await userEvent.type(input, 'Hello AI!'); await userEvent.keyboard('{Enter}'); // Verify message and response expect(await findByText('Hello AI!')).toBeInTheDocument(); workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, status: 'success' }); await waitFor(() => expect(getByText('AI response message')).toBeInTheDocument()); // Verify workflow execution expect(workflowsStore.runWorkflow).toHaveBeenCalledWith( expect.objectContaining({ runData: undefined, triggerToStartFrom: { name: 'Chat', data: { data: { main: [ [ { json: { action: 'sendMessage', chatInput: 'Hello AI!', sessionId: expect.any(String), }, }, ], ], }, executionIndex: 0, executionStatus: 'success', executionTime: 0, source: [null], startTime: expect.any(Number), }, }, }), ); }); it('should show loading state during message processing', async () => { const { findByTestId, queryByTestId } = render(); // Send message const input = await findByTestId('chat-input'); await userEvent.type(input, 'Test message'); await userEvent.keyboard('{Enter}'); await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument()); workflowsStore.setActiveExecutionId(undefined); workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, status: 'success' }); await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument()); }); it('should handle workflow execution errors', async () => { workflowsStore.runWorkflow.mockRejectedValueOnce(new Error()); const { findByTestId } = render(); const input = await findByTestId('chat-input'); await userEvent.type(input, 'Hello AI!'); await userEvent.keyboard('{Enter}'); const toast = useToast(); expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow'); }); }); describe('session management', () => { const mockMessages: ChatMessage[] = [ { id: '1', text: 'Existing message', sender: 'user', }, ]; beforeEach(() => { vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => { messages.value.push(...mockMessages); return { sendMessage: vi.fn(), previousMessageIndex: ref(0), isLoading: computed(() => false), }; }); }); it('should allow copying session ID', async () => { const clipboardSpy = vi.fn(); document.execCommand = clipboardSpy; const { getByTestId } = render(); await userEvent.click(getByTestId('chat-session-id')); const toast = useToast(); expect(clipboardSpy).toHaveBeenCalledWith('copy'); expect(toast.showMessage).toHaveBeenCalledWith({ message: '', title: 'Copied to clipboard', type: 'success', }); }); it('should refresh session when messages exist', async () => { const { getByTestId } = render(); const originalSessionId = getByTestId('chat-session-id').textContent; await userEvent.click(getByTestId('refresh-session-button')); expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId); }); }); describe('file handling', () => { beforeEach(() => { vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({ sendMessage: vi.fn(), previousMessageIndex: ref(0), isLoading: computed(() => false), }); logsStore.state = LOGS_PANEL_STATE.ATTACHED; workflowsStore.allowFileUploads = true; }); it('should enable file uploads when allowed by chat trigger node', async () => { workflowsStore.setNodes(aiChatWorkflow.nodes); workflowsStore.setNodeParameters({ name: chatTriggerNode.name, value: { options: { allowFileUploads: true } }, }); const { getByTestId, queryByTestId } = render(); expect(getByTestId('canvas-chat')).toBeInTheDocument(); expect(getByTestId('chat-attach-file-button')).toBeInTheDocument(); workflowsStore.setNodeParameters({ name: chatTriggerNode.name, value: { options: { allowFileUploads: false } }, }); await waitFor(() => expect(queryByTestId('chat-attach-file-button')).not.toBeInTheDocument(), ); }); }); describe('message history handling', () => { it('should properly navigate through message history with wrap-around', async () => { workflowsStore.resetChatMessages(); workflowsStore.appendChatMessage('Message 1'); workflowsStore.appendChatMessage('Message 2'); workflowsStore.appendChatMessage('Message 3'); const { findByTestId } = render(); const input = await findByTestId('chat-input'); chatEventBus.emit('focusInput'); // First up should show most recent message await userEvent.keyboard('{ArrowUp}'); expect(input).toHaveValue('Message 3'); // Second up should show second most recent await userEvent.keyboard('{ArrowUp}'); expect(input).toHaveValue('Message 2'); // Third up should show oldest message await userEvent.keyboard('{ArrowUp}'); expect(input).toHaveValue('Message 1'); // Fourth up should wrap around to most recent await userEvent.keyboard('{ArrowUp}'); expect(input).toHaveValue('Message 3'); // Down arrow should go in reverse await userEvent.keyboard('{ArrowDown}'); expect(input).toHaveValue('Message 1'); }); it('should reset message history navigation on new input', async () => { workflowsStore.resetChatMessages(); workflowsStore.appendChatMessage('Message 1'); workflowsStore.appendChatMessage('Message 2'); const { findByTestId } = render(); const input = await findByTestId('chat-input'); chatEventBus.emit('focusInput'); // Navigate to oldest message await userEvent.keyboard('{ArrowUp}'); // Most recent await userEvent.keyboard('{ArrowUp}'); // Oldest expect(input).toHaveValue('Message 1'); await userEvent.type(input, 'New message'); await userEvent.keyboard('{Enter}'); await userEvent.keyboard('{ArrowUp}'); expect(input).toHaveValue('Message 2'); }); }); describe('message reuse and repost', () => { const sendMessageSpy = vi.fn(); beforeEach(() => { const mockMessages: ChatMessage[] = [ { id: '1', text: 'Original message', sender: 'user', }, { id: '2', text: 'AI response', sender: 'bot', }, ]; vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => { messages.value.push(...mockMessages); return { sendMessage: sendMessageSpy, previousMessageIndex: ref(0), isLoading: computed(() => false), }; }); }); it('should repost user message with new execution', async () => { const { findByTestId } = render(); const repostButton = await findByTestId('repost-message-button'); await userEvent.click(repostButton); expect(sendMessageSpy).toHaveBeenCalledWith('Original message'); }); it('should show message options only for appropriate messages', async () => { const { findByText, container } = render(); await findByText('Original message'); const userMessage = container.querySelector('.chat-message-from-user'); expect( userMessage?.querySelector('[data-test-id="repost-message-button"]'), ).toBeInTheDocument(); expect( userMessage?.querySelector('[data-test-id="reuse-message-button"]'), ).toBeInTheDocument(); await findByText('AI response'); const botMessage = container.querySelector('.chat-message-from-bot'); expect( botMessage?.querySelector('[data-test-id="repost-message-button"]'), ).not.toBeInTheDocument(); expect( botMessage?.querySelector('[data-test-id="reuse-message-button"]'), ).not.toBeInTheDocument(); }); }); describe('keyboard shortcuts', () => { it('should handle Enter key with modifier to start new line', async () => { const { findByTestId } = render(); const input = await findByTestId('chat-input'); await userEvent.type(input, 'Line 1'); await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); await userEvent.type(input, 'Line 2'); expect(input).toHaveValue('Line 1\nLine 2'); }); }); }); });