Files
gemini-cli/packages/cli/src/ui/components/Footer.test.tsx
T

756 lines
21 KiB
TypeScript
Raw Normal View History

/**
* @license
2026-02-09 21:53:10 -05:00
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2026-02-26 00:30:46 -05:00
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { Footer } from './Footer.js';
2026-02-16 12:54:39 -05:00
import { createMockSettings } from '../../test-utils/settings.js';
2026-02-26 00:30:46 -05:00
import path from 'node:path';
2026-02-26 00:30:46 -05:00
// Normalize paths to POSIX slashes for stable cross-platform snapshots.
const normalizeFrame = (frame: string | undefined) => {
if (!frame) return frame;
return frame.replace(/\\/g, '/');
2026-02-26 00:30:46 -05:00
};
const mockSessionStats = {
2026-02-16 12:54:39 -05:00
sessionId: 'test-session-id',
sessionStartTime: new Date(),
promptCount: 0,
2026-02-16 12:54:39 -05:00
lastPromptTokenCount: 150000,
metrics: {
2026-02-16 12:54:39 -05:00
files: {
totalLinesAdded: 12,
totalLinesRemoved: 4,
},
tools: {
2026-02-16 12:54:39 -05:00
count: 0,
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
2026-02-16 12:54:39 -05:00
auto_accept: 0,
},
byName: {},
2026-02-16 12:54:39 -05:00
latency: { avg: 0, max: 0, min: 0 },
},
2026-02-16 12:54:39 -05:00
models: {
'gemini-pro': {
api: {
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
},
tokens: {
input: 0,
prompt: 0,
candidates: 0,
total: 1500,
cached: 0,
thoughts: 0,
tool: 0,
},
2026-02-26 00:30:46 -05:00
roles: {},
2026-02-16 12:54:39 -05:00
},
},
},
};
2026-02-16 12:54:39 -05:00
const defaultProps = {
model: 'gemini-pro',
targetDir: '/long/path/to/some/deeply/nested/directories/to/make/it/long',
debugMode: false,
branchName: 'main',
errorCount: 0,
};
describe('<Footer />', () => {
beforeEach(() => {
const root = path.parse(process.cwd()).root;
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
});
2026-02-26 00:30:46 -05:00
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
2026-02-26 00:30:46 -05:00
unmount();
});
describe('path display', () => {
2026-02-26 00:30:46 -05:00
it('should display a shortened path on a narrow terminal', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 79,
uiState: { sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-16 12:54:39 -05:00
const output = lastFrame();
expect(output).toBeDefined();
2026-02-26 00:30:46 -05:00
// Should contain some part of the path, likely shortened
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
unmount();
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 80,
uiState: { sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-16 12:54:39 -05:00
const output = lastFrame();
expect(output).toBeDefined();
2026-02-26 00:30:46 -05:00
expect(output).toContain(
path.join('directories', 'to', 'make', 'it', 'long'),
);
unmount();
2026-02-16 12:54:39 -05:00
});
});
2026-02-26 00:30:46 -05:00
it('displays the branch name when provided', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-16 12:54:39 -05:00
expect(lastFrame()).toContain(defaultProps.branchName);
2026-02-26 00:30:46 -05:00
unmount();
});
2026-02-26 00:30:46 -05:00
it('does not display the branch name when not provided', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Branch');
unmount();
});
2026-02-26 00:30:46 -05:00
it('displays the model name and context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
2026-02-09 21:53:10 -05:00
},
},
2026-02-26 00:30:46 -05:00
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% left/);
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-26 00:30:46 -05:00
it('displays the usage indicator when usage is low', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
2026-02-16 12:54:39 -05:00
},
2026-02-09 21:53:10 -05:00
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-09 21:53:10 -05:00
expect(lastFrame()).toContain('15%');
2026-02-26 00:30:46 -05:00
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
2026-02-09 21:53:10 -05:00
});
2026-02-26 00:30:46 -05:00
it('hides the usage indicator when usage is not near limit', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
2026-02-09 21:53:10 -05:00
},
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-09 21:53:10 -05:00
expect(lastFrame()).not.toContain('Usage remaining');
2026-02-26 00:30:46 -05:00
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
2026-02-09 21:53:10 -05:00
});
2026-02-26 00:30:46 -05:00
it('displays "Limit reached" message when remaining is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
2026-02-09 21:53:10 -05:00
},
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
2026-02-16 12:54:39 -05:00
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
2026-02-26 00:30:46 -05:00
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
2026-02-09 21:53:10 -05:00
});
2026-02-26 00:30:46 -05:00
it('displays the model name and abbreviated context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
unmount();
});
describe('sandbox and trust info', () => {
2026-02-26 00:30:46 -05:00
it('should display untrusted when isTrustedFolder is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
2026-02-26 00:30:46 -05:00
unmount();
});
it('should display custom sandbox info when SANDBOX env is set', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
unmount();
});
2026-02-26 00:30:46 -05:00
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
2026-02-26 00:30:46 -05:00
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
unmount();
});
2026-02-26 00:30:46 -05:00
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
2026-02-26 00:30:46 -05:00
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain('no sandbox');
2026-02-26 00:30:46 -05:00
vi.unstubAllEnvs();
unmount();
});
2026-02-26 00:30:46 -05:00
it('should prioritize untrusted message over sandbox info', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
2026-02-26 00:30:46 -05:00
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
unmount();
});
});
describe('footer configuration filtering (golden snapshots)', () => {
2026-02-26 00:30:46 -05:00
beforeEach(() => {
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});
2026-02-26 00:30:46 -05:00
afterEach(() => {
vi.unstubAllEnvs();
});
2026-02-26 00:30:46 -05:00
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-wide',
);
unmount();
});
it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
},
2026-02-26 00:30:46 -05:00
}),
},
);
await waitUntilReady();
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
'footer-minimal',
);
unmount();
});
it('renders footer with only model info hidden (partial filtering)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
2026-02-26 00:30:46 -05:00
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
2026-02-26 00:30:46 -05:00
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'footer-only-sandbox',
);
unmount();
});
it('hides the context percentage when hideContextPercentage is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% left/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% left/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
2026-02-26 00:30:46 -05:00
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-narrow',
);
unmount();
2026-02-16 12:54:39 -05:00
});
});
2026-02-13 09:21:48 -05:00
2026-02-16 12:54:39 -05:00
describe('Footer Token Formatting', () => {
2026-02-26 00:30:46 -05:00
const renderWithTokens = async (tokens: number) => {
const result = renderWithProviders(<Footer />, {
2026-02-16 12:54:39 -05:00
width: 120,
uiState: {
sessionStats: {
2026-02-26 00:30:46 -05:00
...mockSessionStats,
2026-02-16 12:54:39 -05:00
metrics: {
2026-02-26 00:30:46 -05:00
...mockSessionStats.metrics,
2026-02-16 12:54:39 -05:00
models: {
'gemini-pro': {
api: {
totalRequests: 0,
totalErrors: 0,
totalLatencyMs: 0,
},
tokens: {
input: 0,
prompt: 0,
candidates: 0,
total: tokens,
cached: 0,
thoughts: 0,
tool: 0,
},
2026-02-26 00:30:46 -05:00
roles: {},
2026-02-16 12:54:39 -05:00
},
2026-02-13 09:21:48 -05:00
},
},
},
2026-02-16 12:54:39 -05:00
},
settings: createMockSettings({
ui: {
footer: {
items: ['token-count'],
2026-02-13 09:26:46 -05:00
},
2026-02-13 09:21:48 -05:00
},
2026-02-16 12:54:39 -05:00
}),
});
2026-02-26 00:30:46 -05:00
await result.waitUntilReady();
return result;
};
2026-02-13 09:21:48 -05:00
2026-02-26 00:30:46 -05:00
it('formats thousands with k', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500);
2026-02-16 12:54:39 -05:00
expect(lastFrame()).toContain('1.5k tokens');
2026-02-26 00:30:46 -05:00
unmount();
2026-02-13 09:21:48 -05:00
});
2026-02-26 00:30:46 -05:00
it('formats millions with m', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500000);
2026-02-16 12:54:39 -05:00
expect(lastFrame()).toContain('1.5m tokens');
2026-02-26 00:30:46 -05:00
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-13 09:21:48 -05:00
2026-02-26 00:30:46 -05:00
it('formats billions with b', async () => {
const { lastFrame, unmount } = await renderWithTokens(1500000000);
2026-02-16 12:54:39 -05:00
expect(lastFrame()).toContain('1.5b tokens');
2026-02-26 00:30:46 -05:00
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-13 09:21:48 -05:00
2026-02-26 00:30:46 -05:00
it('formats small numbers without suffix', async () => {
const { lastFrame, unmount } = await renderWithTokens(500);
2026-02-16 12:54:39 -05:00
expect(lastFrame()).toContain('500 tokens');
2026-02-26 00:30:46 -05:00
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-13 09:21:48 -05:00
});
2026-02-16 12:54:39 -05:00
describe('Footer Custom Items', () => {
2026-02-26 00:30:46 -05:00
it('renders items in the specified order', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
2026-02-13 09:21:48 -05:00
},
2026-02-26 00:30:46 -05:00
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'cwd'],
},
},
}),
},
);
await waitUntilReady();
2026-02-13 09:21:48 -05:00
2026-02-16 12:54:39 -05:00
const output = lastFrame();
2026-02-26 00:30:46 -05:00
const modelIdx = output.indexOf('/model');
const cwdIdx = output.indexOf('Path');
2026-02-16 12:54:39 -05:00
expect(modelIdx).toBeLessThan(cwdIdx);
2026-02-26 00:30:46 -05:00
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-13 09:21:48 -05:00
2026-02-26 00:30:46 -05:00
it('renders multiple items with proper alignment', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
2026-02-13 09:21:48 -05:00
},
2026-02-26 00:30:46 -05:00
settings: createMockSettings({
vimMode: {
vimMode: true,
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
ui: {
footer: {
items: ['cwd', 'git-branch', 'sandbox-status', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
2026-02-16 12:54:39 -05:00
const output = lastFrame();
expect(output).toBeDefined();
// Headers should be present
expect(output).toContain('Path');
expect(output).toContain('Branch');
expect(output).toContain('/docs');
expect(output).toContain('/model');
// Data should be present
2026-02-16 13:22:46 -05:00
expect(output).toContain('main');
2026-02-16 12:54:39 -05:00
expect(output).toContain('gemini-pro');
2026-02-26 00:30:46 -05:00
unmount();
});
it('handles empty items array', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
}),
},
);
await waitUntilReady();
2026-02-13 09:21:48 -05:00
2026-02-26 00:30:46 -05:00
const output = lastFrame({ allowEmpty: true });
2026-02-16 12:54:39 -05:00
expect(output).toBeDefined();
2026-02-26 00:30:46 -05:00
expect(output.trim()).toBe('');
unmount();
});
it('does not render items that are conditionally hidden', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
2026-02-16 12:54:39 -05:00
},
2026-02-26 00:30:46 -05:00
settings: createMockSettings({
ui: {
footer: {
items: ['cwd', 'git-branch', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
2026-02-13 09:21:48 -05:00
2026-02-16 12:54:39 -05:00
const output = lastFrame();
expect(output).toBeDefined();
expect(output).not.toContain('Branch');
expect(output).toContain('Path');
expect(output).toContain('/model');
2026-02-26 00:30:46 -05:00
unmount();
});
});
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
},
);
await waitUntilReady();
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
expect(lastFrame()).not.toContain('gemini-2.5-pro');
unmount();
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
},
);
await waitUntilReady();
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
2026-02-16 12:54:39 -05:00
});
2026-02-13 09:21:48 -05:00
});
});