Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -10,9 +10,135 @@ import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useSlashCompletion } from './useSlashCompletion.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import { useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
// Test utility type and helper function for creating test SlashCommands
type TestSlashCommand = Omit<SlashCommand, 'kind'> &
Partial<Pick<SlashCommand, 'kind'>>;
function createTestCommand(command: TestSlashCommand): SlashCommand {
return {
kind: CommandKind.BUILT_IN, // default for tests
...command,
};
}
// Track AsyncFzf constructor calls for cache testing
let asyncFzfConstructorCalls = 0;
const resetConstructorCallCount = () => {
asyncFzfConstructorCalls = 0;
};
const getConstructorCallCount = () => asyncFzfConstructorCalls;
// Centralized fuzzy matching simulation logic
// Note: This is a simplified reimplementation that may diverge from real fzf behavior.
// Integration tests in useSlashCompletion.integration.test.ts use the real fzf library
// to catch any behavioral differences and serve as our "canary in a coal mine."
function simulateFuzzyMatching(items: readonly string[], query: string) {
const results = [];
if (query) {
const lowerQuery = query.toLowerCase();
for (const item of items) {
const lowerItem = item.toLowerCase();
// Exact match gets highest score
if (lowerItem === lowerQuery) {
results.push({
item,
positions: [],
score: 100,
start: 0,
end: item.length,
});
continue;
}
// Prefix match gets high score
if (lowerItem.startsWith(lowerQuery)) {
results.push({
item,
positions: [],
score: 80,
start: 0,
end: query.length,
});
continue;
}
// Fuzzy matching: check if query chars appear in order
let queryIndex = 0;
let score = 0;
for (
let i = 0;
i < lowerItem.length && queryIndex < lowerQuery.length;
i++
) {
if (lowerItem[i] === lowerQuery[queryIndex]) {
queryIndex++;
score += 10 - i; // Earlier matches get higher scores
}
}
// If all query characters were found in order, include this item
if (queryIndex === lowerQuery.length) {
results.push({
item,
positions: [],
score,
start: 0,
end: query.length,
});
}
}
}
// Sort by score descending (better matches first)
results.sort((a, b) => b.score - a.score);
return Promise.resolve(results);
}
// Mock the fzf module to provide a working fuzzy search implementation for tests
vi.mock('fzf', async () => {
const actual = await vi.importActual<typeof import('fzf')>('fzf');
return {
...actual,
AsyncFzf: vi.fn().mockImplementation((items, _options) => {
asyncFzfConstructorCalls++;
return {
find: vi
.fn()
.mockImplementation((query: string) =>
simulateFuzzyMatching(items, query),
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}),
};
});
// Default mock behavior helper - now uses centralized logic
const createDefaultAsyncFzfMock =
() => (items: readonly string[], _options: unknown) => {
asyncFzfConstructorCalls++;
return {
find: vi
.fn()
.mockImplementation((query: string) =>
simulateFuzzyMatching(items, query),
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
// Export test utilities
export {
resetConstructorCallCount,
getConstructorCallCount,
createDefaultAsyncFzfMock,
};
// Test harness to capture the state from the hook's callbacks.
function useTestHarnessForSlashCompletion(
enabled: boolean,
@@ -50,20 +176,26 @@ describe('useSlashCompletion', () => {
describe('Top-Level Commands', () => {
it('should suggest all top-level commands for the root slash', async () => {
const slashCommands = [
{ name: 'help', altNames: ['?'], description: 'Show help' },
{
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
}),
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
{ name: 'clear', description: 'Clear the screen' },
{
}),
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [{ name: 'show', description: 'Show memory' }],
},
{ name: 'chat', description: 'Manage chat history' },
] as unknown as SlashCommand[];
subCommands: [
createTestCommand({ name: 'show', description: 'Show memory' }),
],
}),
createTestCommand({ name: 'chat', description: 'Manage chat history' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -81,8 +213,8 @@ describe('useSlashCompletion', () => {
it('should filter commands based on partial input', async () => {
const slashCommands = [
{ name: 'memory', description: 'Manage memory' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'memory', description: 'Manage memory' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -92,19 +224,26 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'memory',
value: 'memory',
description: 'Manage memory',
commandKind: CommandKind.BUILT_IN,
},
]);
});
});
it('should suggest commands based on partial altNames', async () => {
const slashCommands = [
{
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -114,19 +253,26 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{
label: 'stats',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
},
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'stats',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
commandKind: CommandKind.BUILT_IN,
},
]);
});
});
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen', action: vi.fn() },
] as unknown as SlashCommand[];
createTestCommand({
name: 'clear',
description: 'Clear the screen',
action: vi.fn(),
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -143,19 +289,19 @@ describe('useSlashCompletion', () => {
'should not suggest commands when altNames is fully typed',
async (query) => {
const mockSlashCommands = [
{
createTestCommand({
name: 'help',
altNames: ['?'],
description: 'Show help',
action: vi.fn(),
},
{
}),
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
action: vi.fn(),
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -172,8 +318,8 @@ describe('useSlashCompletion', () => {
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -188,8 +334,8 @@ describe('useSlashCompletion', () => {
it('should not provide suggestions for an unknown command', async () => {
const slashCommands = [
{ name: 'help', description: 'Show help' },
] as unknown as SlashCommand[];
createTestCommand({ name: 'help', description: 'Show help' }),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -201,20 +347,45 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions).toHaveLength(0);
});
it('should not suggest hidden commands', async () => {
const slashCommands = [
createTestCommand({
name: 'visible',
description: 'A visible command',
}),
createTestCommand({
name: 'hidden',
description: 'A hidden command',
hidden: true,
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions.length).toBe(1);
expect(result.current.suggestions[0].label).toBe('visible');
});
});
describe('Sub-Commands', () => {
it('should suggest sub-commands for a parent command', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -228,23 +399,33 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{ label: 'show', value: 'show', description: 'Show memory' },
{ label: 'add', value: 'add', description: 'Add to memory' },
{
label: 'show',
value: 'show',
description: 'Show memory',
commandKind: CommandKind.BUILT_IN,
},
{
label: 'add',
value: 'add',
description: 'Add to memory',
commandKind: CommandKind.BUILT_IN,
},
]),
);
});
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -257,23 +438,33 @@ describe('useSlashCompletion', () => {
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{ label: 'show', value: 'show', description: 'Show memory' },
{ label: 'add', value: 'add', description: 'Add to memory' },
{
label: 'show',
value: 'show',
description: 'Show memory',
commandKind: CommandKind.BUILT_IN,
},
{
label: 'add',
value: 'add',
description: 'Add to memory',
commandKind: CommandKind.BUILT_IN,
},
]),
);
});
it('should filter sub-commands by prefix', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -283,22 +474,29 @@ describe('useSlashCompletion', () => {
),
);
expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' },
]);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'add',
value: 'add',
description: 'Add to memory',
commandKind: CommandKind.BUILT_IN,
},
]);
});
});
it('should provide no suggestions for an invalid sub-command', async () => {
const slashCommands = [
{
createTestCommand({
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
createTestCommand({ name: 'show', description: 'Show memory' }),
createTestCommand({ name: 'add', description: 'Add to memory' }),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
@@ -327,18 +525,18 @@ describe('useSlashCompletion', () => {
);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -351,7 +549,13 @@ describe('useSlashCompletion', () => {
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
mockCommandContext,
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
},
}),
'my-ch',
);
});
@@ -370,18 +574,18 @@ describe('useSlashCompletion', () => {
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -393,7 +597,16 @@ describe('useSlashCompletion', () => {
);
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume',
name: 'resume',
args: '',
},
}),
'',
);
});
await waitFor(() => {
@@ -404,18 +617,18 @@ describe('useSlashCompletion', () => {
it('should handle completion function that returns null', async () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
{
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
createTestCommand({
name: 'resume',
description: 'Resume a saved chat',
completion: completionFn,
},
}),
],
},
] as unknown as SlashCommand[];
}),
];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
@@ -431,4 +644,210 @@ describe('useSlashCompletion', () => {
});
});
});
describe('Command Kind Information', () => {
it('should include commandKind for MCP commands in suggestions', async () => {
const slashCommands = [
{
name: 'summarize',
description: 'Summarize content',
kind: CommandKind.MCP_PROMPT,
action: vi.fn(),
},
{
name: 'help',
description: 'Show help',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
] as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{
label: 'summarize',
value: 'summarize',
description: 'Summarize content',
commandKind: CommandKind.MCP_PROMPT,
},
{
label: 'help',
value: 'help',
description: 'Show help',
commandKind: CommandKind.BUILT_IN,
},
]),
);
});
it('should include commandKind when filtering MCP commands by prefix', async () => {
const slashCommands = [
{
name: 'summarize',
description: 'Summarize content',
kind: CommandKind.MCP_PROMPT,
action: vi.fn(),
},
{
name: 'settings',
description: 'Open settings',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
] as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/summ',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'summarize',
value: 'summarize',
description: 'Summarize content',
commandKind: CommandKind.MCP_PROMPT,
},
]);
});
});
it('should include commandKind for sub-commands', async () => {
const slashCommands = [
{
name: 'memory',
description: 'Manage memory',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'show',
description: 'Show memory',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
{
name: 'add',
description: 'Add to memory',
kind: CommandKind.MCP_PROMPT,
action: vi.fn(),
},
],
},
] as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{
label: 'show',
value: 'show',
description: 'Show memory',
commandKind: CommandKind.BUILT_IN,
},
{
label: 'add',
value: 'add',
description: 'Add to memory',
commandKind: CommandKind.MCP_PROMPT,
},
]),
);
});
it('should include commandKind for file commands', async () => {
const slashCommands = [
{
name: 'custom-script',
description: 'Run custom script',
kind: CommandKind.FILE,
action: vi.fn(),
},
] as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/custom',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
label: 'custom-script',
value: 'custom-script',
description: 'Run custom script',
commandKind: CommandKind.FILE,
},
]);
});
});
});
it('should not call shared callbacks when disabled', () => {
const mockSetSuggestions = vi.fn();
const mockSetIsLoadingSuggestions = vi.fn();
const mockSetIsPerfectMatch = vi.fn();
const slashCommands = [
createTestCommand({
name: 'help',
description: 'Show help',
}),
];
const { rerender } = renderHook(
({ enabled, query }) =>
useSlashCompletion({
enabled,
query,
slashCommands,
commandContext: mockCommandContext,
setSuggestions: mockSetSuggestions,
setIsLoadingSuggestions: mockSetIsLoadingSuggestions,
setIsPerfectMatch: mockSetIsPerfectMatch,
}),
{
initialProps: { enabled: false, query: '@src/file' },
},
);
// Clear any initial calls
mockSetSuggestions.mockClear();
mockSetIsLoadingSuggestions.mockClear();
mockSetIsPerfectMatch.mockClear();
// Change query while disabled (simulating @ completion typing)
rerender({ enabled: false, query: '@src/file.ts' });
rerender({ enabled: false, query: '@src/file.tsx' });
// Should not have called shared callbacks during @ completion typing
expect(mockSetSuggestions).not.toHaveBeenCalled();
expect(mockSetIsLoadingSuggestions).not.toHaveBeenCalled();
expect(mockSetIsPerfectMatch).not.toHaveBeenCalled();
});
});