319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import Settings from '@/pages/Settings';
|
|
import { createProjectConfig } from '../../shared/project-config';
|
|
import { useOpencodeStore } from '@/stores/opencode';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
|
|
const hostApiFetchMock = vi.fn();
|
|
const invokeIpcMock = vi.fn();
|
|
|
|
vi.mock('@/lib/host-api', () => ({
|
|
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
|
}));
|
|
|
|
vi.mock('@/lib/api-client', () => ({
|
|
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
|
|
toUserMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
}));
|
|
|
|
vi.mock('@/components/settings/UpdateSettings', () => ({
|
|
UpdateSettings: () => null,
|
|
}));
|
|
|
|
vi.mock('react-i18next', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('react-i18next')>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string) => key,
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('sonner', () => ({
|
|
toast: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
function renderSettings() {
|
|
return render(
|
|
<MemoryRouter initialEntries={['/settings']}>
|
|
<Routes>
|
|
<Route path="/settings" element={<Settings />} />
|
|
<Route path="/models" element={<div data-testid="models-config-page" />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
function mockValidatedWorkspaceHostApi(activeProject: {
|
|
id: string;
|
|
path: string;
|
|
name: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastOpenedAt: string;
|
|
}) {
|
|
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
|
if (path === '/api/opencode/status') {
|
|
return { state: 'stopped', port: 4096 };
|
|
}
|
|
if (path === '/api/opencode/config-summary') {
|
|
return {
|
|
model: 'openai/gpt-5.4',
|
|
smallModel: 'openai/gpt-5.4-mini',
|
|
providerIds: ['openai', 'openrouter'],
|
|
providerCount: 2,
|
|
};
|
|
}
|
|
if (path === '/api/opencode/projects') {
|
|
return { projects: [activeProject], activeProject };
|
|
}
|
|
if (path === '/api/opencode/start' && init?.method === 'POST') {
|
|
return {
|
|
success: true,
|
|
status: { state: 'running', port: 4097, url: 'http://127.0.0.1:4097' },
|
|
};
|
|
}
|
|
if (path === '/api/opencode/sessions') {
|
|
return { sessions: [] };
|
|
}
|
|
if (path === '/api/opencode/sessions/status') {
|
|
return { statuses: {} };
|
|
}
|
|
if (path === '/api/opencode/health') {
|
|
return {
|
|
ok: true,
|
|
status: { state: 'running', port: 4097, url: 'http://127.0.0.1:4097' },
|
|
};
|
|
}
|
|
throw new Error(`Unexpected path ${path}`);
|
|
});
|
|
}
|
|
|
|
describe('Settings runtime workspace', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
invokeIpcMock.mockResolvedValue(undefined);
|
|
useSettingsStore.setState({
|
|
theme: 'system',
|
|
language: 'en',
|
|
launchAtStartup: false,
|
|
telemetryEnabled: false,
|
|
proxyEnabled: false,
|
|
proxyServer: '',
|
|
proxyHttpServer: '',
|
|
proxyHttpsServer: '',
|
|
proxyAllServer: '',
|
|
proxyBypassRules: '',
|
|
sidebarCollapsed: false,
|
|
devModeUnlocked: false,
|
|
});
|
|
useOpencodeStore.setState({
|
|
status: { state: 'stopped', port: 4096 },
|
|
health: null,
|
|
healthCheckedAt: null,
|
|
runtimeConfigSummary: null,
|
|
projects: [],
|
|
activeProject: null,
|
|
sessions: [],
|
|
sessionsByProjectId: {},
|
|
selectedSessionId: null,
|
|
sessionStatuses: {},
|
|
sessionMessages: [],
|
|
sessionMessagesBySessionId: {},
|
|
streamingMessage: null,
|
|
streamingMessagesBySessionId: {},
|
|
streamingTools: [],
|
|
streamingToolsBySessionId: {},
|
|
sendingSessionId: null,
|
|
sendingSessionIds: {},
|
|
queuedSessionPrompts: {},
|
|
sessionDiffs: [],
|
|
sessionDiffLoading: false,
|
|
sessionDiffError: null,
|
|
fileStatuses: [],
|
|
fileSearchResults: [],
|
|
contentSearchResults: [],
|
|
selectedContextFile: null,
|
|
fileContextLoading: false,
|
|
fileContextError: null,
|
|
runtimeAutoStartAttempted: false,
|
|
loading: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('hides non-general settings until the general header reveal button is clicked', async () => {
|
|
const activeProject = {
|
|
id: 'prj_1',
|
|
path: 'D:/repo/packages/ui',
|
|
name: 'ui',
|
|
createdAt: '2026-05-12T00:00:00.000Z',
|
|
updatedAt: '2026-05-12T00:00:00.000Z',
|
|
lastOpenedAt: '2026-05-12T00:00:00.000Z',
|
|
};
|
|
mockValidatedWorkspaceHostApi(activeProject);
|
|
|
|
renderSettings();
|
|
|
|
expect(await screen.findByRole('heading', { name: 'appearance.title' })).toBeInTheDocument();
|
|
expect(screen.queryByTestId('settings-opencode-workspace')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Network')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('updates.title')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('advanced.title')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'appearance.showMoreSettings' }));
|
|
|
|
expect(await screen.findByTestId('settings-opencode-workspace')).toBeInTheDocument();
|
|
expect(screen.getByText('Network')).toBeInTheDocument();
|
|
expect(screen.getByText('updates.title')).toBeInTheDocument();
|
|
expect(screen.getByText('advanced.title')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders runtime, model, and workspace controls in settings without exposing the runtime brand', async () => {
|
|
const activeProject = {
|
|
id: 'prj_1',
|
|
path: 'D:/repo/packages/ui',
|
|
name: 'ui',
|
|
createdAt: '2026-05-12T00:00:00.000Z',
|
|
updatedAt: '2026-05-12T00:00:00.000Z',
|
|
lastOpenedAt: '2026-05-12T00:00:00.000Z',
|
|
};
|
|
|
|
mockValidatedWorkspaceHostApi(activeProject);
|
|
|
|
renderSettings();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'appearance.showMoreSettings' }));
|
|
|
|
const workspace = await screen.findByTestId('settings-opencode-workspace');
|
|
expect(within(workspace).getByText('Workspace')).toBeInTheDocument();
|
|
expect(screen.getByTestId('settings-opencode-runtime')).toBeInTheDocument();
|
|
expect(screen.getByTestId('settings-opencode-models')).toBeInTheDocument();
|
|
expect(screen.getByText('openai/gpt-5.4')).toBeInTheDocument();
|
|
expect(screen.getByText('openai/gpt-5.4-mini')).toBeInTheDocument();
|
|
expect(screen.getByText('2 providers')).toBeInTheDocument();
|
|
expect(screen.getAllByText('D:/repo/packages/ui').length).toBeGreaterThan(0);
|
|
|
|
await waitFor(() =>
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/start', {
|
|
method: 'POST',
|
|
})
|
|
);
|
|
expect(screen.getByRole('button', { name: 'Restart runtime' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Stop runtime' })).toBeInTheDocument();
|
|
expect(document.body.textContent).not.toMatch(/opencode/i);
|
|
expect(screen.queryByLabelText(/opencode/i)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Check health' }));
|
|
|
|
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/health'));
|
|
});
|
|
|
|
it('opens the model configuration page from the workspace models card', async () => {
|
|
const activeProject = {
|
|
id: 'prj_1',
|
|
path: 'D:/repo/packages/ui',
|
|
name: 'ui',
|
|
createdAt: '2026-05-12T00:00:00.000Z',
|
|
updatedAt: '2026-05-12T00:00:00.000Z',
|
|
lastOpenedAt: '2026-05-12T00:00:00.000Z',
|
|
};
|
|
mockValidatedWorkspaceHostApi(activeProject);
|
|
|
|
renderSettings();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'appearance.showMoreSettings' }));
|
|
|
|
const modelsCard = await screen.findByTestId('settings-opencode-models');
|
|
fireEvent.click(within(modelsCard).getByRole('button', { name: 'Configure models' }));
|
|
|
|
expect(screen.getByTestId('models-config-page')).toBeInTheDocument();
|
|
});
|
|
|
|
it('does not activate a workspace project when its configuration is missing', async () => {
|
|
const activeProject = {
|
|
id: 'prj_1',
|
|
path: 'D:/repo/packages/ui',
|
|
name: 'ui',
|
|
createdAt: '2026-05-12T00:00:00.000Z',
|
|
updatedAt: '2026-05-12T00:00:00.000Z',
|
|
lastOpenedAt: '2026-05-12T00:00:00.000Z',
|
|
};
|
|
hostApiFetchMock.mockImplementation(async (path: string, _init?: RequestInit) => {
|
|
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
|
|
if (path === '/api/opencode/config-summary') {
|
|
return {
|
|
model: 'openai/gpt-5.4',
|
|
smallModel: 'openai/gpt-5.4-mini',
|
|
providerIds: ['openai'],
|
|
providerCount: 1,
|
|
};
|
|
}
|
|
if (path === '/api/opencode/projects') return { projects: [activeProject], activeProject: null };
|
|
if (path === `/api/opencode/projects/config?projectId=${encodeURIComponent(activeProject.id)}`) {
|
|
return { status: 'missing' };
|
|
}
|
|
throw new Error(`Unexpected path ${path}`);
|
|
});
|
|
|
|
renderSettings();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'appearance.showMoreSettings' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: `Use project ${activeProject.name}` }));
|
|
|
|
expect(await screen.findByText(/configuration is missing/i)).toBeInTheDocument();
|
|
expect(useOpencodeStore.getState().activeProject).toBeNull();
|
|
});
|
|
|
|
it('activates a workspace project only after its configuration validates', async () => {
|
|
const activeProject = {
|
|
id: 'prj_1',
|
|
path: 'D:/repo/packages/ui',
|
|
name: 'ui',
|
|
createdAt: '2026-05-12T00:00:00.000Z',
|
|
updatedAt: '2026-05-12T00:00:00.000Z',
|
|
lastOpenedAt: '2026-05-12T00:00:00.000Z',
|
|
};
|
|
const config = { ...createProjectConfig(), initialized: true };
|
|
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
|
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
|
|
if (path === '/api/opencode/config-summary') {
|
|
return {
|
|
model: 'openai/gpt-5.4',
|
|
smallModel: 'openai/gpt-5.4-mini',
|
|
providerIds: ['openai'],
|
|
providerCount: 1,
|
|
};
|
|
}
|
|
if (path === '/api/opencode/projects') return { projects: [activeProject], activeProject: null };
|
|
if (path === `/api/opencode/projects/config?projectId=${encodeURIComponent(activeProject.id)}`) {
|
|
return { status: 'valid', config, knowledgeFiles: [] };
|
|
}
|
|
if (path === '/api/opencode/projects/active' && init?.method === 'POST') {
|
|
return {
|
|
success: true,
|
|
project: activeProject,
|
|
projects: [activeProject],
|
|
activeProject,
|
|
};
|
|
}
|
|
throw new Error(`Unexpected path ${path}`);
|
|
});
|
|
|
|
renderSettings();
|
|
fireEvent.click(await screen.findByRole('button', { name: 'appearance.showMoreSettings' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: `Use project ${activeProject.name}` }));
|
|
|
|
await waitFor(() => {
|
|
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/projects/active', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ projectId: activeProject.id }),
|
|
});
|
|
});
|
|
expect(useOpencodeStore.getState().activeProject).toEqual(activeProject);
|
|
});
|
|
});
|