// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ConnectorDetail } from '@open-design/contracts'; import { ConnectorsBrowser } from '../../src/components/ConnectorsBrowser'; import { cancelConnectorAuthorization, connectConnector, disconnectConnector, fetchConnectorDetail, fetchConnectorDiscovery, fetchConnectors, fetchConnectorStatuses, openExternalUrl, } from '../../src/providers/registry'; import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events'; vi.mock('../../src/providers/registry', async () => { const actual = await vi.importActual( '../../src/providers/registry', ); return { ...actual, cancelConnectorAuthorization: vi.fn(), connectConnector: vi.fn(), disconnectConnector: vi.fn(), fetchConnectorDetail: vi.fn(), fetchConnectorDiscovery: vi.fn(), fetchConnectors: vi.fn(), fetchConnectorStatuses: vi.fn(), openExternalUrl: vi.fn(), }; }); const configuredComposioConnector: ConnectorDetail = { id: 'github', name: 'GitHub', provider: 'Composio', category: 'Code', status: 'connected', auth: { provider: 'composio', configured: true }, tools: [], }; function makeTool(name: string): ConnectorDetail['tools'][number] { return { name, title: name.replace(/_/g, ' '), safety: { sideEffect: 'read', approval: 'auto', reason: 'Reads data.' }, refreshEligible: true, }; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } describe('ConnectorsBrowser', () => { afterEach(() => { cleanup(); vi.mocked(cancelConnectorAuthorization).mockReset(); vi.mocked(connectConnector).mockReset(); vi.mocked(disconnectConnector).mockReset(); vi.mocked(fetchConnectors).mockReset(); vi.mocked(fetchConnectorDetail).mockReset(); vi.mocked(fetchConnectorDiscovery).mockReset(); vi.mocked(fetchConnectorStatuses).mockReset(); vi.mocked(openExternalUrl).mockReset(); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null); vi.mocked(connectConnector).mockResolvedValue({ connector: null }); vi.mocked(disconnectConnector).mockResolvedValue(null); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); vi.mocked(openExternalUrl).mockResolvedValue(true); window.sessionStorage.clear(); }); it('masks the grid immediately when the Composio key is cleared locally', async () => { vi.mocked(fetchConnectors).mockResolvedValue([configuredComposioConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([configuredComposioConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); render(); await waitFor(() => expect(screen.getByTestId('connector-gate')).toBeTruthy()); expect(screen.getByTestId('connector-grid-wrap').className).toContain('is-masked'); }); it('broadcasts connector changes when a connect action completes immediately', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; const connectedConnector: ConnectorDetail = { ...availableConnector, status: 'connected', accountLabel: 'inbox@example.com', }; const onChanged = vi.fn(); window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged); vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: connectedConnector, auth: { kind: 'connected' }, }); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)); window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged); }); it('broadcasts connector changes when a status refresh observes a completed authorization', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; const onChanged = vi.fn(); window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged); vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'connected', accountLabel: 'inbox@example.com' }, }); render(); await screen.findByText('GitHub'); fireEvent(window, new Event('focus')); await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)); window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged); }); it('broadcasts connector changes after disconnect succeeds', async () => { const onChanged = vi.fn(); window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged); vi.mocked(fetchConnectors).mockResolvedValue([configuredComposioConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([configuredComposioConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(disconnectConnector).mockResolvedValue({ ...configuredComposioConnector, status: 'available', }); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1)); window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged); }); it('keeps discovered tools when discovery resolves before the base catalog', async () => { const base = deferred(); const discovery = deferred(); vi.mocked(fetchConnectors).mockReturnValue(base.promise); vi.mocked(fetchConnectorDiscovery).mockReturnValue(discovery.promise); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); render(); discovery.resolve([ { ...configuredComposioConnector, tools: [ { name: 'list_issues', title: 'List issues', safety: { sideEffect: 'read', approval: 'auto', reason: 'Reads issues.' }, refreshEligible: true, }, ], }, ]); base.resolve([configuredComposioConnector]); await screen.findByText('GitHub'); await screen.findAllByText('1 tool'); fireEvent.click(screen.getByRole('button', { name: 'Open GitHub details' })); await screen.findByText('List issues'); await waitFor(() => expect(screen.getByText('List issues')).toBeTruthy()); expect(screen.getAllByText('1 tool')).toHaveLength(2); }); it('stops showing the drawer loading state after discovery completes with zero tools', async () => { const zeroToolConnector: ConnectorDetail = { ...configuredComposioConnector, toolCount: 0, }; vi.mocked(fetchConnectors).mockResolvedValue([zeroToolConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([zeroToolConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Open GitHub details' })); await waitFor(() => { expect( screen.getByText( 'No tools available yet. Connect to discover what this integration exposes.', ), ).toBeTruthy(); expect(screen.queryByText('Loading tools…')).toBeNull(); }); }); it('prefers refreshed catalog statuses over stale cached connector state', async () => { vi.mocked(fetchConnectors) .mockResolvedValueOnce([configuredComposioConnector]) .mockResolvedValueOnce([ { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: false }, }, ]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); const { rerender } = render( , ); await screen.findByRole('button', { name: 'Disconnect' }); rerender(); await waitFor(() => { expect(screen.getByRole('button', { name: 'Connect' })).toBeTruthy(); expect(screen.queryByRole('button', { name: 'Disconnect' })).toBeNull(); }); }); it('hydrates partial static tool previews when the advertised tool count is larger', async () => { const partialPreviewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [makeTool('search_pages'), makeTool('create_page')], }; vi.mocked(fetchConnectors).mockResolvedValue([partialPreviewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([partialPreviewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue({ ...partialPreviewConnector, tools: [makeTool('search_pages'), makeTool('create_page'), makeTool('update_page')], toolsNextCursor: 'next-page', toolsHasMore: true, }); render(); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => { expect(fetchConnectorDetail).toHaveBeenCalledWith('notion', { hydrateTools: true, toolsLimit: 50, }); }); await screen.findByText('update page'); expect(screen.getByRole('button', { name: 'Load more tools' })).toBeTruthy(); }); it('hydrates empty tool previews when the advertised tool count is unknown', async () => { const unknownCountConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'bitbucket', name: 'Bitbucket', status: 'available', tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([unknownCountConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([unknownCountConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue({ ...unknownCountConnector, tools: [makeTool('list_repositories')], }); render(); await screen.findByText('Bitbucket'); fireEvent.click(screen.getByRole('button', { name: 'Open Bitbucket details' })); await waitFor(() => { expect(fetchConnectorDetail).toHaveBeenCalledWith('bitbucket', { hydrateTools: true, toolsLimit: 50, }); }); await screen.findByText('list repositories'); }); it('does not fetch drawer tool previews before the Composio key is configured', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); render(); await screen.findByText('Notion'); expect(screen.getByTestId('connector-grid-wrap').className).toContain('is-masked'); expect(screen.queryByTestId('connector-drawer')).toBeNull(); expect(fetchConnectorDetail).not.toHaveBeenCalled(); }); it('does not keep loading after failed tool preview fetches', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); render(); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1)); await new Promise((resolve) => setTimeout(resolve, 0)); expect(fetchConnectorDetail).toHaveBeenCalledTimes(1); expect(screen.queryByText('Loading tools…')).toBeNull(); expect(screen.getByText('Tool details are unavailable, but this connector reports 48 tools.')).toBeTruthy(); }); it('keeps static preview tools visible when preview hydration fails', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [makeTool('search_pages'), makeTool('create_page')], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); render(); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1)); await screen.findByText('search pages'); expect(screen.getByText('create page')).toBeTruthy(); expect(screen.queryByText('Loading tools…')).toBeNull(); }); it('retries failed drawer preview hydration when the catalog refresh key changes', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); const { rerender } = render( , ); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1)); rerender(); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(2)); }); it('retries failed drawer preview hydration when the drawer is reopened', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); render(); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByTestId('connector-drawer')).toBeNull()); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(2)); }); it('retries failed drawer preview hydration when reopened from a panel alert', async () => { const previewConnector: ConnectorDetail = { ...configuredComposioConnector, id: 'notion', name: 'Notion', status: 'available', toolCount: 48, tools: [], }; vi.mocked(fetchConnectors).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([previewConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(fetchConnectorDetail).mockResolvedValue(null); vi.mocked(connectConnector).mockResolvedValue({ connector: null, error: 'Composio provider is not configured', }); render(); await screen.findByText('Notion'); fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByTestId('connector-drawer')).toBeNull()); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(connectConnector).toHaveBeenCalledWith('notion')); const alert = await screen.findByRole('status'); expect(alert.textContent).toContain('Notion: Composio provider is not configured'); const alertRow = alert.closest('.connector-panel-alert'); expect(alertRow).not.toBeNull(); fireEvent.click(within(alertRow as HTMLElement).getByRole('button', { name: 'Open Notion details' })); await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(2)); expect(fetchConnectorDetail).toHaveBeenNthCalledWith(2, 'notion', { hydrateTools: true, toolsLimit: 50, }); }); it('cancels pending authorization through the daemon before clearing the local state', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(availableConnector); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); const authorizationButton = screen.getByRole('button', { name: 'Continue in browser' }); fireEvent.click(authorizationButton); await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith( 'https://example.com/oauth', )); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).toBeTruthy()); }); it('keeps pending authorization visible when daemon cancellation fails', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy(); const status = screen.getByRole('status'); expect(status.textContent).toContain("Couldn't cancel authorization. Try again."); expect(status.closest('.connector-grid')).toBeNull(); expect(status.closest('.connector-card')).toBeNull(); const alertRow = status.closest('.connector-panel-alert'); expect(alertRow).not.toBeNull(); fireEvent.click(within(alertRow as HTMLElement).getByRole('button', { name: 'Open GitHub details' })); const drawer = await screen.findByTestId('connector-drawer'); expect(within(drawer).getByRole('alert').textContent).toContain("Couldn't cancel authorization. Try again."); expect(document.querySelector('.connector-panel-alert')).toBeNull(); expect( JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'), ).toHaveProperty('github'); }); it('clears failed authorization cancel alerts after status refresh connects', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses) .mockResolvedValueOnce({}) .mockResolvedValueOnce({ github: { status: 'connected' } }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); expect(screen.getByRole('status').textContent).toContain("Couldn't cancel authorization. Try again."); fireEvent.focus(window); await waitFor(() => expect(fetchConnectorStatuses).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); expect(document.querySelector('.connector-panel-alert')).toBeNull(); expect(screen.getByRole('button', { name: 'Disconnect' })).toBeTruthy(); }); it('does not show a failed authorization cancel alert when refresh already reports connected', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'connected' } }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); await waitFor(() => expect(fetchConnectorStatuses).toHaveBeenCalledTimes(1)); await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); expect(document.querySelector('.connector-panel-alert')).toBeNull(); expect(screen.getByRole('button', { name: 'Disconnect' })).toBeTruthy(); }); it('surfaces a connect error above the connector grid', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: null, error: 'Composio provider is not configured', }); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(connectConnector).toHaveBeenCalledWith('github')); const alert = await screen.findByRole('status'); expect(alert.textContent).toContain('GitHub: Composio provider is not configured'); expect(alert.textContent).toContain('GitHub'); expect(alert.textContent).toContain('Composio provider is not configured'); expect(alert.closest('.connector-grid')).toBeNull(); expect(alert.closest('.connector-card')).toBeNull(); const alertRow = alert.closest('.connector-panel-alert'); expect(alertRow).not.toBeNull(); fireEvent.click(within(alertRow as HTMLElement).getByRole('button', { name: 'Open GitHub details' })); const drawer = await screen.findByTestId('connector-drawer'); expect(within(drawer).getByText('Composio provider is not configured')).toBeTruthy(); expect(document.querySelector('.connector-panel-alert')).toBeNull(); }); it('preserves full long connect errors in the panel alert title and drawer', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, name: 'GitHub Enterprise Connector With A Very Long Display Name', status: 'available', auth: { provider: 'composio', configured: true }, }; const longError = [ 'OAuth failed because provider returned invalid_request.', 'Raw response:', 'https://example.com/callback?error_description=this-is-a-very-long-provider-token-that-should-not-stretch-the-card-grid', ].join(' '); vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: null, error: longError, }); render(); await screen.findByText('GitHub Enterprise Connector With A Very Long Display Name'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(connectConnector).toHaveBeenCalledWith('github')); const alert = await screen.findByRole('status'); expect(alert.textContent).toContain(longError); expect(alert.closest('.connector-grid')).toBeNull(); expect(alert.closest('.connector-card')).toBeNull(); expect(alert.querySelector('strong')?.getAttribute('title')).toBe( 'GitHub Enterprise Connector With A Very Long Display Name', ); const message = alert.querySelector('span[title]'); expect(message?.getAttribute('title')).toBe(longError); const alertRow = alert.closest('.connector-panel-alert'); expect(alertRow).not.toBeNull(); fireEvent.click(within(alertRow as HTMLElement).getByRole('button', { name: 'Open GitHub Enterprise Connector With A Very Long Display Name details', })); const drawer = await screen.findByTestId('connector-drawer'); expect(within(drawer).getByText(longError)).toBeTruthy(); expect(document.querySelector('.connector-panel-alert')).toBeNull(); }); it('clears the panel connect error when the user retries and succeeds', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector) .mockResolvedValueOnce({ connector: null, error: 'Composio provider is not configured' }) .mockResolvedValueOnce({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2026-05-08T10:00:00.000Z', }, }); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect( screen.getByRole('status').textContent, ).toContain('Composio provider is not configured')); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(connectConnector).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); }); it('does not mark failed OAuth launches as pending authorization', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({}); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2026-05-08T10:00:00.000Z', }, error: 'Popup blocked. Allow popups for Open Design and try again.', }); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await waitFor(() => expect(connectConnector).toHaveBeenCalledWith('github')); await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).toBeTruthy()); expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); expect( JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'), ).not.toHaveProperty('github'); }); it('does not auto-cancel pending authorization on focus while the daemon authorization window is still valid', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'available' }, }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(availableConnector); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); fireEvent(window, new Event('focus')); await waitFor(() => expect(fetchConnectorStatuses).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 0)); expect(cancelConnectorAuthorization).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy(); expect( JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'), ).toHaveProperty('github'); }); it('auto-cancels stuck pending authorization on focus once the daemon authorization window has expired', async () => { vi.useFakeTimers({ toFake: ['Date'] }); const startMs = new Date('2026-05-17T10:00:00.000Z').getTime(); vi.setSystemTime(startMs); const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'available' }, }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: new Date(startMs + 5 * 60 * 1000).toISOString(), }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(availableConnector); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); vi.setSystemTime(startMs + 10 * 60 * 1000); fireEvent(window, new Event('focus')); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); await screen.findByRole('button', { name: 'Connect' }); expect( JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'), ).not.toHaveProperty('github'); vi.useRealTimers(); }); it('marks the auto-cancel as failed when the daemon /authorization/cancel returns null after the authorization expires', async () => { vi.useFakeTimers({ toFake: ['Date'] }); const startMs = new Date('2026-05-17T10:00:00.000Z').getTime(); vi.setSystemTime(startMs); const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'available' }, }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: new Date(startMs + 5 * 60 * 1000).toISOString(), }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); vi.setSystemTime(startMs + 10 * 60 * 1000); fireEvent(window, new Event('focus')); await waitFor(() => expect(cancelConnectorAuthorization).toHaveBeenCalledWith('github')); expect(await screen.findAllByText("Couldn't cancel authorization. Try again.")).toHaveLength(2); vi.useRealTimers(); }); it('does not auto-cancel pending authorization on focus when the daemon already reports the connector as connected', async () => { const availableConnector: ConnectorDetail = { ...configuredComposioConnector, status: 'available', auth: { provider: 'composio', configured: true }, }; vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]); vi.mocked(fetchConnectorStatuses).mockResolvedValue({ github: { status: 'connected' }, }); vi.mocked(connectConnector).mockResolvedValue({ connector: availableConnector, auth: { kind: 'redirect_required', redirectUrl: 'https://example.com/oauth', expiresAt: '2099-05-08T10:00:00.000Z', }, }); vi.mocked(cancelConnectorAuthorization).mockResolvedValue(availableConnector); render(); await screen.findByText('GitHub'); fireEvent.click(screen.getByRole('button', { name: 'Connect' })); await screen.findByRole('button', { name: 'Cancel' }); fireEvent(window, new Event('focus')); await waitFor(() => expect(fetchConnectorStatuses).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 0)); expect(cancelConnectorAuthorization).not.toHaveBeenCalled(); }); });