Files
makelore/tests/unit/settings-opencode.test.tsx
2026-07-29 17:22:35 +08:00

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('standard-development'), 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);
});
});