mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
344 lines
10 KiB
TypeScript
344 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,
|
|
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 } });
|
|
});
|
|
});
|