540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
interface StoreState {
|
|
installCustomMcpServer: ReturnType<typeof vi.fn>;
|
|
cliStatus?: Record<string, unknown> | null;
|
|
cliStatusLoading?: boolean;
|
|
}
|
|
|
|
const storeState = {} as StoreState;
|
|
const lookupMock = vi.fn();
|
|
|
|
vi.mock('@renderer/store', () => ({
|
|
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
|
|
}));
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
apiKeys: {
|
|
lookup: (...args: unknown[]) => lookupMock(...args),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/button', () => ({
|
|
Button: ({
|
|
children,
|
|
onClick,
|
|
type = 'button',
|
|
disabled,
|
|
}: React.PropsWithChildren<{
|
|
onClick?: () => void;
|
|
type?: 'button' | 'submit' | 'reset';
|
|
disabled?: boolean;
|
|
}>) =>
|
|
React.createElement(
|
|
'button',
|
|
{
|
|
type,
|
|
disabled,
|
|
onClick,
|
|
},
|
|
children
|
|
),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/dialog', () => ({
|
|
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
|
|
open ? React.createElement('div', null, children) : null,
|
|
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
|
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
|
|
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
|
|
DialogDescription: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement('p', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/input', () => ({
|
|
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
|
|
React.createElement('input', props),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/label', () => ({
|
|
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/select', () => ({
|
|
Select: ({
|
|
children,
|
|
value,
|
|
onValueChange,
|
|
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
|
|
React.createElement(
|
|
'select',
|
|
{
|
|
'data-testid': 'scope-select',
|
|
value,
|
|
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
|
|
},
|
|
children
|
|
),
|
|
SelectTrigger: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
SelectValue: () => null,
|
|
SelectContent: ({ children }: React.PropsWithChildren) =>
|
|
React.createElement(React.Fragment, null, children),
|
|
SelectItem: ({
|
|
children,
|
|
value,
|
|
disabled,
|
|
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
|
React.createElement('option', { value, disabled }, children),
|
|
}));
|
|
|
|
vi.mock('lucide-react', () => {
|
|
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
|
|
return {
|
|
Plus: Icon,
|
|
Server: Icon,
|
|
Trash2: Icon,
|
|
};
|
|
});
|
|
|
|
import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog';
|
|
|
|
function setNativeValue(
|
|
element: HTMLInputElement | HTMLSelectElement,
|
|
value: string,
|
|
eventName: 'input' | 'change'
|
|
): void {
|
|
const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
|
descriptor?.set?.call(element, value);
|
|
element.dispatchEvent(new Event(eventName, { bubbles: true }));
|
|
}
|
|
|
|
describe('CustomMcpServerDialog project scope', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
|
|
storeState.cliStatus = {
|
|
flavor: 'claude',
|
|
installed: true,
|
|
authLoggedIn: true,
|
|
binaryPath: '/usr/local/bin/claude',
|
|
launchError: null,
|
|
providers: [],
|
|
};
|
|
storeState.cliStatusLoading = false;
|
|
lookupMock.mockReset();
|
|
lookupMock.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('disables non-user scopes without an active project', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
|
|
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
|
|
expect(projectOption.disabled).toBe(true);
|
|
expect(localOption.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('defaults to global scope in multimodel mode', async () => {
|
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
expect(scopeSelect.value).toBe('global');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('preserves entered values when multimodel scope metadata loads after open', async () => {
|
|
storeState.cliStatus = null;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
|
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
|
|
await act(async () => {
|
|
setNativeValue(nameInput, 'late-hydration-server', 'input');
|
|
setNativeValue(packageInput, '@example/late-hydration', 'input');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(scopeSelect.value).toBe('user');
|
|
|
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe(
|
|
'late-hydration-server'
|
|
);
|
|
expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe(
|
|
'@example/late-hydration'
|
|
);
|
|
expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe(
|
|
'global'
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('disables installation when the runtime declares MCP writes unavailable', async () => {
|
|
storeState.cliStatus = {
|
|
flavor: 'agent_teams_orchestrator',
|
|
installed: true,
|
|
authLoggedIn: true,
|
|
binaryPath: '/usr/local/bin/claude-multimodel',
|
|
launchError: null,
|
|
providers: [
|
|
{
|
|
providerId: 'anthropic',
|
|
displayName: 'Anthropic',
|
|
supported: true,
|
|
authenticated: true,
|
|
authMethod: 'oauth_token',
|
|
verificationState: 'verified',
|
|
models: [],
|
|
canLoginFromUi: true,
|
|
capabilities: {
|
|
teamLaunch: true,
|
|
oneShot: true,
|
|
extensions: {
|
|
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
|
mcp: {
|
|
status: 'read-only',
|
|
ownership: 'shared',
|
|
reason: 'MCP writes unavailable',
|
|
},
|
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: null,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('MCP writes unavailable');
|
|
const installButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.includes('Install')
|
|
) as HTMLButtonElement | undefined;
|
|
expect(installButton).toBeDefined();
|
|
expect(installButton?.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('looks up project-scoped API keys only when project scope is selected', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: '/tmp/custom-mcp-project',
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
|
button.textContent?.includes('Add')
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
addEnvButton.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const envKeyInput = host.querySelector(
|
|
'input[placeholder="ENV_VAR_NAME"]'
|
|
) as HTMLInputElement;
|
|
await act(async () => {
|
|
setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined);
|
|
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
await act(async () => {
|
|
setNativeValue(scopeSelect, 'project', 'change');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project');
|
|
|
|
await act(async () => {
|
|
setNativeValue(scopeSelect, 'user', 'change');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('clears stale project auto-filled values when switching back to user scope', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
lookupMock
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }])
|
|
.mockResolvedValueOnce([]);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose: vi.fn(),
|
|
projectPath: '/tmp/custom-mcp-project',
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
|
button.textContent?.includes('Add')
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
addEnvButton.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const envKeyInput = host.querySelector(
|
|
'input[placeholder="ENV_VAR_NAME"]'
|
|
) as HTMLInputElement;
|
|
const envValueInput = host.querySelector(
|
|
'input[placeholder="value"]'
|
|
) as HTMLInputElement;
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
|
|
await act(async () => {
|
|
setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await act(async () => {
|
|
setNativeValue(scopeSelect, 'project', 'change');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(envValueInput.value).toBe('project-secret');
|
|
|
|
await act(async () => {
|
|
setNativeValue(scopeSelect, 'user', 'change');
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(envValueInput.value).toBe('');
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('passes projectPath for project-scoped custom installs', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onClose = vi.fn();
|
|
const projectPath = '/tmp/custom-mcp-project';
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose,
|
|
projectPath,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
|
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
|
|
await act(async () => {
|
|
setNativeValue(nameInput, 'custom-context7', 'input');
|
|
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
|
|
setNativeValue(scopeSelect, 'project', 'change');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const installButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Install'
|
|
) as HTMLButtonElement;
|
|
expect(installButton.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
installButton.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName: 'custom-context7',
|
|
scope: 'project',
|
|
projectPath,
|
|
})
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('passes projectPath for local-scoped custom installs', async () => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const onClose = vi.fn();
|
|
const projectPath = '/tmp/custom-mcp-project';
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(CustomMcpServerDialog, {
|
|
open: true,
|
|
onClose,
|
|
projectPath,
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const nameInput = host.querySelector('#custom-name') as HTMLInputElement;
|
|
const packageInput = host.querySelector('#custom-npm') as HTMLInputElement;
|
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
|
|
|
await act(async () => {
|
|
setNativeValue(nameInput, 'local-context7', 'input');
|
|
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
|
|
setNativeValue(scopeSelect, 'local', 'change');
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const installButton = Array.from(host.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Install'
|
|
) as HTMLButtonElement;
|
|
expect(installButton.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
installButton.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(storeState.installCustomMcpServer).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName: 'local-context7',
|
|
scope: 'local',
|
|
projectPath,
|
|
})
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|