Files
open-design/apps/web/tests/components/App.connectors.test.tsx

348 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from '../../src/App';
import type { AppConfig } from '../../src/types';
import {
fetchDaemonConfig,
fetchComposioConfigFromDaemon,
loadConfig,
mergeDaemonConfig,
saveConfig,
syncComposioConfigToDaemon,
syncConfigToDaemon,
} from '../../src/state/config';
import {
daemonIsLive,
fetchAgents,
fetchAppVersionInfo,
fetchDesignSystems,
fetchPromptTemplates,
fetchSkills,
} from '../../src/providers/registry';
import { listProjects, listTemplates } from '../../src/state/projects';
const useRouteMock = vi.fn(() => ({ kind: 'home' as const }));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
useRoute: () => useRouteMock(),
}));
vi.mock('../../src/components/EntryView', () => ({
EntryView: ({ onOpenSettings }: { onOpenSettings: (section?: 'composio') => void }) => (
<button type="button" onClick={() => onOpenSettings('composio')}>
Open connectors settings
</button>
),
}));
vi.mock('../../src/components/ProjectView', () => ({
ProjectView: () => <div>Project view</div>,
}));
vi.mock('../../src/components/pet/PetOverlay', () => ({
PetOverlay: () => null,
}));
vi.mock('../../src/components/pet/pets', () => ({
migrateCustomPetAtlas: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../src/components/SettingsDialog', () => ({
SettingsDialog: ({
initial,
initialSection,
onPersistComposioKey,
}: {
initial: AppConfig;
initialSection?: string;
onPersistComposioKey: (composio: AppConfig['composio']) => void;
}) => (
<div role="dialog" aria-label="Settings dialog">
<div>Section: {initialSection}</div>
<div>Composio tail: {initial.composio?.apiKeyTail ?? 'none'}</div>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
apiKeyTail: initial.composio?.apiKeyTail ?? '',
})
}
>
Save connectors key
</button>
<button
type="button"
onClick={() =>
onPersistComposioKey({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
})
}
>
Clear connectors key
</button>
</div>
),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
daemonIsLive: vi.fn(),
fetchAgents: vi.fn(),
fetchAppVersionInfo: vi.fn(),
fetchDesignSystems: vi.fn(),
fetchPromptTemplates: vi.fn(),
fetchSkills: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
listProjects: vi.fn(),
listTemplates: vi.fn(),
};
});
vi.mock('../../src/state/config', async () => {
const actual = await vi.importActual<typeof import('../../src/state/config')>(
'../../src/state/config',
);
return {
...actual,
loadConfig: vi.fn(),
mergeDaemonConfig: vi.fn(),
saveConfig: vi.fn(),
fetchDaemonConfig: vi.fn().mockResolvedValue({}),
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true),
fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null),
};
});
const mockedDaemonIsLive = vi.mocked(daemonIsLive);
const mockedFetchAgents = vi.mocked(fetchAgents);
const mockedFetchAppVersionInfo = vi.mocked(fetchAppVersionInfo);
const mockedFetchDesignSystems = vi.mocked(fetchDesignSystems);
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
const mockedFetchSkills = vi.mocked(fetchSkills);
const mockedListProjects = vi.mocked(listProjects);
const mockedListTemplates = vi.mocked(listTemplates);
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
const mockedFetchComposioConfigFromDaemon = vi.mocked(fetchComposioConfigFromDaemon);
const mockedLoadConfig = vi.mocked(loadConfig);
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
const mockedSaveConfig = vi.mocked(saveConfig);
const mockedSyncConfigToDaemon = vi.mocked(syncConfigToDaemon);
const mockedSyncComposioConfigToDaemon = vi.mocked(syncComposioConfigToDaemon);
const baseConfig: AppConfig = {
mode: 'api',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
composio: {},
agentModels: {},
agentCliEnv: {},
};
describe('App connectors settings flows', () => {
beforeEach(() => {
mockedDaemonIsLive.mockResolvedValue(true);
mockedFetchAgents.mockResolvedValue([]);
mockedFetchSkills.mockResolvedValue([]);
mockedFetchDesignSystems.mockResolvedValue([]);
mockedFetchPromptTemplates.mockResolvedValue([]);
mockedFetchAppVersionInfo.mockResolvedValue(null);
mockedListProjects.mockResolvedValue([]);
mockedListTemplates.mockResolvedValue([]);
mockedFetchDaemonConfig.mockResolvedValue({});
mockedFetchComposioConfigFromDaemon.mockResolvedValue(null);
mockedMergeDaemonConfig.mockImplementation((local) => local);
mockedLoadConfig.mockReturnValue({ ...baseConfig });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('hydrates a daemon-saved Composio key into settings when local state does not have a pending edit', async () => {
mockedFetchComposioConfigFromDaemon.mockResolvedValue({
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
await waitFor(() => {
expect(screen.getByText('Composio tail: uQEg')).toBeTruthy();
});
});
it('does not show first-run privacy consent until daemon config hydration finishes', async () => {
let resolveDaemonConfig: (value: Record<string, never>) => void = () => {};
mockedFetchDaemonConfig.mockReturnValue(
new Promise((resolve) => {
resolveDaemonConfig = resolve;
}),
);
const { container } = render(<App />);
await waitFor(() => {
expect(mockedFetchDaemonConfig).toHaveBeenCalled();
});
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
resolveDaemonConfig({});
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
const banner = container.querySelector('.privacy-consent-banner');
expect(banner?.querySelector('.seg-control')).toBeNull();
expect(banner?.querySelector('.seg-btn.active')).toBeNull();
expect(screen.getByRole('button', { name: 'Help improve' }).className).toContain(
'privacy-consent-action',
);
});
it('hides first-run privacy consent while settings is open', async () => {
const { container } = render(<App />);
await waitFor(() => {
expect(container.querySelector('.privacy-consent-banner')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
});
expect(container.querySelector('.privacy-consent-banner')).toBeNull();
});
it('normalizes local persistence but sends the raw replacement key to the daemon on save', async () => {
mockedLoadConfig.mockReturnValue({
...baseConfig,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'Save connectors key' }));
await waitFor(() => {
expect(mockedSyncComposioConfigToDaemon).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'cmp_secret_replacement',
apiKeyConfigured: true,
}),
);
});
expect(mockedSaveConfig).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'ment',
},
}),
);
expect(mockedSyncConfigToDaemon).toHaveBeenCalledWith(
expect.objectContaining({
onboardingCompleted: true,
}),
);
expect(mockedSaveConfig.mock.calls.at(-1)?.[0]).toMatchObject({
onboardingCompleted: true,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'ment',
},
});
});
it('sends a cleared Composio config to the daemon when the saved key is removed', async () => {
mockedLoadConfig.mockReturnValue({
...baseConfig,
composio: {
apiKey: '',
apiKeyConfigured: true,
apiKeyTail: 'uQEg',
},
});
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Open connectors settings' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Settings dialog' })).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'Clear connectors key' }));
await waitFor(() => {
expect(mockedSyncComposioConfigToDaemon).toHaveBeenCalledWith({
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
});
});
expect(mockedSaveConfig.mock.calls.at(-1)?.[0]).toMatchObject({
composio: {
apiKey: '',
apiKeyConfigured: false,
apiKeyTail: '',
},
});
});
});