# 🚀 Sync Gemini CLI v0.2.1 - Major Feature Update (#483)

This commit is contained in:
tanzhenxin
2025-09-01 14:48:55 +08:00
committed by GitHub
parent 1610c1586e
commit 2572faf726
292 changed files with 19401 additions and 5941 deletions

View File

@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { render } from 'ink-testing-library';
import { renderWithProviders } from '../test-utils/render.js';
import { AppWrapper as App } from './App.js';
import {
Config as ServerConfig,
@@ -162,7 +162,11 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
getDetectedIdeDisplayName: vi.fn(() => 'VSCode'),
addStatusChangeListener: vi.fn(),
removeStatusChangeListener: vi.fn(),
getConnectionStatus: vi.fn(() => 'connected'),
})),
isTrustedFolder: vi.fn(() => true),
};
});
@@ -354,7 +358,7 @@ describe('App UI', () => {
});
afterEach(() => {
delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER;
delete process.env['GEMINI_CLI_DISABLE_AUTOUPDATER'];
});
it('should not start the update process when running from git', async () => {
@@ -370,7 +374,7 @@ describe('App UI', () => {
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -396,7 +400,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -426,7 +430,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -456,7 +460,7 @@ describe('App UI', () => {
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -478,7 +482,7 @@ describe('App UI', () => {
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
mockedIsGitRepository.mockResolvedValue(false);
process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true';
process.env['GEMINI_CLI_DISABLE_AUTOUPDATER'] = 'true';
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
@@ -490,7 +494,7 @@ describe('App UI', () => {
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -519,7 +523,7 @@ describe('App UI', () => {
},
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -528,7 +532,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('1 open file (ctrl+e to view)');
expect(lastFrame()).toContain('1 open file (ctrl+g to view)');
});
it('should not display any files when not available', async () => {
@@ -538,7 +542,7 @@ describe('App UI', () => {
},
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -574,7 +578,7 @@ describe('App UI', () => {
},
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -583,7 +587,7 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('3 open files (ctrl+e to view)');
expect(lastFrame()).toContain('3 open files (ctrl+g to view)');
});
it('should display active file and other context', async () => {
@@ -602,7 +606,7 @@ describe('App UI', () => {
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['QWEN.md']);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -612,7 +616,7 @@ describe('App UI', () => {
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain(
'Using: 1 open file (ctrl+e to view) | 1 QWEN.md file',
'Using: 1 open file (ctrl+g to view) | 1 QWEN.md file',
);
});
@@ -623,7 +627,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -641,7 +645,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -662,7 +666,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -689,7 +693,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -714,7 +718,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -735,7 +739,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -756,7 +760,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -778,7 +782,7 @@ describe('App UI', () => {
mockConfig.getDebugMode.mockReturnValue(false);
mockConfig.getShowMemoryUsage.mockReturnValue(false);
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -791,7 +795,7 @@ describe('App UI', () => {
});
it('should display Tips component by default', async () => {
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -810,7 +814,7 @@ describe('App UI', () => {
},
});
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -824,7 +828,7 @@ describe('App UI', () => {
it('should display Header component by default', async () => {
const { Header } = await import('./components/Header.js');
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -842,7 +846,7 @@ describe('App UI', () => {
user: { hideBanner: true },
});
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -854,6 +858,58 @@ describe('App UI', () => {
expect(vi.mocked(Header)).not.toHaveBeenCalled();
});
it('should display Footer component by default', async () => {
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
// Footer should render - look for target directory which is always shown
expect(lastFrame()).toContain('/test/dir');
});
it('should not display Footer component when hideFooter is true', async () => {
mockSettings = createMockSettings({
user: { hideFooter: true },
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
// Footer should not render - target directory should not appear
expect(lastFrame()).not.toContain('/test/dir');
});
it('should show footer if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideFooter: false },
user: { hideFooter: true },
workspace: { hideFooter: true },
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
// Footer should render because system overrides - look for target directory
expect(lastFrame()).toContain('/test/dir');
});
it('should show tips if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideTips: false },
@@ -861,7 +917,7 @@ describe('App UI', () => {
workspace: { hideTips: true },
});
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -877,7 +933,7 @@ describe('App UI', () => {
let originalNoColor: string | undefined;
beforeEach(() => {
originalNoColor = process.env.NO_COLOR;
originalNoColor = process.env['NO_COLOR'];
// Ensure no theme is set for these tests
mockSettings = createMockSettings({});
mockConfig.getDebugMode.mockReturnValue(false);
@@ -885,13 +941,13 @@ describe('App UI', () => {
});
afterEach(() => {
process.env.NO_COLOR = originalNoColor;
process.env['NO_COLOR'] = originalNoColor;
});
it('should display theme dialog if NO_COLOR is not set', async () => {
delete process.env.NO_COLOR;
delete process.env['NO_COLOR'];
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -904,9 +960,9 @@ describe('App UI', () => {
});
it('should display a message if NO_COLOR is set', async () => {
process.env.NO_COLOR = 'true';
process.env['NO_COLOR'] = 'true';
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -921,7 +977,7 @@ describe('App UI', () => {
});
it('should render the initial UI correctly', () => {
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -941,7 +997,7 @@ describe('App UI', () => {
thought: null,
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -971,7 +1027,7 @@ describe('App UI', () => {
getUserTier: vi.fn(),
} as unknown as GeminiClient);
const { unmount, rerender } = render(
const { unmount, rerender } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1013,7 +1069,7 @@ describe('App UI', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1039,7 +1095,7 @@ describe('App UI', () => {
},
});
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1061,7 +1117,7 @@ describe('App UI', () => {
},
});
const { unmount } = render(
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1081,7 +1137,7 @@ describe('App UI', () => {
rows: 24,
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1093,6 +1149,34 @@ describe('App UI', () => {
});
});
describe('NO_COLOR smoke test', () => {
let originalNoColor: string | undefined;
beforeEach(() => {
originalNoColor = process.env['NO_COLOR'];
});
afterEach(() => {
process.env['NO_COLOR'] = originalNoColor;
});
it('should render without errors when NO_COLOR is set', async () => {
process.env['NO_COLOR'] = 'true';
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toBeTruthy();
expect(lastFrame()).toContain('Type your message or @path/to/file');
});
});
describe('FolderTrustDialog', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
@@ -1101,7 +1185,7 @@ describe('App UI', () => {
handleFolderTrustSelect: vi.fn(),
});
const { lastFrame, unmount } = render(
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
@@ -1112,5 +1196,301 @@ describe('App UI', () => {
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
});
mockConfig.isTrustedFolder.mockReturnValue(false);
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
it('should not display the folder trust dialog when the feature is disabled', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
});
mockConfig.isTrustedFolder.mockReturnValue(false);
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).not.toContain('Do you trust this folder?');
});
});
describe('Message Queuing', () => {
let mockSubmitQuery: typeof vi.fn;
beforeEach(() => {
mockSubmitQuery = vi.fn();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should queue messages when handleFinalSubmit is called during streaming', () => {
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
});
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// The message should not be sent immediately during streaming
expect(mockSubmitQuery).not.toHaveBeenCalled();
});
it('should auto-send queued messages when transitioning from Responding to Idle', async () => {
const mockSubmitQueryFn = vi.fn();
// Start with Responding state
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQueryFn,
initError: null,
pendingHistoryItems: [],
thought: null,
});
const { unmount, rerender } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// Simulate the hook returning Idle state (streaming completed)
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQueryFn,
initError: null,
pendingHistoryItems: [],
thought: null,
});
// Rerender to trigger the useEffect with new state
rerender(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
// The effect uses setTimeout(100ms) before sending
await vi.advanceTimersByTimeAsync(100);
// Note: In the actual implementation, messages would be queued first
// This test verifies the auto-send mechanism works when state transitions
});
it('should display queued messages with dimmed color', () => {
// This test would require being able to simulate handleFinalSubmit
// and then checking the rendered output for the queued messages
// with the ▸ prefix and dimColor styling
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
});
const { unmount, lastFrame } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// The actual queued messages display is tested visually
// since we need to trigger handleFinalSubmit which is internal
const output = lastFrame();
expect(output).toBeDefined();
});
it('should clear message queue after sending', async () => {
const mockSubmitQueryFn = vi.fn();
// Start with idle to allow message queue to process
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQueryFn,
initError: null,
pendingHistoryItems: [],
thought: null,
});
const { unmount, lastFrame } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// After sending, the queue should be cleared
// This is handled internally by setMessageQueue([]) in the useEffect
await vi.advanceTimersByTimeAsync(100);
// Verify the component renders without errors
expect(lastFrame()).toBeDefined();
});
it('should handle empty messages by filtering them out', () => {
// The handleFinalSubmit function trims and checks if length > 0
// before adding to queue, so empty messages are filtered
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
});
const { unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// Empty or whitespace-only messages won't be added to queue
// This is enforced by the trimmedValue.length > 0 check
expect(mockSubmitQuery).not.toHaveBeenCalled();
});
it('should combine multiple queued messages with double newlines', async () => {
// This test verifies that when multiple messages are queued,
// they are combined with '\n\n' as the separator
const mockSubmitQueryFn = vi.fn();
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQueryFn,
initError: null,
pendingHistoryItems: [],
thought: null,
});
const { unmount, lastFrame } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// The combining logic uses messageQueue.join('\n\n')
// This is tested by the implementation in the useEffect
await vi.advanceTimersByTimeAsync(100);
expect(lastFrame()).toBeDefined();
});
it('should limit displayed messages to MAX_DISPLAYED_QUEUED_MESSAGES', () => {
// This test verifies the display logic handles multiple messages correctly
// by checking that the MAX_DISPLAYED_QUEUED_MESSAGES constant is respected
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
// Verify the display logic exists and can handle multiple messages
// The actual queue behavior is tested in the useMessageQueue hook tests
expect(output).toBeDefined();
// Check that the component renders without errors when there are messages to display
expect(output).not.toContain('Error');
});
it('should render message queue display without errors', () => {
// Test that the message queue display logic renders correctly
// This verifies the UI changes for performance improvements work
vi.mocked(useGeminiStream).mockReturnValue({
streamingState: StreamingState.Responding,
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
// Verify component renders without errors
expect(output).toBeDefined();
expect(output).not.toContain('Error');
// Verify the component structure is intact (loading indicator should be present)
expect(output).toContain('esc to cancel');
});
});
});