mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-16 00:14:37 +00:00
feat(cursor): harden live probe and runtime contracts
This commit is contained in:
1 parent
2eaf760d20
commit
e1049e38d4
23 files changed
+2186
-306
No files matched your search
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AllProviders } from '../../setup/test-utils';
|
||||
import { useCursor } from '@/hooks/use-cursor';
|
||||
|
||||
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <AllProviders>{children}</AllProviders>;
|
||||
|
||||
describe('useCursor', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('preserves structured probe failures and refreshes live status queries', async () => {
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.endsWith('/api/cursor/status')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/models')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
current: 'gpt-5.3-codex',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/settings/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') {
|
||||
return Promise.resolve(createJsonResponse(probeFailure, 503));
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { result } = renderHook(() => useCursor(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.status?.enabled).toBe(true));
|
||||
await waitFor(() => expect(result.current.models.length).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
const probeResult = await result.current.runProbeAsync();
|
||||
expect(probeResult).toMatchObject(probeFailure);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models'))
|
||||
.length
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCursor: vi.fn(),
|
||||
runProbeAsync: vi.fn(),
|
||||
resetProbe: vi.fn(),
|
||||
refetchStatus: vi.fn(),
|
||||
refetchRawSettings: vi.fn(),
|
||||
updateConfigAsync: vi.fn(),
|
||||
saveRawSettingsAsync: vi.fn(),
|
||||
autoDetectAuthAsync: vi.fn(),
|
||||
importManualAuthAsync: vi.fn(),
|
||||
startDaemonAsync: vi.fn(),
|
||||
stopDaemonAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cursor', () => ({
|
||||
useCursor: mocks.useCursor,
|
||||
}));
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: toastMocks,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/copilot/config-form/raw-editor-section', () => ({
|
||||
RawEditorSection: () => <div>Raw Editor</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/searchable-select', () => ({
|
||||
SearchableSelect: ({ value }: { value?: string }) => (
|
||||
<div data-testid="searchable-select">{value ?? 'select'}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CursorPage } from '@/pages/cursor';
|
||||
|
||||
const probeFailure = {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
status: 503,
|
||||
duration_ms: 321,
|
||||
error_type: 'daemon_not_running',
|
||||
model: 'gpt-5.3-codex',
|
||||
message: 'Cursor daemon is not running.',
|
||||
};
|
||||
|
||||
function buildUseCursorResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: {
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
auth_method: 'manual',
|
||||
token_age: 1,
|
||||
token_expired: false,
|
||||
daemon_running: false,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
},
|
||||
statusLoading: false,
|
||||
refetchStatus: mocks.refetchStatus,
|
||||
config: {
|
||||
enabled: true,
|
||||
port: 20129,
|
||||
auto_start: false,
|
||||
ghost_mode: true,
|
||||
model: 'gpt-5.3-codex',
|
||||
opus_model: 'gpt-5.1-codex-max',
|
||||
sonnet_model: 'gpt-5.3-codex',
|
||||
haiku_model: 'gpt-5-mini',
|
||||
},
|
||||
updateConfigAsync: mocks.updateConfigAsync,
|
||||
isUpdatingConfig: false,
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
modelsLoading: false,
|
||||
currentModel: 'gpt-5.3-codex',
|
||||
rawSettings: {
|
||||
settings: {},
|
||||
mtime: 100,
|
||||
path: '/tmp/cursor.settings.json',
|
||||
exists: true,
|
||||
},
|
||||
rawSettingsLoading: false,
|
||||
refetchRawSettings: mocks.refetchRawSettings,
|
||||
saveRawSettingsAsync: mocks.saveRawSettingsAsync,
|
||||
isSavingRawSettings: false,
|
||||
autoDetectAuthAsync: mocks.autoDetectAuthAsync,
|
||||
isAutoDetectingAuth: false,
|
||||
importManualAuthAsync: mocks.importManualAuthAsync,
|
||||
isImportingManualAuth: false,
|
||||
startDaemonAsync: mocks.startDaemonAsync,
|
||||
isStartingDaemon: false,
|
||||
stopDaemonAsync: mocks.stopDaemonAsync,
|
||||
isStoppingDaemon: false,
|
||||
runProbeAsync: mocks.runProbeAsync,
|
||||
isRunningProbe: false,
|
||||
probeResult: undefined,
|
||||
resetProbe: mocks.resetProbe,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CursorPage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.runProbeAsync.mockReset();
|
||||
mocks.resetProbe.mockReset();
|
||||
mocks.runProbeAsync.mockResolvedValue(probeFailure);
|
||||
mocks.useCursor.mockReturnValue(buildUseCursorResult());
|
||||
toastMocks.error.mockReset();
|
||||
toastMocks.success.mockReset();
|
||||
});
|
||||
|
||||
it('renders persistent live probe results in the sidebar', async () => {
|
||||
mocks.useCursor.mockReturnValue(
|
||||
buildUseCursorResult({
|
||||
probeResult: probeFailure,
|
||||
})
|
||||
);
|
||||
|
||||
render(<CursorPage />);
|
||||
|
||||
expect(screen.getByText('Live Probe')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('Probe failed')).toBeInTheDocument());
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
expect(screen.getByText('321 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cursor daemon is not running.')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-5.3-codex').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('runs the live probe from the sidebar action', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
|
||||
|
||||
await waitFor(() => expect(mocks.runProbeAsync).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('blocks live probe runs while local edits are unsaved', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
await userEvent.clear(screen.getByLabelText('Port'));
|
||||
await userEvent.type(screen.getByLabelText('Port'), '20130');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Run Live Probe' }));
|
||||
|
||||
expect(mocks.runProbeAsync).not.toHaveBeenCalled();
|
||||
expect(toastMocks.error).toHaveBeenCalledWith(
|
||||
'Save or discard Cursor changes before running the live probe'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user