mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(cursor): resolve PR review findings
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export const CURSOR_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
'probe',
|
||||
'models',
|
||||
'start',
|
||||
'stop',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type FrameResult,
|
||||
StreamingFrameParser,
|
||||
decompressPayload,
|
||||
mapCursorConnectError,
|
||||
} from './cursor-stream-parser.js';
|
||||
|
||||
/** Executor parameters */
|
||||
@@ -88,12 +89,12 @@ function toCursorErrorPayloadFromJson(jsonError: {
|
||||
jsonError?.error?.message ||
|
||||
'API Error';
|
||||
|
||||
const isRateLimit = jsonError?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(jsonError?.error?.code);
|
||||
|
||||
return {
|
||||
message: errorMsg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || 'unknown',
|
||||
};
|
||||
}
|
||||
@@ -650,6 +651,12 @@ export class CursorExecutor {
|
||||
|
||||
req.on('end', () => {
|
||||
if (streamClosed) return;
|
||||
for (const frame of parser.finish()) {
|
||||
if (frame.type === 'error') {
|
||||
handleFrameError(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolveStreamingResponse();
|
||||
if (chunkCount === 0 && toolCallCount === 0) {
|
||||
emitSSE(buildChunk({ role: 'assistant', content: '' }, null));
|
||||
@@ -757,14 +764,14 @@ export class CursorExecutor {
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const isRateLimit = json?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: msg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
code: isRateLimit ? 'rate_limited' : 'cursor_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
code: json?.error?.code || 'cursor_error',
|
||||
},
|
||||
};
|
||||
return;
|
||||
@@ -821,6 +828,18 @@ export class CursorExecutor {
|
||||
yield { type: 'thinking', text: result.thinking };
|
||||
}
|
||||
}
|
||||
|
||||
if (offset !== buffer.length) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: {
|
||||
message: 'Truncated Cursor ConnectRPC frame.',
|
||||
status: 502,
|
||||
errorType: 'server_error',
|
||||
code: 'cursor_protocol_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
transformProtobufToJSON(buffer: Buffer, model: string, _body: ExecutorParams['body']): Response {
|
||||
|
||||
@@ -144,6 +144,12 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
});
|
||||
|
||||
if (!startResult.success) {
|
||||
daemonRunning = await isDaemonRunning(config.port);
|
||||
} else {
|
||||
daemonRunning = true;
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'daemon',
|
||||
@@ -153,8 +159,6 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
error_type: 'daemon_start_failed',
|
||||
};
|
||||
}
|
||||
|
||||
daemonRunning = true;
|
||||
}
|
||||
|
||||
if (!daemonRunning) {
|
||||
|
||||
@@ -37,6 +37,34 @@ export class CursorConnectFrameError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function mapCursorConnectError(code?: string): { status: number; errorType: string } {
|
||||
switch (code?.toLowerCase()) {
|
||||
case 'resource_exhausted':
|
||||
return { status: 429, errorType: 'rate_limit_error' };
|
||||
case 'unauthenticated':
|
||||
return { status: 401, errorType: 'authentication_error' };
|
||||
case 'permission_denied':
|
||||
return { status: 403, errorType: 'permission_error' };
|
||||
case 'not_found':
|
||||
return { status: 404, errorType: 'api_error' };
|
||||
case 'already_exists':
|
||||
case 'aborted':
|
||||
return { status: 409, errorType: 'api_error' };
|
||||
case 'deadline_exceeded':
|
||||
return { status: 504, errorType: 'api_error' };
|
||||
case 'unimplemented':
|
||||
return { status: 501, errorType: 'api_error' };
|
||||
case 'unavailable':
|
||||
return { status: 503, errorType: 'api_error' };
|
||||
case 'invalid_argument':
|
||||
case 'failed_precondition':
|
||||
case 'out_of_range':
|
||||
return { status: 400, errorType: 'api_error' };
|
||||
default:
|
||||
return { status: 500, errorType: 'api_error' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatConnectFrameFlags(flags: number): string {
|
||||
return `0x${flags.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -59,6 +87,10 @@ function toFrameErrorResult(error: unknown): Extract<FrameResult, { type: 'error
|
||||
};
|
||||
}
|
||||
|
||||
function createTruncatedFrameError(): CursorConnectFrameError {
|
||||
return new CursorConnectFrameError('Truncated Cursor ConnectRPC frame.', 502, 'server_error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress payload if gzip-compressed.
|
||||
* Skips decompression for JSON error payloads.
|
||||
@@ -151,12 +183,12 @@ export class StreamingFrameParser {
|
||||
json?.error?.message;
|
||||
|
||||
if (msg) {
|
||||
const isRateLimit = json?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
@@ -176,12 +208,12 @@ export class StreamingFrameParser {
|
||||
json?.error?.details?.[0]?.debug?.details?.detail ||
|
||||
json?.error?.message ||
|
||||
'API Error';
|
||||
const isRateLimit = json?.error?.code === 'resource_exhausted';
|
||||
const mappedError = mapCursorConnectError(json?.error?.code);
|
||||
results.push({
|
||||
type: 'error',
|
||||
message: msg,
|
||||
status: isRateLimit ? 429 : 400,
|
||||
errorType: isRateLimit ? 'rate_limit_error' : 'api_error',
|
||||
status: mappedError.status,
|
||||
errorType: mappedError.errorType,
|
||||
});
|
||||
return results;
|
||||
}
|
||||
@@ -217,4 +249,13 @@ export class StreamingFrameParser {
|
||||
hasPartial(): boolean {
|
||||
return this.buffer.length > 0;
|
||||
}
|
||||
|
||||
finish(): FrameResult[] {
|
||||
if (this.buffer.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.buffer = Buffer.alloc(0);
|
||||
return [toFrameErrorResult(createTruncatedFrameError())];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,19 @@ interface DaemonStartPreconditionError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
function getPublicAutoDetectError(result: ReturnType<typeof autoDetectTokens>): string {
|
||||
switch (result.reason) {
|
||||
case 'db_not_found':
|
||||
return 'Cursor state database not found on this host.';
|
||||
case 'sqlite_unavailable':
|
||||
return 'Cursor state database was found, but sqlite3 is not available in PATH.';
|
||||
case 'db_query_failed':
|
||||
return 'Cursor state database was found, but CCS could not query it.';
|
||||
default:
|
||||
return result.error ?? 'Token not found';
|
||||
}
|
||||
}
|
||||
|
||||
export function getDaemonStartPreconditionError(
|
||||
input: DaemonStartPreconditionInput
|
||||
): DaemonStartPreconditionError | null {
|
||||
@@ -132,7 +145,7 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise<v
|
||||
? 500
|
||||
: 404;
|
||||
res.status(status).json({
|
||||
error: result.error ?? 'Token not found',
|
||||
error: getPublicAutoDetectError(result),
|
||||
reason: result.reason ?? null,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -85,6 +85,20 @@ describe('npm CLI', () => {
|
||||
assert(!output.includes("Profile '--verbose' not found"), 'Should not treat flags as profiles');
|
||||
}
|
||||
});
|
||||
|
||||
it('routes cursor probe through the cursor command handler', function() {
|
||||
try {
|
||||
execSync(`bun "${srcCcsPath}" cursor probe`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 3000,
|
||||
env: { ...process.env, CCS_HOME: testCcsHome }
|
||||
});
|
||||
} catch (e) {
|
||||
const output = e.stderr?.toString() || e.stdout?.toString() || '';
|
||||
assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup');
|
||||
assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Profile handling', () => {
|
||||
|
||||
@@ -846,7 +846,7 @@ describe('Request Encoding', () => {
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle malformed frame gracefully', () => {
|
||||
it('should reject malformed frame headers', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Incomplete frame header (only 3 bytes instead of 5)
|
||||
@@ -856,11 +856,13 @@ describe('Request Encoding', () => {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should return valid response even with malformed input
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
});
|
||||
|
||||
it('should handle truncated payload', () => {
|
||||
it('should reject truncated payloads', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
|
||||
// Frame header says payload is 100 bytes but only 5 bytes follow
|
||||
@@ -872,8 +874,10 @@ describe('Request Encoding', () => {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should handle gracefully
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.status).toBe(502);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('server_error');
|
||||
expect(body.error.message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
});
|
||||
|
||||
it('should handle multi-frame buffer', () => {
|
||||
@@ -1057,6 +1061,29 @@ describe('CursorExecutor', () => {
|
||||
expect(body.error.type).toBe('rate_limit_error');
|
||||
});
|
||||
|
||||
it('should map unavailable end-stream errors to 503', async () => {
|
||||
const unavailableFrame = buildFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'unavailable',
|
||||
message: 'upstream down',
|
||||
},
|
||||
})
|
||||
),
|
||||
0x02
|
||||
);
|
||||
|
||||
const result = executor.transformProtobufToJSON(unavailableFrame, 'gpt-4', {
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(503);
|
||||
const body = JSON.parse(await result.text());
|
||||
expect(body.error.type).toBe('api_error');
|
||||
expect(body.error.message).toContain('upstream down');
|
||||
});
|
||||
|
||||
it('should surface reasoning_content when thinking payload is present', async () => {
|
||||
const textContent = 'Final answer';
|
||||
const thinkingContent = 'Internal reasoning trail';
|
||||
@@ -1281,6 +1308,23 @@ describe('CursorExecutor', () => {
|
||||
expect(body).not.toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('emits an SSE error event when trailing bytes leave a truncated frame', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const combined = Buffer.concat([buildTextFrame('Partial success'), Buffer.from([0x00, 0x00, 0x00])]);
|
||||
|
||||
const result = executor.transformProtobufToSSE(combined, 'test-model', {
|
||||
messages: [],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
const body = await result.text();
|
||||
expect(body).toContain('Partial success');
|
||||
expect(body).toContain('event: error');
|
||||
expect(body).toContain('Truncated Cursor ConnectRPC frame');
|
||||
expect(body).not.toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('returns an explicit error for unknown ConnectRPC frame flags', async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const result = executor.transformProtobufToJSON(buildFrame(new Uint8Array([0x01]), 0x04), 'gpt-4', {
|
||||
@@ -1510,6 +1554,30 @@ describe('StreamingFrameParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should map unavailable end-stream errors to 503 in the parser', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const endStreamError = buildFrame(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'unavailable',
|
||||
message: 'upstream down',
|
||||
},
|
||||
})
|
||||
),
|
||||
0x02
|
||||
);
|
||||
|
||||
const results = parser.push(endStreamError);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(503);
|
||||
expect(results[0].errorType).toBe('api_error');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse thinking frames', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
const frame = buildThinkingFrame('Think step by step');
|
||||
@@ -1601,6 +1669,20 @@ describe('StreamingFrameParser', () => {
|
||||
parser2.push(emptyFrame);
|
||||
expect(parser2.hasPartial()).toBe(false);
|
||||
});
|
||||
|
||||
it('should surface truncated trailing bytes when the stream finishes', () => {
|
||||
const parser = new StreamingFrameParser();
|
||||
parser.push(Buffer.from([0x00, 0x00, 0x00]));
|
||||
|
||||
const results = parser.finish();
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].type).toBe('error');
|
||||
if (results[0].type === 'error') {
|
||||
expect(results[0].status).toBe(502);
|
||||
expect(results[0].message).toContain('Truncated Cursor ConnectRPC frame');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decompressPayload', () => {
|
||||
|
||||
@@ -284,6 +284,7 @@ describe('Cursor Routes Logic', () => {
|
||||
expect(typeof json.error).toBe('string');
|
||||
expect(json.error?.length).toBeGreaterThan(0);
|
||||
expect(json.reason).toBe('db_not_found');
|
||||
expect(json.error).not.toContain(isolatedHome);
|
||||
} finally {
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome;
|
||||
|
||||
@@ -269,6 +269,7 @@ export function useCursor() {
|
||||
|
||||
config: configQuery.data,
|
||||
configLoading: configQuery.isLoading,
|
||||
refetchConfig: configQuery.refetch,
|
||||
|
||||
models: modelsQuery.data?.models ?? [],
|
||||
currentModel: modelsQuery.data?.current ?? null,
|
||||
@@ -317,6 +318,7 @@ export function useCursor() {
|
||||
statusQuery.refetch,
|
||||
configQuery.data,
|
||||
configQuery.isLoading,
|
||||
configQuery.refetch,
|
||||
modelsQuery.data,
|
||||
modelsQuery.isLoading,
|
||||
rawSettingsQuery.data,
|
||||
|
||||
@@ -1340,6 +1340,8 @@ const resources = {
|
||||
probeSucceeded: 'Probe succeeded',
|
||||
probeFailed: 'Probe failed',
|
||||
probeSaveFirst: 'Save or discard Cursor changes before running the live probe',
|
||||
refreshStatus: 'Refresh Cursor status',
|
||||
refreshConfiguration: 'Refresh Cursor configuration',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Loading Droid diagnostics...',
|
||||
@@ -2688,6 +2690,8 @@ const resources = {
|
||||
probeSucceeded: '探测成功',
|
||||
probeFailed: '探测失败',
|
||||
probeSaveFirst: '请先保存或放弃 Cursor 更改,再运行实时探测',
|
||||
refreshStatus: '刷新 Cursor 状态',
|
||||
refreshConfiguration: '刷新 Cursor 配置',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: '加载 Droid 诊断中...',
|
||||
@@ -4130,6 +4134,8 @@ const resources = {
|
||||
probeSucceeded: 'Kiểm tra thành công',
|
||||
probeFailed: 'Kiểm tra thất bại',
|
||||
probeSaveFirst: 'Hãy lưu hoặc bỏ các thay đổi Cursor trước khi chạy kiểm tra trực tiếp',
|
||||
refreshStatus: 'Làm mới trạng thái Cursor',
|
||||
refreshConfiguration: 'Làm mới cấu hình Cursor',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Đang tải chẩn đoán Droid...',
|
||||
@@ -5580,6 +5586,8 @@ const resources = {
|
||||
probeSucceeded: 'プローブ成功',
|
||||
probeFailed: 'プローブ失敗',
|
||||
probeSaveFirst: 'ライブプローブを実行する前に Cursor の変更を保存するか破棄してください',
|
||||
refreshStatus: 'Cursor の状態を更新',
|
||||
refreshConfiguration: 'Cursor の設定を更新',
|
||||
},
|
||||
droidPage: {
|
||||
loadingDiagnostics: 'Droid診断を読み込み中...',
|
||||
|
||||
+22
-7
@@ -292,6 +292,7 @@ export function CursorPage() {
|
||||
statusLoading,
|
||||
refetchStatus,
|
||||
config,
|
||||
refetchConfig,
|
||||
updateConfigAsync,
|
||||
isUpdatingConfig,
|
||||
models,
|
||||
@@ -395,6 +396,11 @@ export function CursorPage() {
|
||||
setProbeSnapshotKey(null);
|
||||
};
|
||||
|
||||
const resetConfigDraft = (nextConfig = config) => {
|
||||
setConfigDraft(buildConfigDraft(nextConfig));
|
||||
setConfigDirty(false);
|
||||
};
|
||||
|
||||
const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired);
|
||||
const integrationBadge = useMemo(
|
||||
() =>
|
||||
@@ -699,11 +705,15 @@ export function CursorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeaderRefresh = () => {
|
||||
const handleHeaderRefresh = async () => {
|
||||
setRawConfigDirty(false);
|
||||
clearProbeState();
|
||||
refetchStatus();
|
||||
refetchRawSettings();
|
||||
const [, refreshedConfig] = await Promise.all([
|
||||
refetchStatus(),
|
||||
refetchConfig(),
|
||||
refetchRawSettings(),
|
||||
]);
|
||||
resetConfigDraft(refreshedConfig.data ?? config);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -733,6 +743,8 @@ export function CursorPage() {
|
||||
className="h-8 w-8"
|
||||
onClick={() => refetchStatus()}
|
||||
disabled={statusLoading}
|
||||
aria-label={t('cursorPage.refreshStatus')}
|
||||
title={t('cursorPage.refreshStatus')}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
@@ -794,11 +806,12 @@ export function CursorPage() {
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={probeResult ? 'outline' : 'secondary'}
|
||||
variant={visibleProbeResult ? 'outline' : 'secondary'}
|
||||
className={cn(
|
||||
probeResult?.ok && 'border-green-500/40 text-green-600 dark:text-green-300',
|
||||
probeResult &&
|
||||
!probeResult.ok &&
|
||||
visibleProbeResult?.ok &&
|
||||
'border-green-500/40 text-green-600 dark:text-green-300',
|
||||
visibleProbeResult &&
|
||||
!visibleProbeResult.ok &&
|
||||
'border-red-500/40 text-red-600 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
@@ -993,6 +1006,8 @@ export function CursorPage() {
|
||||
size="sm"
|
||||
onClick={handleHeaderRefresh}
|
||||
disabled={statusLoading || rawSettingsLoading}
|
||||
aria-label={t('cursorPage.refreshConfiguration')}
|
||||
title={t('cursorPage.refreshConfiguration')}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runProbeAsync: vi.fn(),
|
||||
resetProbe: vi.fn(),
|
||||
refetchStatus: vi.fn(),
|
||||
refetchConfig: vi.fn(),
|
||||
refetchRawSettings: vi.fn(),
|
||||
updateConfigAsync: vi.fn(),
|
||||
saveRawSettingsAsync: vi.fn(),
|
||||
@@ -75,6 +76,7 @@ function buildUseCursorResult(overrides: Record<string, unknown> = {}) {
|
||||
sonnet_model: 'gpt-5.3-codex',
|
||||
haiku_model: 'gpt-5-mini',
|
||||
},
|
||||
refetchConfig: mocks.refetchConfig,
|
||||
updateConfigAsync: mocks.updateConfigAsync,
|
||||
isUpdatingConfig: false,
|
||||
models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }],
|
||||
@@ -110,6 +112,13 @@ describe('CursorPage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.runProbeAsync.mockReset();
|
||||
mocks.resetProbe.mockReset();
|
||||
mocks.refetchConfig.mockReset();
|
||||
mocks.refetchConfig.mockResolvedValue({
|
||||
status: 'success',
|
||||
isError: false,
|
||||
error: null,
|
||||
data: buildUseCursorResult().config,
|
||||
});
|
||||
mocks.runProbeAsync.mockResolvedValue(probeFailure);
|
||||
mocks.useCursor.mockReturnValue(buildUseCursorResult());
|
||||
toastMocks.error.mockReset();
|
||||
@@ -154,4 +163,22 @@ describe('CursorPage', () => {
|
||||
'Save or discard Cursor changes before running the live probe'
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes config state with accessible refresh controls', async () => {
|
||||
render(<CursorPage />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Refresh Cursor status' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Refresh Cursor configuration' })
|
||||
).toBeInTheDocument();
|
||||
|
||||
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: 'Refresh Cursor configuration' }));
|
||||
|
||||
await waitFor(() => expect(mocks.refetchConfig).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByLabelText('Port')).toHaveValue(20129);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user