Files
SidandGitHub e948405c22 fix(web): surface connector auth errors and stop silent popup close (#725) (#1128)
* 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.
2026-05-10 19:38:18 +08:00

340 lines
10 KiB
TypeScript

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 } });
});
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 } });
});
});