import { describe, expect, it } from 'vitest'; import { isTrustedConnectorCallbackOrigin, sortConnectorsForDisplay, sortConnectorsForSearch, } from '../../src/components/EntryView'; import { clearConnectorAuthorizationErrorsForConnected, clearConnectorAuthorizationPending, getConnectorDisplayToolCount, mergeConnectorActionResult, mergeConnectorToolPreview, pruneConnectorAuthorizationPending, updateConnectorAuthorizationPendingFromConnectResponse, updateConnectorAuthorizationPendingFromStatuses, } from '../../src/components/ConnectorsBrowser'; describe('connector OAuth callback origin', () => { it('accepts the app origin', () => { expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60809', 'http://127.0.0.1:60809')).toBe(true); }); it('accepts loopback daemon origins on a different port', () => { expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60807', 'http://127.0.0.1:60809')).toBe(true); expect(isTrustedConnectorCallbackOrigin('http://localhost:60807', 'http://127.0.0.1:60809')).toBe(true); }); it('rejects non-loopback origins', () => { expect(isTrustedConnectorCallbackOrigin('https://example.com', 'http://127.0.0.1:60809')).toBe(false); expect(isTrustedConnectorCallbackOrigin('file://callback', 'http://127.0.0.1:60809')).toBe(false); }); }); describe('connector display sorting', () => { it('preserves discovered tools when a fast action response only changes status', () => { const merged = mergeConnectorActionResult( { id: 'twitter', name: 'Twitter', provider: 'Composio', category: 'Social', description: 'Read Twitter/X timelines, tweets, users, and searches.', status: 'connected', accountLabel: 'ca_twitter', toolCount: 72, tools: [ { title: 'Search posts', name: 'twitter.search_posts', safety: { sideEffect: 'read', approval: 'auto', reason: 'Read-only search.' }, refreshEligible: true, }, ], }, { id: 'twitter', name: 'Twitter', provider: 'Composio', category: 'Social', description: 'Read Twitter/X timelines, tweets, users, and searches.', status: 'available', tools: [], }, ); expect(merged).toMatchObject({ id: 'twitter', status: 'available', toolCount: 72, tools: [expect.objectContaining({ name: 'twitter.search_posts' })], }); }); it('uses display tool counts without treating them as hydrated tools', () => { const connector = { id: 'airtable', name: 'Airtable', provider: 'Composio', category: 'Database', status: 'available' as const, toolCount: 25, tools: [], }; expect(getConnectorDisplayToolCount(connector)).toBe(25); expect(connector.tools).toEqual([]); }); it('prefers advertised tool counts over curated preview tool names', () => { const connector = { id: 'github', name: 'GitHub', provider: 'Composio', category: 'Developer', status: 'connected' as const, toolCount: 846, tools: [ { title: 'Search repositories', name: 'github.github_search_repositories', safety: { sideEffect: 'read' as const, approval: 'auto' as const, reason: 'Read-only search.' }, refreshEligible: true, }, { title: 'Get issue', name: 'github.github_get_issue', safety: { sideEffect: 'read' as const, approval: 'auto' as const, reason: 'Read-only get.' }, refreshEligible: true, }, ], curatedToolNames: ['github.github_search_repositories', 'github.github_get_issue'], }; expect(getConnectorDisplayToolCount(connector)).toBe(846); }); it('appends paginated preview tools without duplicating rows', () => { const current = { id: 'canvas', name: 'Canvas', provider: 'Composio', category: 'Education', status: 'available' as const, toolCount: 574, toolsNextCursor: 'cursor_2', toolsHasMore: true, tools: [ { title: 'List courses', name: 'canvas.list_courses', safety: { sideEffect: 'read' as const, approval: 'auto' as const, reason: 'Read-only list.' }, refreshEligible: true, }, ], }; const next = { ...current, toolsHasMore: false, tools: [ current.tools[0]!, { title: 'Get course', name: 'canvas.get_course', safety: { sideEffect: 'read' as const, approval: 'auto' as const, reason: 'Read-only get.' }, refreshEligible: true, }, ], }; const merged = mergeConnectorToolPreview(current, next, true); expect(merged.tools.map((tool) => tool.name)).toEqual(['canvas.list_courses', 'canvas.get_course']); expect(merged.toolsHasMore).toBe(false); }); it('places connected connectors first and sorts the rest alphabetically', () => { const sorted = sortConnectorsForDisplay([ { id: 'zapi', name: 'Zapier', provider: 'Composio', category: 'Automation', status: 'available', tools: [] }, { id: 'gmail', name: 'Gmail', provider: 'Composio', category: 'Email', status: 'connected', tools: [] }, { id: 'airtable', name: 'Airtable', provider: 'Composio', category: 'Data', status: 'available', tools: [] }, { id: 'github', name: 'GitHub', provider: 'Composio', category: 'Code', status: 'connected', tools: [] }, { id: 'calendar', name: 'Calendar', provider: 'Composio', category: 'Calendar', status: 'available', tools: [] }, ]); expect(sorted.map((connector) => connector.id)).toEqual([ 'github', 'gmail', 'airtable', 'calendar', 'zapi', ]); }); it('ranks exact and prefix name/provider matches above description matches', () => { const sorted = sortConnectorsForSearch([ { id: 'linear', name: 'Linear', provider: 'Composio', category: 'Project management', status: 'connected', description: 'Sync issues from GitHub repositories.', tools: [], allowedToolNames: [], curatedToolNames: [], }, { id: 'github-enterprise', name: 'GitHub Enterprise', provider: 'Composio', category: 'Code', status: 'available', tools: [], allowedToolNames: [], curatedToolNames: [], }, { id: 'github', name: 'GitHub', provider: 'Composio', category: 'Code', status: 'available', tools: [], allowedToolNames: [], curatedToolNames: [], }, { id: 'slack', name: 'Slack', provider: 'Composio', category: 'Communication', status: 'connected', tools: [ { title: 'Post GitHub release', name: 'post_github_release', safety: { sideEffect: 'write', approval: 'confirm', reason: 'Posts a message.' }, refreshEligible: false, }, ], allowedToolNames: [], curatedToolNames: [], }, ], 'github'); expect(sorted.map((connector) => connector.id)).toEqual([ 'github', 'github-enterprise', 'slack', 'linear', ]); }); }); describe('connector authorization pending state', () => { const future = '2026-05-08T10:10:00.000Z'; const nowMs = Date.parse('2026-05-08T10:00:00.000Z'); it('marks redirect_required connect responses as pending', () => { const pending = updateConnectorAuthorizationPendingFromConnectResponse({}, { connector: { id: 'exist', name: 'Exist', provider: 'Composio', category: 'Personal', status: 'available', tools: [], allowedToolNames: [], curatedToolNames: [], }, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: future, }, }, nowMs); expect(pending).toEqual({ exist: { expiresAt: future, redirectUrl: 'https://example.com/oauth', }, }); }); it('keeps pending state while status polling still reports available', () => { const pending = updateConnectorAuthorizationPendingFromStatuses( { exist: { expiresAt: future } }, { exist: { status: 'available' } }, nowMs, ); expect(pending).toEqual({ exist: { expiresAt: future } }); }); it('clears pending state when status polling reports connected', () => { const pending = updateConnectorAuthorizationPendingFromStatuses( { exist: { expiresAt: future } }, { exist: { status: 'connected', accountLabel: 'me@example.com' } }, nowMs, ); expect(pending).toEqual({}); }); it('expires pending state after the auth response expiry time', () => { const pending = pruneConnectorAuthorizationPending( { exist: { expiresAt: '2026-05-08T09:59:59.000Z' } }, nowMs, ); expect(pending).toEqual({}); }); it('clears pending state for immediately connected responses', () => { const pending = updateConnectorAuthorizationPendingFromConnectResponse({ exist: { expiresAt: future } }, { connector: { id: 'exist', name: 'Exist', provider: 'Composio', category: 'Personal', status: 'connected', tools: [], allowedToolNames: [], curatedToolNames: [], }, auth: { kind: 'connected' }, }, nowMs); expect(pending).toEqual({}); }); it('clears stored auth errors for connectors observed as connected', () => { const errors = clearConnectorAuthorizationErrorsForConnected( { exist: 'Composio provider is not configured', airtable: 'Connection failed' }, { exist: { status: 'connected' }, airtable: { status: 'available' } }, ); expect(errors).toEqual({ airtable: 'Connection failed' }); }); it('returns the same errors object when no connector transitions to connected', () => { const original = { exist: 'Composio provider is not configured' }; const errors = clearConnectorAuthorizationErrorsForConnected( original, { exist: { status: 'available' } }, ); expect(errors).toBe(original); }); it('cancels pending authorization without changing other pending connectors', () => { const pending = clearConnectorAuthorizationPending( { exist: { expiresAt: future }, airtable: { expiresAt: future }, }, 'exist', ); expect(pending).toEqual({ airtable: { expiresAt: future } }); }); });