/
GreyCat
/
openhuman
Обзор
Документация
Войти
/
GreyCat
/
openhuman
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
app/src/components/intelligence/AddMemorySourceDialog.test.tsx
462 строки
19 KB
Steven Enamakel's Droid
feat(memory): add Conversation as a memory source kind (#3441)
06 июн 2026, 22:55
Не верифицирован
06 июн 2026, 22:55
6cf53c0
Код
Авторство
О чём код?
/** * Tests for the AddMemorySourceDialog — focused on the Composio connection * picker: deduplication, readable labels, and no raw connection IDs in the * rendered dropdown (issue #3356). */ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { listConnections } from '../../lib/composio/composioApi'; import { getSupportedToolkits } from '../../services/memorySourcesService'; import { renderWithProviders } from '../../test/test-utils'; import { AddMemorySourceDialog, deduplicateConnections } from './AddMemorySourceDialog'; // --------------------------------------------------------------------------- // Module mocks // --------------------------------------------------------------------------- vi.mock('../../lib/composio/composioApi', () => ({ listConnections: vi.fn() })); vi.mock('../../services/memorySourcesService', () => ({ addMemorySource: vi.fn(), getSupportedToolkits: vi.fn(), SOURCE_KIND_ICONS: { folder: '📁', composio: '🔗', conversation: '💬', github_repo: '🐙', rss_feed: '📡', web_page: '🌐', twitter_query: '🐦', }, SOURCE_KIND_LABEL_KEYS: { folder: 'memorySources.kind.folder', composio: 'memorySources.kind.composio', conversation: 'memorySources.kind.conversation', github_repo: 'memorySources.kind.github_repo', rss_feed: 'memorySources.kind.rss_feed', web_page: 'memorySources.kind.web_page', twitter_query: 'memorySources.kind.twitter_query', }, })); const mockListConnections = listConnections as ReturnType<typeof vi.fn>; const mockGetSupportedToolkits = getSupportedToolkits as ReturnType<typeof vi.fn>; /** Every toolkit used across the picker component tests is syncable by default, * so existing assertions keep passing. Tests that exercise the disabled / * "Coming soon" path override this with a narrower set. */ const DEFAULT_SUPPORTED = ['gmail', 'slack', 'notion', 'github', 'linear', 'clickup']; // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- function renderDialog() { const onClose = vi.fn(); const onAdded = vi.fn(); renderWithProviders(<AddMemorySourceDialog open onClose={onClose} onAdded={onAdded} />); return { onClose, onAdded }; } async function openComposioStep() { renderDialog(); // The i18n context renders the real English string from en.ts const integrationBtn = screen.getByText('Integration'); fireEvent.click(integrationBtn); // Wait for async connection fetch await waitFor(() => expect(mockListConnections).toHaveBeenCalledTimes(1)); } /** Open the custom connection dropdown so its option rows render. */ async function openListbox() { const trigger = await screen.findByTestId('composio-connection-picker'); fireEvent.click(trigger); await screen.findByTestId('composio-connection-listbox'); } // --------------------------------------------------------------------------- // Unit tests: deduplicateConnections helper // --------------------------------------------------------------------------- describe('deduplicateConnections', () => { it('returns an empty array for empty input', () => { expect(deduplicateConnections([])).toEqual([]); }); it('uses accountEmail as the identity label', () => { const conn = { id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'user@example.com', }; const result = deduplicateConnections([conn]); expect(result).toHaveLength(1); expect(result[0].label).toBe('Gmail · user@example.com'); expect(result[0].conn.id).toBe('conn-1'); }); it('falls back to workspace when accountEmail is absent', () => { const conn = { id: 'conn-2', toolkit: 'Slack', status: 'ACTIVE', workspace: 'my-workspace' }; const result = deduplicateConnections([conn]); expect(result[0].label).toBe('Slack · my-workspace'); }); it('falls back to username when neither email nor workspace is present', () => { const conn = { id: 'conn-3', toolkit: 'GitHub', status: 'ACTIVE', username: 'octocat' }; const result = deduplicateConnections([conn]); expect(result[0].label).toBe('GitHub · octocat'); }); it('uses connection ID as label when no identity field is available', () => { const conn = { id: 'conn-x', toolkit: 'Notion', status: 'ACTIVE' }; const result = deduplicateConnections([conn]); expect(result[0].label).toBe('Notion · conn-x'); }); it('shows each connection ID for multiple no-identity connections', () => { const conns = [ { id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' }, { id: 'conn-b', toolkit: 'Notion', status: 'ACTIVE' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(2); expect(result[0].label).toBe('Notion · conn-a'); expect(result[1].label).toBe('Notion · conn-b'); }); it('deduplicates connections with the same toolkit and identity', () => { const conns = [ { id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' }, { id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(1); expect(result[0].conn.id).toBe('conn-1'); expect(result[0].label).toBe('Gmail · a@example.com'); }); it('keeps connections with the same toolkit but different identities', () => { const conns = [ { id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@example.com' }, { id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'b@example.com' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(2); }); it('uses the connection ID in the label when no identity is available', () => { const conns = [ { id: 'raw-uuid-abc123', toolkit: 'Linear', status: 'ACTIVE' }, { id: 'raw-uuid-def456', toolkit: 'Linear', status: 'ACTIVE' }, ]; const result = deduplicateConnections(conns); expect(result[0].label).toBe('Linear · raw-uuid-abc123'); expect(result[1].label).toBe('Linear · raw-uuid-def456'); }); it('shows connection IDs for no-identity connections across toolkits', () => { const conns = [ { id: 'n-1', toolkit: 'Notion', status: 'ACTIVE' }, { id: 's-1', toolkit: 'Slack', status: 'ACTIVE' }, { id: 'n-2', toolkit: 'Notion', status: 'ACTIVE' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(3); expect(result.find(r => r.conn.id === 'n-1')?.label).toBe('Notion · n-1'); expect(result.find(r => r.conn.id === 'n-2')?.label).toBe('Notion · n-2'); expect(result.find(r => r.conn.id === 's-1')?.label).toBe('Slack · s-1'); }); it('prefers ACTIVE over EXPIRED when deduplicating same toolkit+identity', () => { // Backend returns EXPIRED first — the ACTIVE one should win const conns = [ { id: 'conn-expired', toolkit: 'Gmail', status: 'EXPIRED', accountEmail: 'x@example.com' }, { id: 'conn-active', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(1); expect(result[0].conn.id).toBe('conn-active'); }); it('deduplicates identity-less connections with the same conn.id', () => { // Same connection returned twice with no identity — must not produce duplicate React keys const conns = [ { id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' }, { id: 'conn-same', toolkit: 'Notion', status: 'ACTIVE' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(1); expect(result[0].conn.id).toBe('conn-same'); }); it('sorts CONNECTED equal to ACTIVE above PENDING and EXPIRED', () => { const conns = [ { id: 'exp', toolkit: 'Linear', status: 'EXPIRED', accountEmail: 'a@b.com' }, { id: 'pending', toolkit: 'Linear', status: 'PENDING', accountEmail: 'a@b.com' }, { id: 'connected', toolkit: 'Linear', status: 'CONNECTED', accountEmail: 'a@b.com' }, ]; const result = deduplicateConnections(conns); expect(result).toHaveLength(1); // CONNECTED ranks same as ACTIVE — must win over EXPIRED and PENDING expect(result[0].conn.id).toBe('connected'); }); }); // --------------------------------------------------------------------------- // Component tests: Conversation kind // --------------------------------------------------------------------------- describe('AddMemorySourceDialog — Conversation kind', () => { beforeEach(() => { mockListConnections.mockReset(); mockGetSupportedToolkits.mockReset(); mockGetSupportedToolkits.mockResolvedValue(DEFAULT_SUPPORTED); }); it('shows no extra fields and submits with just a label', async () => { const { addMemorySource } = await import('../../services/memorySourcesService'); const mockAdd = addMemorySource as ReturnType<typeof vi.fn>; mockAdd.mockResolvedValue({ id: 'src_conv', kind: 'conversation', label: 'Chats', enabled: true, }); const { onAdded } = renderDialog(); const conversationBtn = screen.getByText('Conversation'); fireEvent.click(conversationBtn); const labelInput = screen.getByPlaceholderText('My research notes'); fireEvent.change(labelInput, { target: { value: 'Chats' } }); const submitBtn = screen.getByRole('button', { name: 'Add' }); fireEvent.click(submitBtn); await waitFor(() => { expect(mockAdd).toHaveBeenCalledWith( expect.objectContaining({ kind: 'conversation', label: 'Chats', enabled: true }) ); }); await waitFor(() => expect(onAdded).toHaveBeenCalled()); }); }); // --------------------------------------------------------------------------- // Component tests: ComposioPicker inside the dialog // --------------------------------------------------------------------------- describe('AddMemorySourceDialog — Composio picker', () => { beforeEach(() => { mockListConnections.mockReset(); mockGetSupportedToolkits.mockReset(); mockGetSupportedToolkits.mockResolvedValue(DEFAULT_SUPPORTED); }); it('shows loading state while fetching connections', async () => { // Never resolves during this test mockListConnections.mockReturnValue(new Promise(() => {})); renderDialog(); fireEvent.click(screen.getByText('Integration')); await waitFor(() => expect(screen.queryByText('Loading connections…')).toBeTruthy()); }); it('shows no-connections message when list is empty', async () => { mockListConnections.mockResolvedValue({ connections: [] }); await openComposioStep(); await waitFor(() => expect( screen.queryByText('No active Composio connections found. Connect an integration first.') ).toBeTruthy() ); }); it('renders readable labels — toolkit · identity — not raw IDs', async () => { mockListConnections.mockResolvedValue({ connections: [ { id: 'raw-id-xyz', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'user@gmail.com' }, ], }); await openComposioStep(); await openListbox(); await waitFor(() => expect(screen.queryByText('Gmail · user@gmail.com')).toBeTruthy()); expect(screen.queryByText('raw-id-xyz')).toBeNull(); }); it('deduplicates same toolkit+identity connections in the dropdown', async () => { mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-1', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' }, { id: 'conn-2', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'x@example.com' }, ], }); await openComposioStep(); await openListbox(); await waitFor(() => { const options = screen.getAllByRole('option'); const gmailOptions = options.filter(o => o.textContent?.includes('Gmail · x@example.com')); expect(gmailOptions).toHaveLength(1); }); }); it('shows connection IDs for connections without identity fields', async () => { mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-a', toolkit: 'Notion', status: 'ACTIVE' }, { id: 'conn-b', toolkit: 'Notion', status: 'ACTIVE' }, ], }); await openComposioStep(); await openListbox(); await waitFor(() => { expect(screen.queryByText('Notion · conn-a')).toBeTruthy(); expect(screen.queryByText('Notion · conn-b')).toBeTruthy(); }); }); it('auto-fills the source label when a connection is selected', async () => { mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'my-workspace' }, ], }); await openComposioStep(); await openListbox(); const option = await screen.findByTestId('composio-option-conn-1'); fireEvent.click(option); // The label field should be auto-filled, and the dropdown collapses to // show the chosen connection on the trigger. await waitFor(() => { const labelInput = screen.getByPlaceholderText('My research notes'); expect((labelInput as HTMLInputElement).value).toBe('Slack · my-workspace'); }); }); it('disables unsupported toolkits with a "Coming soon" tag and keeps them unselectable', async () => { // Slack is syncable; Google Calendar is not in the supported set. mockGetSupportedToolkits.mockResolvedValue(['slack']); mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-slack', toolkit: 'slack', status: 'ACTIVE', workspace: 'acme' }, { id: 'conn-gcal', toolkit: 'googlecalendar', status: 'ACTIVE', accountEmail: 'a@x.com' }, ], }); await openComposioStep(); await openListbox(); const supported = await screen.findByTestId('composio-option-conn-slack'); const unsupported = await screen.findByTestId('composio-option-conn-gcal'); expect(supported.getAttribute('aria-disabled')).toBe('false'); expect(unsupported.getAttribute('aria-disabled')).toBe('true'); expect(screen.getByTestId('composio-option-coming-soon-conn-gcal')).toBeInTheDocument(); // No "Coming soon" chip on the supported row. expect(screen.queryByTestId('composio-option-coming-soon-conn-slack')).toBeNull(); // Clicking the unsupported row must NOT select it (label stays empty). fireEvent.click(unsupported); const labelInput = screen.getByPlaceholderText('My research notes'); expect((labelInput as HTMLInputElement).value).toBe(''); // Clicking the supported row selects it. fireEvent.click(supported); await waitFor(() => expect((labelInput as HTMLInputElement).value).toBe('slack · acme')); }); it('treats every connection as supported when the supported-toolkit RPC fails', async () => { // Fallback path: getSupportedToolkits rejects → null set → nothing disabled. mockGetSupportedToolkits.mockRejectedValue(new Error('rpc down')); mockListConnections.mockResolvedValue({ connections: [{ id: 'conn-x', toolkit: 'sentry', status: 'ACTIVE', accountEmail: 'a@x.com' }], }); await openComposioStep(); await openListbox(); const option = await screen.findByTestId('composio-option-conn-x'); expect(option.getAttribute('aria-disabled')).toBe('false'); expect(screen.queryByTestId('composio-option-coming-soon-conn-x')).toBeNull(); }); it('closes the dropdown when Escape is pressed', async () => { mockListConnections.mockResolvedValue({ connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], }); await openComposioStep(); await openListbox(); expect(screen.queryByTestId('composio-connection-listbox')).toBeInTheDocument(); fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => expect(screen.queryByTestId('composio-connection-listbox')).toBeNull()); }); it('closes the dropdown on an outside click', async () => { mockListConnections.mockResolvedValue({ connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], }); await openComposioStep(); await openListbox(); expect(screen.queryByTestId('composio-connection-listbox')).toBeInTheDocument(); // A mousedown outside the picker container collapses the listbox. fireEvent.mouseDown(document.body); await waitFor(() => expect(screen.queryByTestId('composio-connection-listbox')).toBeNull()); }); it('opens the listbox with ArrowDown on the trigger button', async () => { mockListConnections.mockResolvedValue({ connections: [{ id: 'conn-1', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }], }); await openComposioStep(); const trigger = await screen.findByTestId('composio-connection-picker'); fireEvent.keyDown(trigger, { key: 'ArrowDown' }); await screen.findByTestId('composio-connection-listbox'); }); it('navigates options with the arrow keys and selects with Enter', async () => { mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-gmail', toolkit: 'Gmail', status: 'ACTIVE', accountEmail: 'a@x.com' }, { id: 'conn-slack', toolkit: 'Slack', status: 'ACTIVE', workspace: 'acme' }, ], }); await openComposioStep(); await openListbox(); const listbox = screen.getByTestId('composio-connection-listbox'); // Opens highlighting the first selectable option; ArrowDown moves to the next. expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-gmail'); fireEvent.keyDown(listbox, { key: 'ArrowDown' }); expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); // Enter selects the highlighted option and closes the dropdown. fireEvent.keyDown(listbox, { key: 'Enter' }); await waitFor(() => { const labelInput = screen.getByPlaceholderText('My research notes'); expect((labelInput as HTMLInputElement).value).toBe('Slack · acme'); }); expect(screen.queryByTestId('composio-connection-listbox')).toBeNull(); }); it('skips unsupported options during keyboard navigation', async () => { mockGetSupportedToolkits.mockResolvedValue(['slack']); mockListConnections.mockResolvedValue({ connections: [ { id: 'conn-slack', toolkit: 'slack', status: 'ACTIVE', workspace: 'acme' }, { id: 'conn-gcal', toolkit: 'googlecalendar', status: 'ACTIVE', accountEmail: 'a@x.com' }, ], }); await openComposioStep(); await openListbox(); const listbox = screen.getByTestId('composio-connection-listbox'); // Only the supported option is reachable; wrapping keeps it on slack. expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); fireEvent.keyDown(listbox, { key: 'ArrowDown' }); expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); fireEvent.keyDown(listbox, { key: 'ArrowUp' }); expect(listbox).toHaveAttribute('aria-activedescendant', 'composio-opt-conn-slack'); }); });