fix(cli): enable typechecking for ui/components tests (#11419)

Co-authored-by: Jacob MacDonald <jakemac@google.com>
This commit is contained in:
Sandy Tao
2025-10-17 16:16:12 -07:00
committed by GitHub
parent f4330c9f5e
commit cedf0235a1
12 changed files with 377 additions and 239 deletions

View File

@@ -31,14 +31,14 @@ describe('<ContextSummaryDisplay />', () => {
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c' }],
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
it('should render on a single line on a wide screen', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
const output = lastFrame()!;
expect(output).toContain(
'Using: 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server',
);
@@ -48,7 +48,7 @@ describe('<ContextSummaryDisplay />', () => {
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const output = lastFrame()!;
const expectedLines = [
' Using:',
' - 1 open file (ctrl+g to view)',
@@ -62,12 +62,12 @@ describe('<ContextSummaryDisplay />', () => {
it('should switch layout at the 80-column breakpoint', () => {
// At 80 columns, should be on one line
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
expect(wideFrame().includes('\n')).toBe(false);
expect(wideFrame()!.includes('\n')).toBe(false);
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
expect(narrowFrame().includes('\n')).toBe(true);
expect(narrowFrame().split('\n').length).toBe(4);
expect(narrowFrame()!.includes('\n')).toBe(true);
expect(narrowFrame()!.split('\n').length).toBe(4);
});
it('should not render empty parts', () => {
@@ -79,7 +79,7 @@ describe('<ContextSummaryDisplay />', () => {
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = [' Using:', ' - 1 open file (ctrl+g to view)'];
const actualLines = lastFrame().split('\n');
const actualLines = lastFrame()!.split('\n');
expect(actualLines).toEqual(expectedLines);
});
});

View File

@@ -9,7 +9,8 @@ import {
createMockSettings,
} from '../../test-utils/render.js';
import { Footer } from './Footer.js';
import { tildeifyPath } from '@google/gemini-cli-core';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
@@ -32,15 +33,41 @@ const defaultProps = {
branchName: 'main',
};
const sessionStats = {
sessionStats: { lastPromptTokenCount: 0, lastResponseTokenCount: 0 },
const mockSessionStats: SessionStatsState = {
sessionId: 'test-session',
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
promptCount: 0,
metrics: {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
},
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: defaultProps.branchName, ...sessionStats },
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
});
expect(lastFrame()).toBeDefined();
});
@@ -49,7 +76,7 @@ describe('<Footer />', () => {
it('should display a shortened path on a narrow terminal', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
const tildePath = tildeifyPath(defaultProps.targetDir);
const pathLength = Math.max(20, Math.floor(79 * 0.25));
@@ -61,7 +88,7 @@ describe('<Footer />', () => {
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 80,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath =
@@ -73,7 +100,10 @@ describe('<Footer />', () => {
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: defaultProps.branchName, ...sessionStats },
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
});
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
@@ -81,7 +111,7 @@ describe('<Footer />', () => {
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { branchName: undefined, ...sessionStats },
uiState: { branchName: undefined, sessionStats: mockSessionStats },
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
@@ -89,7 +119,7 @@ describe('<Footer />', () => {
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
@@ -98,7 +128,7 @@ describe('<Footer />', () => {
it('displays the model name and abbreviated context percentage', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 99,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
@@ -108,7 +138,7 @@ describe('<Footer />', () => {
it('should display untrusted when isTrustedFolder is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, ...sessionStats },
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
});
@@ -117,7 +147,7 @@ describe('<Footer />', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: undefined, ...sessionStats },
uiState: { isTrustedFolder: undefined, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
@@ -128,7 +158,7 @@ describe('<Footer />', () => {
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, ...sessionStats },
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
@@ -139,7 +169,7 @@ describe('<Footer />', () => {
vi.stubEnv('SANDBOX', '');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: true, ...sessionStats },
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
@@ -149,7 +179,7 @@ describe('<Footer />', () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { isTrustedFolder: false, ...sessionStats },
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
@@ -161,7 +191,7 @@ describe('<Footer />', () => {
it('renders complete footer with all sections visible (baseline)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
});
@@ -169,7 +199,7 @@ describe('<Footer />', () => {
it('renders footer with all optional sections hidden (minimal footer)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
@@ -186,7 +216,7 @@ describe('<Footer />', () => {
it('renders footer with only model info hidden (partial filtering)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
@@ -203,7 +233,7 @@ describe('<Footer />', () => {
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
@@ -220,7 +250,7 @@ describe('<Footer />', () => {
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
uiState: { ...sessionStats },
uiState: { sessionStats: mockSessionStats },
});
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
});

View File

@@ -172,6 +172,9 @@ describe('InputPrompt', () => {
text: '',
accept: vi.fn(),
clear: vi.fn(),
isLoading: false,
isActive: false,
markSelected: vi.fn(),
},
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -200,6 +203,8 @@ describe('InputPrompt', () => {
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false,
checking: false,
});
props = {
@@ -223,6 +228,8 @@ describe('InputPrompt', () => {
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
};
});
@@ -1553,7 +1560,11 @@ describe('InputPrompt', () => {
describe('paste auto-submission protection', () => {
beforeEach(() => {
vi.useFakeTimers();
mockedUseKittyKeyboardProtocol.mockReturnValue({ supported: false });
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: false,
enabled: false,
checking: false,
});
});
afterEach(() => {
@@ -1618,7 +1629,11 @@ describe('InputPrompt', () => {
{
name: 'kitty',
setup: () =>
mockedUseKittyKeyboardProtocol.mockReturnValue({ supported: true }),
mockedUseKittyKeyboardProtocol.mockReturnValue({
supported: true,
enabled: true,
checking: false,
}),
},
])(
'should allow immediate submission for a trusted paste ($name)',

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -24,6 +25,7 @@ const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -59,9 +61,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
expect(lastFrame()).toContain(
@@ -90,9 +101,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
@@ -133,9 +153,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
@@ -176,9 +205,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
@@ -211,9 +249,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
expect(lastFrame()).toMatchSnapshot();
@@ -239,9 +286,18 @@ describe('<ModelStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
@@ -23,6 +24,7 @@ const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -57,7 +59,12 @@ describe('<SessionSummaryDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -38,23 +39,34 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
return render(<StatsDisplay duration="1s" />);
};
// Helper to create metrics with default zero values
const createTestMetrics = (
overrides: Partial<SessionMetrics> = {},
): SessionMetrics => ({
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
...overrides,
});
describe('<StatsDisplay />', () => {
it('renders only the Performance section in its zero state', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const zeroMetrics = createTestMetrics();
const { lastFrame } = renderWithMockedStats(zeroMetrics);
const output = lastFrame();
@@ -67,7 +79,7 @@ describe('<StatsDisplay />', () => {
});
it('renders a table with two models correctly', () => {
const metrics: SessionMetrics = {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
@@ -92,19 +104,7 @@ describe('<StatsDisplay />', () => {
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -117,7 +117,7 @@ describe('<StatsDisplay />', () => {
});
it('renders all sections when all data is present', () => {
const metrics: SessionMetrics = {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -136,22 +136,28 @@ describe('<StatsDisplay />', () => {
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
totalDecisions: {
accept: 1,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 1, reject: 0, modify: 0 },
decisions: {
accept: 1,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -166,29 +172,34 @@ describe('<StatsDisplay />', () => {
describe('Conditional Rendering Tests', () => {
it('hides User Agreement when no decisions are made', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 2,
totalSuccess: 1,
totalFail: 1,
totalDurationMs: 123,
totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
}, // No decisions
byName: {
'test-tool': {
count: 2,
success: 1,
fail: 1,
durationMs: 123,
decisions: { accept: 0, reject: 0, modify: 0 },
decisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -200,7 +211,7 @@ describe('<StatsDisplay />', () => {
});
it('hides Efficiency section when cache is not used', () => {
const metrics: SessionMetrics = {
const metrics = createTestMetrics({
models: {
'gemini-2.5-pro': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
@@ -214,19 +225,7 @@ describe('<StatsDisplay />', () => {
},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -238,61 +237,61 @@ describe('<StatsDisplay />', () => {
describe('Conditional Color Tests', () => {
it('renders success rate in green for high values', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
totalSuccess: 10,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in yellow for medium values', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
totalSuccess: 9,
totalFail: 1,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
it('renders success rate in red for low values', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 10,
totalSuccess: 5,
totalFail: 5,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
expect(lastFrame()).toMatchSnapshot();
});
@@ -300,21 +299,25 @@ describe('<StatsDisplay />', () => {
describe('Code Changes Display', () => {
it('displays Code Changes when line counts are present', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 42,
totalLinesRemoved: 18,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -326,21 +329,21 @@ describe('<StatsDisplay />', () => {
});
it('hides Code Changes when no lines are added or removed', () => {
const metrics: SessionMetrics = {
models: {},
const metrics = createTestMetrics({
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
});
const { lastFrame } = renderWithMockedStats(metrics);
const output = lastFrame();
@@ -351,21 +354,7 @@ describe('<StatsDisplay />', () => {
});
describe('Title Rendering', () => {
const zeroMetrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const zeroMetrics = createTestMetrics();
it('renders the default title when no title prop is provided', () => {
const { lastFrame } = renderWithMockedStats(zeroMetrics);

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -24,6 +25,7 @@ const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
@@ -46,9 +48,18 @@ describe('<ToolStatsDisplay />', () => {
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
expect(lastFrame()).toContain(
@@ -65,17 +76,31 @@ describe('<ToolStatsDisplay />', () => {
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 1, reject: 0, modify: 0 },
totalDecisions: {
accept: 1,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 1, reject: 0, modify: 0 },
decisions: {
accept: 1,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
@@ -91,24 +116,43 @@ describe('<ToolStatsDisplay />', () => {
totalSuccess: 2,
totalFail: 1,
totalDurationMs: 300,
totalDecisions: { accept: 1, reject: 1, modify: 1 },
totalDecisions: {
accept: 1,
reject: 1,
modify: 1,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {
'tool-a': {
count: 2,
success: 1,
fail: 1,
durationMs: 200,
decisions: { accept: 1, reject: 1, modify: 0 },
decisions: {
accept: 1,
reject: 1,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
'tool-b': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 1 },
decisions: {
accept: 0,
reject: 0,
modify: 1,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
@@ -129,6 +173,7 @@ describe('<ToolStatsDisplay />', () => {
accept: 123456789,
reject: 98765432,
modify: 12345,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {
'long-named-tool-for-testing-wrapping-and-such': {
@@ -140,10 +185,15 @@ describe('<ToolStatsDisplay />', () => {
accept: 123456789,
reject: 98765432,
modify: 12345,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
expect(lastFrame()).toMatchSnapshot();
@@ -157,17 +207,31 @@ describe('<ToolStatsDisplay />', () => {
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 100,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {
'test-tool': {
count: 1,
success: 1,
fail: 0,
durationMs: 100,
decisions: { accept: 0, reject: 0, modify: 0 },
decisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();

View File

@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID:
│ Session ID: test-session
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │

View File

@@ -12,6 +12,7 @@ import type {
TextBuffer,
TextBufferState,
TextBufferAction,
VisualLayout,
} from './text-buffer.js';
import {
useTextBuffer,
@@ -24,6 +25,12 @@ import {
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]],
};
const initialState: TextBufferState = {
lines: [''],
cursorRow: 0,
@@ -33,6 +40,9 @@ const initialState: TextBufferState = {
redoStack: [],
clipboard: null,
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 24,
visualLayout: defaultVisualLayout,
};
describe('textBufferReducer', () => {
@@ -1700,28 +1710,40 @@ describe('logicalPosToOffset', () => {
});
});
// Helper to create state for reducer tests
const createTestState = (
lines: string[],
cursorRow: number,
cursorCol: number,
viewportWidth = 80,
): TextBufferState => {
const text = lines.join('\n');
let state = textBufferReducer(initialState, {
type: 'set_text',
payload: text,
});
state = textBufferReducer(state, {
type: 'set_cursor',
payload: { cursorRow, cursorCol, preferredCol: null },
});
state = textBufferReducer(state, {
type: 'set_viewport',
payload: { width: viewportWidth, height: 24 },
});
return state;
};
describe('textBufferReducer vim operations', () => {
describe('vim_delete_line', () => {
it('should delete a single line including newline in multi-line text', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3'],
cursorRow: 1,
cursorCol: 2,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 2 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
const result = textBufferReducer(state, action);
expect(result).toHaveOnlyValidCharacters();
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
@@ -1731,25 +1753,14 @@ describe('textBufferReducer vim operations', () => {
});
it('should delete multiple lines when count > 1', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const state = createTestState(['line1', 'line2', 'line3', 'line4'], 1, 0);
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 2 },
};
const result = textBufferReducer(initialState, action);
const result = textBufferReducer(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete line2 and line3, leaving line1 and line4
@@ -1759,25 +1770,14 @@ describe('textBufferReducer vim operations', () => {
});
it('should clear single line content when only one line exists', () => {
const initialState: TextBufferState = {
lines: ['only line'],
cursorRow: 0,
cursorCol: 5,
preferredCol: null,
visualLines: [['only line']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 5 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const state = createTestState(['only line'], 0, 5);
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
const result = textBufferReducer(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should clear the line content but keep the line
@@ -1787,25 +1787,14 @@ describe('textBufferReducer vim operations', () => {
});
it('should handle deleting the last line properly', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const state = createTestState(['line1', 'line2'], 1, 0);
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
const result = textBufferReducer(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete the last line completely, not leave empty line
@@ -1815,18 +1804,7 @@ describe('textBufferReducer vim operations', () => {
});
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const state = createTestState(['line1', 'line2', 'line3', 'line4'], 0, 0);
// Delete all 4 lines with 4dd
const deleteAction: TextBufferAction = {
@@ -1834,7 +1812,7 @@ describe('textBufferReducer vim operations', () => {
payload: { count: 4 },
};
const afterDelete = textBufferReducer(initialState, deleteAction);
const afterDelete = textBufferReducer(state, deleteAction);
expect(afterDelete).toHaveOnlyValidCharacters();
// After deleting all lines, should have one empty line

View File

@@ -6,7 +6,13 @@
import { describe, it, expect } from 'vitest';
import { handleVimAction } from './vim-buffer-actions.js';
import type { TextBufferState } from './text-buffer.js';
import type { TextBufferState, VisualLayout } from './text-buffer.js';
const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
visualToLogicalMap: [[0, 0]],
};
// Helper to create test state
const createTestState = (
@@ -23,6 +29,8 @@ const createTestState = (
clipboard: null,
selectionAnchor: null,
viewportWidth: 80,
viewportHeight: 24,
visualLayout: defaultVisualLayout,
});
describe('vim-buffer-actions', () => {
@@ -266,7 +274,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); // Beginning of empty line
});
});
@@ -331,7 +339,7 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2); // Should move to line with 'test'
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(3); // Should be at 't' (end of 'test')
});
@@ -354,7 +362,7 @@ describe('vim-buffer-actions', () => {
payload: { count: 1 },
});
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Should move to empty line
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0); // Empty line has col 0
});
@@ -382,7 +390,7 @@ describe('vim-buffer-actions', () => {
it('should handle precomposed characters with diacritics', () => {
// Test case with precomposed é for comparison
const state = createTestState(['café test'], 0, 0); // Start at 'c'
const state = createTestState(['café test'], 0, 0);
// First 'e' command should move to the 'é' (position 3)
let result = handleVimAction(state, {
@@ -814,7 +822,7 @@ describe('vim-buffer-actions', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'h', count: 2 },
payload: { movement: 'h' as const, count: 2 },
};
const result = handleVimAction(state, action);
@@ -827,7 +835,7 @@ describe('vim-buffer-actions', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'l', count: 3 },
payload: { movement: 'l' as const, count: 3 },
};
const result = handleVimAction(state, action);
@@ -840,7 +848,7 @@ describe('vim-buffer-actions', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j', count: 2 },
payload: { movement: 'j' as const, count: 2 },
};
const result = handleVimAction(state, action);