diff --git a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx index e73a493c21..6e3f335e2d 100644 --- a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx +++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx @@ -3,8 +3,6 @@ */ /** @jsx h */ import { createSearchClient } from '@instantsearch/mocks'; -import { wait } from '@instantsearch/testutils'; -import userEvent from '@testing-library/user-event'; import instantsearch from '../../../index.es'; import chat from '../chat'; @@ -29,32 +27,5 @@ describe('chat', () => { See documentation: https://www.algolia.com/doc/api-reference/widgets/chat/js/" `); }); - - test('adds custom CSS classes', async () => { - const container = document.createElement('div'); - const searchClient = createSearchClient(); - - const search = instantsearch({ indexName: 'indexName', searchClient }); - const widget = chat({ - container, - agentId: 'agentId', - cssClasses: { - root: 'ROOT', - container: 'CONTAINER', - }, - }); - - search.addWidgets([widget]); - search.start(); - await wait(0); - - userEvent.click(container.querySelector('.ais-ChatToggleButton')!); - await wait(0); - - expect(container.querySelector('.ais-Chat')).toHaveClass('ROOT'); - expect(container.querySelector('.ais-Chat-container')).toHaveClass( - 'CONTAINER' - ); - }); }); }); diff --git a/packages/react-instantsearch/src/widgets/__tests__/Chat.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/Chat.test.tsx deleted file mode 100644 index 3f8ec4a9f5..0000000000 --- a/packages/react-instantsearch/src/widgets/__tests__/Chat.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/** - * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts - */ - -import { InstantSearchTestWrapper } from '@instantsearch/testutils'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { SearchIndexToolType } from 'instantsearch.js/es/lib/chat'; -import React from 'react'; - -import { Chat } from '../Chat'; - -let mockUseChat: any; -jest.mock('react-instantsearch-core', () => { - const originalModule = jest.requireActual('react-instantsearch-core'); - return { - ...originalModule, - useChat: jest.fn(() => mockUseChat), - }; -}); - -describe('Chat', () => { - test('opens chat when toggle button is clicked', () => { - mockUseChat = { - messages: [], - sendMessage: jest.fn(), - addToolResult: jest.fn(), - }; - const { container } = render( - - - - ); - - const toggleButton = container.querySelector('.ais-ChatToggleButton'); - userEvent.click(toggleButton!); - - const root = container.querySelector('.ais-Chat'); - expect(root).toHaveClass('ROOT'); - }); - - test('closes chat when close button is clicked', () => { - mockUseChat = { - messages: [], - sendMessage: jest.fn(), - addToolResult: jest.fn(), - }; - const { container } = render( - - - - ); - - const toggleButton = container.querySelector('.ais-ChatToggleButton'); - userEvent.click(toggleButton!); - const closeButton = container.querySelector('.ais-ChatHeader-close'); - userEvent.click(closeButton!); - - const root = container.querySelector('.ais-Chat-container'); - expect(root).not.toHaveClass('ais-Chat-container--open'); - }); - - test('should send message when form is submitted', () => { - const sendMessage = jest.fn(); - mockUseChat = { - messages: [], - sendMessage, - addToolResult: jest.fn(), - }; - const { container } = render( - - - - ); - - const toggleButton = container.querySelector('.ais-ChatToggleButton'); - userEvent.click(toggleButton!); - - const textarea = container.querySelector( - '.ais-ChatPrompt-textarea' - ) as HTMLTextAreaElement; - userEvent.type(textarea, 'Hello, world!'); - - const submitBtn = container.querySelector('.ais-ChatPrompt-submit'); - userEvent.click(submitBtn!); - - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith({ text: 'Hello, world!' }); - expect(textarea.value).toBe(''); - }); - - test('should render tools and call onToolCall', () => { - mockUseChat = { - messages: [ - { id: '0', role: 'user', parts: [{ type: 'text', content: 'Hello' }] }, - { id: '1', role: 'assistant', parts: [{ type: 'tool-hello' }] }, - ], - sendMessage: jest.fn(), - }; - const onToolCall = jest.fn(); - const { container } = render( - - ( -
-
The message said hello!
-
- ), - onToolCall, - }, - }} - /> -
- ); - const toggleButton = container.querySelector('.ais-ChatToggleButton'); - userEvent.click(toggleButton!); - - const toolComponent = container.querySelector('[role="alert"]'); - expect(toolComponent).toBeInTheDocument(); - expect(toolComponent).toHaveTextContent('The message said hello!'); - }); - - test('should not use default search index tool if user provides one', () => { - mockUseChat = { - messages: [ - { - id: '0', - role: 'assistant', - parts: [{ type: `tool-${SearchIndexToolType}` }], - }, - ], - sendMessage: jest.fn(), - }; - const onToolCall = jest.fn(); - const { container } = render( - - ( -
-
Custom search index tool
-
- ), - onToolCall, - }, - }} - /> -
- ); - const toggleButton = container.querySelector('.ais-ChatToggleButton'); - userEvent.click(toggleButton!); - - const toolComponent = container.querySelector('[role="alert"]'); - expect(toolComponent).toBeInTheDocument(); - expect(toolComponent).toHaveTextContent('Custom search index tool'); - }); -}); diff --git a/tests/common/widgets/chat/index.ts b/tests/common/widgets/chat/index.ts index 06d75b4021..ff15bc33a8 100644 --- a/tests/common/widgets/chat/index.ts +++ b/tests/common/widgets/chat/index.ts @@ -1,6 +1,8 @@ import { fakeAct, skippableDescribe } from '../../common'; import { createOptionsTests } from './options'; +import { createTemplatesTests } from './templates'; +import { createTranslationsTests } from './translations'; import type { TestOptions, TestSetup } from '../../common'; import type { ChatConnectorParams } from 'instantsearch.js/es/connectors/chat/connectChat'; @@ -39,11 +41,9 @@ export function createChatWidgetTests( }); skippableDescribe('Chat widget common tests', skippedTests, () => { - createOptionsTests(setup, { - act, - skippedTests, - flavor, - }); + createOptionsTests(setup, { act, skippedTests, flavor }); + createTemplatesTests(setup, { act, skippedTests, flavor }); + createTranslationsTests(setup, { act, skippedTests, flavor }); }); } createChatWidgetTests.flavored = true; diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 98a84061f4..68fe2a0b37 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -5,6 +5,8 @@ import userEvent from '@testing-library/user-event'; import { Chat, SearchIndexToolType } from 'instantsearch.js/es/lib/chat'; import React from 'react'; +import { createDefaultWidgetParams, openChat } from './utils'; + import type { ChatWidgetSetup } from '.'; import type { TestOptions } from '../../common'; @@ -13,38 +15,50 @@ export function createOptionsTests( { act }: Required ) { describe('options', () => { - test('renders with default props', async () => { + test('renders with default options', async () => { const searchClient = createSearchClient(); - const chat = new Chat({}); - const sendMessageSpy = jest.spyOn(chat, 'sendMessage'); - - const commonWidgetParams = { - agentId: 'agentId', - chat, - }; await setup({ instantSearchOptions: { indexName: 'indexName', searchClient, }, widgetParams: { - javascript: commonWidgetParams, - react: commonWidgetParams, + javascript: createDefaultWidgetParams(), + react: createDefaultWidgetParams(), vue: {}, }, }); - await act(async () => { - await wait(0); - }); + await openChat(act); - userEvent.click(document.querySelector('.ais-ChatToggleButton')!); + expect(document.querySelector('.ais-Chat')).toBeInTheDocument(); + expect(document.querySelector('.ais-Chat-container')).toBeInTheDocument(); + expect(document.querySelector('.ais-ChatHeader')).toBeInTheDocument(); + expect(document.querySelector('.ais-ChatMessages')).toBeInTheDocument(); + expect(document.querySelector('.ais-ChatPrompt')).toBeInTheDocument(); + }); - await act(async () => { - await wait(0); + test('sends messages when prompt is submitted', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + const sendMessageSpy = jest.spyOn(chat, 'sendMessage'); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: createDefaultWidgetParams(chat), + react: createDefaultWidgetParams(chat), + vue: {}, + }, }); + await openChat(act); + userEvent.type( document.querySelector('.ais-ChatPrompt-textarea')!, 'Hello, world!' @@ -58,201 +72,262 @@ export function createOptionsTests( expect(sendMessageSpy).toHaveBeenCalledWith({ text: 'Hello, world!' }); }); - test('renders with default tools', async () => { + test('closes chat when close button is clicked', async () => { const searchClient = createSearchClient(); - const chat = new Chat({ - messages: [ - { - id: '1', - role: 'assistant', - parts: [ - { - type: `tool-${SearchIndexToolType}`, - toolCallId: '1', - input: { text: 'test' }, - state: 'output-available', - output: { hits: [{ objectID: '123' }] }, - }, - ], - }, - ], - id: 'chat-id', - }); - - const commonWidgetParams = { - agentId: 'agentId', - chat: chat as any, - }; - await setup({ instantSearchOptions: { indexName: 'indexName', searchClient, }, widgetParams: { - javascript: commonWidgetParams, - react: commonWidgetParams, + javascript: createDefaultWidgetParams(), + react: createDefaultWidgetParams(), vue: {}, }, }); - await act(async () => { - await wait(0); - }); - - userEvent.click(document.querySelector('.ais-ChatToggleButton')!); + await openChat(act); - await act(async () => { - await wait(0); - }); + userEvent.click(document.querySelector('.ais-ChatHeader-close')!); - expect(document.querySelector('.ais-Carousel')).toBeInTheDocument(); + expect(document.querySelector('.ais-Chat-container')).not.toHaveClass( + 'ais-Chat-container--open' + ); }); - test('renders with client side tools', async () => { + test('maximizes and minimizes chat when button is clicked', async () => { const searchClient = createSearchClient(); - const chat = new Chat({ - messages: [ - { - id: '1', - role: 'assistant', - parts: [ - { - type: 'tool-hello', - toolCallId: '1', - input: { text: 'hello' }, - state: 'output-available', - output: 'hello', - }, - ], - }, - ], - id: 'chat-id', - }); - - const commonWidgetParams = { - agentId: 'agentId', - chat: chat as any, - }; - await setup({ instantSearchOptions: { indexName: 'indexName', searchClient, }, widgetParams: { - javascript: { - ...commonWidgetParams, - tools: { - hello: { - templates: { - layout: - '
The message said hello!
', - }, - }, - }, - }, - react: { - ...commonWidgetParams, - tools: { - hello: { - layoutComponent: () => ( -
The message said hello!
- ), - }, - }, - }, + javascript: createDefaultWidgetParams(), + react: createDefaultWidgetParams(), vue: {}, }, }); - await act(async () => { - await wait(0); - }); + await openChat(act); - userEvent.click(document.querySelector('.ais-ChatToggleButton')!); + userEvent.click(document.querySelector('.ais-ChatHeader-maximize')!); - await act(async () => { - await wait(0); - }); + expect(document.querySelector('.ais-Chat')).toHaveClass( + 'ais-Chat--maximized' + ); + expect(document.querySelector('.ais-Chat-container')).toHaveClass( + 'ais-Chat-container--maximized' + ); + + userEvent.click(document.querySelector('.ais-ChatHeader-maximize')!); - expect(document.querySelector('#tool-content')!.textContent).toBe( - 'The message said hello!' + expect(document.querySelector('.ais-Chat')).not.toHaveClass( + 'ais-Chat--maximized' + ); + expect(document.querySelector('.ais-Chat-container')).not.toHaveClass( + 'ais-Chat-container--maximized' ); }); - test('renders with custom algolia search tool', async () => { - const searchClient = createSearchClient(); + describe('cssClasses', () => { + test('adds custom CSS classes', async () => { + const searchClient = createSearchClient(); - const chat = new Chat({ - messages: [ - { - id: '1', - role: 'assistant', - parts: [ - { - type: `tool-${SearchIndexToolType}`, - toolCallId: '1', - input: { text: 'hello' }, - state: 'output-available', - output: 'hello', + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + cssClasses: { + root: 'ROOT', + container: 'CONTAINER', }, - ], + }, + react: { + ...createDefaultWidgetParams(), + classNames: { + root: 'ROOT', + container: 'CONTAINER', + }, + }, + vue: {}, }, - ], - id: 'chat-id', + }); + + await openChat(act); + + expect(document.querySelector('.ais-Chat')).toHaveClass('ROOT'); + expect(document.querySelector('.ais-Chat-container')).toHaveClass( + 'CONTAINER' + ); }); - const commonWidgetParams = { - agentId: 'agentId', - chat: chat as any, - }; + }); - await setup({ - instantSearchOptions: { - indexName: 'indexName', - searchClient, - }, - widgetParams: { - javascript: { - ...commonWidgetParams, - tools: { - [SearchIndexToolType]: { - templates: { - layout: - '
The message said hello!
', + describe('tools', () => { + test('renders with default tools', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { text: 'test' }, + state: 'output-available', + output: { hits: [{ objectID: '123' }] }, }, - }, + ], }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: createDefaultWidgetParams(chat), + react: createDefaultWidgetParams(chat), + vue: {}, }, - react: { - ...commonWidgetParams, - tools: { - [SearchIndexToolType]: { - layoutComponent: () => ( -
The message said hello!
- ), + }); + + await openChat(act); + + expect(document.querySelector('.ais-Carousel')).toBeInTheDocument(); + }); + + test('renders with client side tools', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + templates: { + layout: + '
The message said hello!
', + }, + }, }, }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + layoutComponent: () => ( +
The message said hello!
+ ), + }, + }, + }, + vue: {}, }, - vue: {}, - }, - }); + }); - await act(async () => { - await wait(0); + await openChat(act); + + expect(document.querySelector('#tool-content')!.textContent).toBe( + 'The message said hello!' + ); }); - userEvent.click(document.querySelector('.ais-ChatToggleButton')!); + test('renders with custom algolia search tool', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + templates: { + layout: + '
The message said hello!
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + layoutComponent: () => ( +
The message said hello!
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); - await act(async () => { - await wait(0); + expect(document.querySelector('#tool-content')!.textContent).toBe( + 'The message said hello!' + ); }); - - expect(document.querySelector('#tool-content')!.textContent).toBe( - 'The message said hello!' - ); }); }); } diff --git a/tests/common/widgets/chat/templates.tsx b/tests/common/widgets/chat/templates.tsx new file mode 100644 index 0000000000..3474cfddd3 --- /dev/null +++ b/tests/common/widgets/chat/templates.tsx @@ -0,0 +1,343 @@ +import { createSearchClient } from '@instantsearch/mocks'; +import { Chat } from 'instantsearch.js/es/lib/chat'; +import React from 'react'; + +import { createDefaultWidgetParams, openChat } from './utils'; + +import type { ChatWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; + +export function createTemplatesTests( + setup: ChatWidgetSetup, + { act }: Required +) { + describe('templates', () => { + describe('header', () => { + test('renders with custom header template', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + header: { + layout: '
Custom header
', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + headerComponent: () => ( +
Custom header
+ ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-header')!.textContent).toBe( + 'Custom header' + ); + }); + + test('renders with custom sub components', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + header: { + titleIcon: + 'Custom title icon', + closeIcon: + 'Custom close icon', + minimizeIcon: + 'Custom minimize icon', + maximizeIcon: + 'Custom maximize icon', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + headerTitleIconComponent: () => ( + Custom title icon + ), + headerCloseIconComponent: () => ( + Custom close icon + ), + headerMinimizeIconComponent: () => ( + + Custom minimize icon + + ), + headerMaximizeIconComponent: () => ( + + Custom maximize icon + + ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-title-icon')!.textContent).toBe( + 'Custom title icon' + ); + expect(document.querySelector('.custom-close-icon')!.textContent).toBe( + 'Custom close icon' + ); + expect( + document.querySelector('.custom-maximize-icon')!.textContent + ).toBe('Custom maximize icon'); + }); + }); + + describe('prompt', () => { + test('renders with custom prompt template', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + prompt: { + layout: '
Custom prompt
', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + promptComponent: () => ( +
Custom prompt
+ ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-prompt')!.textContent).toBe( + 'Custom prompt' + ); + }); + + test('renders with custom sub components', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + prompt: { + header: '
Custom header
', + footer: '', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + promptHeaderComponent: () => ( +
Custom header
+ ), + promptFooterComponent: () => ( +
Custom footer
+ ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-header')!.textContent).toBe( + 'Custom header' + ); + expect(document.querySelector('.custom-footer')!.textContent).toBe( + 'Custom footer' + ); + }); + }); + + describe('messages', () => { + test('renders with custom loader', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + Object.defineProperty(chat, 'status', { + get: () => 'submitted', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + messages: { + loader: '
Custom loader
', + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + messagesLoaderComponent: () => ( +
Custom loader
+ ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-loader')!.textContent).toBe( + 'Custom loader' + ); + }); + + test('renders with custom error', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + Object.defineProperty(chat, 'status', { + get: () => 'error', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + messages: { + error: '
Custom error
', + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + messagesErrorComponent: () => ( +
Custom error
+ ), + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('.custom-error')!.textContent).toBe( + 'Custom error' + ); + }); + }); + + test('renders with custom actions', async () => { + const searchClient = createSearchClient(); + const chat = new Chat({ + messages: [ + { + id: '0', + role: 'user', + parts: [ + { + type: 'text', + text: 'hello', + }, + ], + }, + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ], + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + actions: ({ message }, { html }) => + message.role === 'assistant' + ? html`` + : html``, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + actionsComponent: ({ message }) => { + return message.role === 'assistant' ? ( + + ) : ( + + ); + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelectorAll('.custom-action')[0].textContent).toBe( + 'Custom user action' + ); + expect(document.querySelectorAll('.custom-action')[1].textContent).toBe( + 'Custom assistant action' + ); + }); + }); +} diff --git a/tests/common/widgets/chat/translations.tsx b/tests/common/widgets/chat/translations.tsx new file mode 100644 index 0000000000..c097db6d92 --- /dev/null +++ b/tests/common/widgets/chat/translations.tsx @@ -0,0 +1,335 @@ +import { createSearchClient } from '@instantsearch/mocks'; +import userEvent from '@testing-library/user-event'; +import { Chat } from 'instantsearch.js/es/lib/chat'; + +import { createDefaultWidgetParams, openChat } from './utils'; + +import type { ChatWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; + +export function createTranslationsTests( + setup: ChatWidgetSetup, + { act }: Required +) { + describe('translations', () => { + describe('header', () => { + test('renders with custom translations', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + header: { + titleText: 'Custom title', + closeLabelText: 'Custom close button label', + clearLabelText: 'Custom clear button label', + maximizeLabelText: 'Custom maximize button label', + minimizeLabelText: 'Custom minimize button label', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + translations: { + header: { + title: 'Custom title', + closeLabel: 'Custom close button label', + clearLabel: 'Custom clear button label', + maximizeLabel: 'Custom maximize button label', + minimizeLabel: 'Custom minimize button label', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document.querySelector('.ais-ChatHeader-title')!.textContent + ).toBe('Custom title'); + expect( + document + .querySelector('.ais-ChatHeader-close')! + .getAttribute('aria-label') + ).toBe('Custom close button label'); + expect( + document.querySelector('.ais-ChatHeader-clear')!.textContent + ).toBe('Custom clear button label'); + expect( + document + .querySelector('.ais-ChatHeader-maximize')! + .getAttribute('aria-label') + ).toBe('Custom maximize button label'); + + userEvent.click(document.querySelector('.ais-ChatHeader-maximize')!); + expect( + document + .querySelector('.ais-ChatHeader-maximize')! + .getAttribute('aria-label') + ).toBe('Custom minimize button label'); + }); + }); + + describe('prompt', () => { + test('renders with custom textarea translations', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + prompt: { + textareaLabelText: 'Custom message input', + textareaPlaceholderText: 'Custom placeholder', + disclaimerText: 'Custom disclaimer', + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + translations: { + prompt: { + textareaLabel: 'Custom message input', + textareaPlaceholder: 'Custom placeholder', + disclaimer: 'Custom disclaimer', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document + .querySelector('.ais-ChatPrompt-textarea') + ?.getAttribute('aria-label') + ).toBe('Custom message input'); + expect( + document + .querySelector('.ais-ChatPrompt-textarea') + ?.getAttribute('placeholder') + ).toBe('Custom placeholder'); + expect( + document.querySelector('.ais-ChatPrompt-footer')!.textContent + ).toBe('Custom disclaimer'); + }); + + test('renders with custom empty message translation', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + prompt: { + emptyMessageTooltipText: 'Custom empty message', + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + translations: { + prompt: { + emptyMessageTooltip: 'Custom empty message', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document + .querySelector('.ais-ChatPrompt-submit') + ?.getAttribute('aria-label') + ).toBe('Custom empty message'); + }); + + test('renders with custom send message translation', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(), + templates: { + prompt: { + sendMessageTooltipText: 'Custom send message', + }, + }, + }, + react: { + ...createDefaultWidgetParams(), + translations: { + prompt: { + sendMessageTooltip: 'Custom send message', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + userEvent.type( + document.querySelector('.ais-ChatPrompt-textarea')!, + 'Hello, world!' + ); + + expect( + document + .querySelector('.ais-ChatPrompt-submit') + ?.getAttribute('aria-label') + ).toBe('Custom send message'); + }); + + test('renders with custom stop response translation', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({}); + Object.defineProperty(chat, 'status', { + get: () => 'streaming', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + templates: { + prompt: { + stopResponseTooltipText: 'Custom stop response', + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + translations: { + prompt: { + stopResponseTooltip: 'Custom stop response', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document + .querySelector('.ais-ChatPrompt-submit') + ?.getAttribute('aria-label') + ).toBe('Custom stop response'); + }); + }); + + describe('messages', () => { + test('renders with custom actions translations', async () => { + const searchClient = createSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams( + new Chat({ + messages: [ + { + id: '0', + role: 'assistant', + parts: [ + { + type: 'text', + text: 'hello', + }, + ], + }, + ], + }) + ), + templates: { + messages: { + copyToClipboardLabelText: 'Custom copy action', + regenerateLabelText: 'Custom regenerate action', + }, + }, + }, + react: { + ...createDefaultWidgetParams( + new Chat({ + messages: [ + { + id: '0', + role: 'assistant', + parts: [ + { + type: 'text', + text: 'hello', + }, + ], + }, + ], + }) + ), + translations: { + messages: { + copyToClipboardLabel: 'Custom copy action', + regenerateLabel: 'Custom regenerate action', + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + const actions = document.querySelectorAll('.ais-ChatMessage-action'); + expect(actions[0].getAttribute('aria-label')).toBe( + 'Custom copy action' + ); + expect(actions[1].getAttribute('aria-label')).toBe( + 'Custom regenerate action' + ); + }); + }); + }); +} diff --git a/tests/common/widgets/chat/utils.ts b/tests/common/widgets/chat/utils.ts new file mode 100644 index 0000000000..cd41984729 --- /dev/null +++ b/tests/common/widgets/chat/utils.ts @@ -0,0 +1,22 @@ +import { wait } from '@instantsearch/testutils'; +import userEvent from '@testing-library/user-event'; +import { Chat } from 'instantsearch.js/es/lib/chat'; + +import type { Act } from '../../common'; + +export const createDefaultWidgetParams = (chat?: Chat) => ({ + agentId: 'agentId', + chat: chat ?? new Chat({}), +}); + +export async function openChat(act: Act) { + await act(async () => { + await wait(0); + }); + + userEvent.click(document.querySelector('.ais-ChatToggleButton')!); + + await act(async () => { + await wait(0); + }); +}