mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(web): surface connector auth errors and stop silent popup close (#725) Two layered bugs caused the "Twitter Connect button does nothing" symptom: 1. ConnectorsBrowser dropped result.error from connectConnector. On Electron desktop the popup is never opened (electronAPI.openExternal path), so the existing renderConnectorAuthError(null, ...) was a no-op and the user got zero feedback. 2. registry.ts silently called authWindow?.close() whenever the connect response did not carry { kind: 'redirect_required', redirectUrl }, leaving web users with a popup that vanishes without explanation. Patch: - Add a per-connector connectorAuthorizationError state and render it as an inline banner on both ConnectorCard and ConnectorDetailDrawer (mirrors the existing cancel-failed pattern; reuses the existing .connector-authorization-error styling). - Replace authWindow?.close() with a renderConnectorAuthInfo helper that branches on auth.kind ('connected' | 'pending' | unknown) and writes an explanatory message to the popup before the user closes it. - Tests: 1 registry test for the pending/info popup branch, 2 ConnectorsBrowser tests for surfacing and clearing the inline banner. * fix(web): clear connector auth error on background status refresh Addresses review feedback from @mrcfps and the Codex bot on PR #1128: the inline error banner stayed visible even after background status refresh marked the connector as `connected` (e.g. user completes auth out-of-band through the Composio dashboard, then focus/poll/message refresh observes the connection). - Add clearConnectorAuthorizationErrorsForConnected helper next to the existing pending-state helpers; same shape, returns the same object reference when nothing changes so React skips a re-render. - Wire it into reloadConnectorStatuses so every status refresh path (pending poll, focus, OAuth callback message) drops stale errors for any connector now reported as connected. - Add 2 unit tests for the helper next to the existing pending-state helper tests in EntryView.test.ts.
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } 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,
|
|
fetchConnectorDetail,
|
|
fetchConnectorDiscovery,
|
|
fetchConnectors,
|
|
fetchConnectorStatuses,
|
|
} from '../../src/providers/registry';
|
|
|
|
vi.mock('../../src/providers/registry', () => ({
|
|
cancelConnectorAuthorization: vi.fn(),
|
|
connectConnector: vi.fn(),
|
|
disconnectConnector: vi.fn(),
|
|
fetchConnectorDetail: vi.fn(),
|
|
fetchConnectorDiscovery: vi.fn(),
|
|
fetchConnectors: vi.fn(),
|
|
fetchConnectorStatuses: 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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe('ConnectorsBrowser', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.mocked(cancelConnectorAuthorization).mockReset();
|
|
vi.mocked(connectConnector).mockReset();
|
|
vi.mocked(fetchConnectors).mockReset();
|
|
vi.mocked(fetchConnectorDetail).mockReset();
|
|
vi.mocked(fetchConnectorDiscovery).mockReset();
|
|
vi.mocked(fetchConnectorStatuses).mockReset();
|
|
vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null);
|
|
vi.mocked(connectConnector).mockResolvedValue({ connector: null });
|
|
vi.mocked(fetchConnectorDetail).mockResolvedValue(null);
|
|
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(<ConnectorsBrowser composioConfigured={false} />);
|
|
|
|
await waitFor(() => expect(screen.getByTestId('connector-gate')).toBeTruthy());
|
|
expect(screen.getByTestId('connector-grid-wrap').className).toContain('is-masked');
|
|
});
|
|
|
|
it('keeps discovered tools when discovery resolves before the base catalog', async () => {
|
|
const base = deferred<ConnectorDetail[]>();
|
|
const discovery = deferred<ConnectorDetail[]>();
|
|
vi.mocked(fetchConnectors).mockReturnValue(base.promise);
|
|
vi.mocked(fetchConnectorDiscovery).mockReturnValue(discovery.promise);
|
|
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
|
|
|
|
render(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(
|
|
<ConnectorsBrowser composioConfigured catalogRefreshKey="initial" />,
|
|
);
|
|
|
|
await screen.findByRole('button', { name: 'Disconnect' });
|
|
|
|
rerender(<ConnectorsBrowser composioConfigured catalogRefreshKey="refetched" />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured={false} />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(
|
|
<ConnectorsBrowser composioConfigured catalogRefreshKey="initial" />,
|
|
);
|
|
|
|
await screen.findByText('Notion');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Notion details' }));
|
|
await waitFor(() => expect(fetchConnectorDetail).toHaveBeenCalledTimes(1));
|
|
|
|
rerender(<ConnectorsBrowser composioConfigured catalogRefreshKey="refetched" />);
|
|
|
|
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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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('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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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(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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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();
|
|
expect(screen.getByRole('alert').textContent).toContain("Couldn't cancel authorization. Try again.");
|
|
expect(
|
|
JSON.parse(window.sessionStorage.getItem('od-connectors-authorization-pending') ?? '{}'),
|
|
).toHaveProperty('github');
|
|
});
|
|
|
|
it('surfaces a connect error inline on the connector card', 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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
await screen.findByText('GitHub');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
|
|
|
await waitFor(() => expect(connectConnector).toHaveBeenCalledWith('github'));
|
|
await waitFor(() => expect(
|
|
screen.getByRole('alert').textContent,
|
|
).toContain('Composio provider is not configured'));
|
|
});
|
|
|
|
it('clears the inline 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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
await screen.findByText('GitHub');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
|
await waitFor(() => expect(
|
|
screen.getByRole('alert').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('alert')).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(<ConnectorsBrowser composioConfigured />);
|
|
|
|
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');
|
|
});
|
|
});
|