qwen-code/packages/cli/src/ui/hooks/useStatusLine.test.ts
2026-07-09 23:12:08 +08:00

1387 lines
45 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeAll,
beforeEach,
afterEach,
} from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as child_process from 'child_process';
import { StreamingState } from '../types.js';
import type { StatusLinePresetReasoning } from '../statusLinePresets.js';
const debugLogMock = vi.hoisted(() => ({
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}));
// --- Mock child_process (auto-mock, then override exec in beforeEach) ---
vi.mock('child_process');
// --- Mock context hooks ---
const mockSettings = {
merged: {} as Record<string, unknown>,
reloadScopeFromDisk: vi.fn(),
};
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: () => mockSettings,
}));
const mockUIState = {
sessionStats: {
sessionId: 'test-session',
lastPromptTokenCount: 100,
metrics: {
models: {},
tools: { totalCalls: 0 },
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
},
currentModel: 'test-model',
branchName: 'main' as string | undefined,
streamingState: StreamingState.Idle,
statusLineSettingsVersion: 0,
statusLineConfigOverride: undefined as
| {
type: 'preset';
items: string[];
useThemeColors?: boolean;
hideContextIndicator?: boolean;
}
| undefined,
};
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
type MockContentGeneratorConfig = {
contextWindowSize: number;
reasoning?: StatusLinePresetReasoning;
};
const getMockContentGeneratorConfig = (): MockContentGeneratorConfig => ({
contextWindowSize: 131072,
});
const mockConfig = {
getTargetDir: vi.fn(() => '/test/dir'),
getModel: vi.fn(() => 'test-model'),
getModelDisplayName: vi.fn(() => 'Test Model'),
getModelsConfig: vi.fn(() => ({
getModelDisplayName: (model: string) =>
model === 'main-model' ? 'Main Model' : 'Test Model',
})),
getCliVersion: vi.fn(() => '1.0.0'),
getContentGeneratorConfig: vi.fn(getMockContentGeneratorConfig),
};
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => mockConfig,
}));
const mockVimMode = {
vimEnabled: false,
vimMode: 'INSERT' as string,
};
vi.mock('../contexts/VimModeContext.js', () => ({
useVimModeState: () => mockVimMode,
useVimModeActions: () => ({
toggleVimEnabled: vi.fn(),
setVimMode: vi.fn(),
}),
useVimMode: () => mockVimMode,
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
createDebugLogger: () => debugLogMock,
// Stub cron-task exports to avoid loading async-mutex under fake timers
readCronTasks: vi.fn(async () => []),
updateCronTasks: vi.fn(async () => undefined),
removeCronTasks: vi.fn(async () => undefined),
getCronFilePath: vi.fn(() => '/tmp/cron-tasks.json'),
generateCronTaskId: vi.fn(() => 'test-id'),
};
});
// --- exec mock state ---
type ExecCallback = (
error: Error | null,
stdout: string,
stderr: string,
) => void;
let execCallback: ExecCallback;
let lastExecCommand: string | undefined;
let stdinWrittenData: string;
let stdinErrorHandler: ((err: Error) => void) | undefined;
let mockKill: ReturnType<typeof vi.fn>;
function setStatusLineConfig(
config:
| {
type: string;
command: string;
refreshInterval?: number;
respectUserColors?: boolean;
hideContextIndicator?: boolean;
}
| {
type: 'preset';
items: string[];
useThemeColors?: boolean;
hideContextIndicator?: boolean;
}
| null
| undefined,
) {
mockSettings.merged =
config === undefined ? {} : { ui: { statusLine: config } };
}
describe('useStatusLine', () => {
// Must import dynamically after mocks are set up
let useStatusLine: typeof import('./useStatusLine.js').useStatusLine;
beforeAll(async () => {
const mod = await import('./useStatusLine.js');
useStatusLine = mod.useStatusLine;
}, 20_000);
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
lastExecCommand = undefined;
stdinWrittenData = '';
stdinErrorHandler = undefined;
mockKill = vi.fn();
mockSettings.reloadScopeFromDisk.mockImplementation(() => undefined);
// Set up exec mock implementation
vi.mocked(child_process.exec).mockImplementation(((
cmd: string,
_opts: unknown,
cb: ExecCallback,
) => {
lastExecCommand = cmd;
execCallback = cb;
stdinWrittenData = '';
stdinErrorHandler = undefined;
return {
stdin: {
on: vi.fn((_event: string, handler: (err: Error) => void) => {
stdinErrorHandler = handler;
}),
write: vi.fn((data: string) => {
stdinWrittenData += data;
return true;
}),
end: vi.fn(),
},
kill: mockKill,
killed: false,
};
}) as unknown as typeof child_process.exec);
// Reset mutable mock state
setStatusLineConfig(undefined);
mockUIState.sessionStats.lastPromptTokenCount = 100;
mockUIState.currentModel = 'test-model';
mockUIState.branchName = 'main';
mockUIState.statusLineSettingsVersion = 0;
mockUIState.statusLineConfigOverride = undefined;
mockUIState.sessionStats.metrics.tools.totalCalls = 0;
mockUIState.sessionStats.metrics.files.totalLinesAdded = 0;
mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0;
mockVimMode.vimEnabled = false;
mockVimMode.vimMode = 'INSERT';
mockConfig.getModelDisplayName.mockReturnValue('Test Model');
mockConfig.getContentGeneratorConfig.mockReturnValue({
contextWindowSize: 131072,
});
});
afterEach(() => {
vi.useRealTimers();
});
// --- getStatusLineConfig validation (tested through the hook) ---
describe('config validation', () => {
it('renders the default preset when no statusLine config is set', () => {
const { result } = renderHook(() => useStatusLine());
expect(child_process.exec).not.toHaveBeenCalled();
expect(result.current.lines).toEqual([
'\u279c dir \u00b7 git:(main) \u00b7 Test Model \u00b7 131.1k Context 0.1% used',
]);
});
it('falls back to config model display name when the preset current model is empty', () => {
mockUIState.currentModel = '';
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([
'\u279c dir \u00b7 git:(main) \u00b7 Test Model \u00b7 131.1k Context 0.1% used',
]);
});
it('renders the session model when config is scoped to a fast subagent', () => {
mockUIState.currentModel = 'main-model';
mockConfig.getModelDisplayName.mockReturnValue('Fast Model');
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([
'\u279c dir \u00b7 git:(main) \u00b7 Main Model \u00b7 131.1k Context 0.1% used',
]);
});
it('renders no lines when statusLine is explicitly null (opt-out)', () => {
setStatusLineConfig(null);
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when statusLine type is not "command"', () => {
setStatusLineConfig({ type: 'invalid', command: 'echo hi' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when command is empty string', () => {
setStatusLineConfig({ type: 'command', command: '' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns null when command is whitespace only', () => {
setStatusLineConfig({ type: 'command', command: ' ' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).not.toHaveBeenCalled();
});
it('returns respectUserColors false by default for command type', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.respectUserColors).toBe(false);
});
it('returns respectUserColors true when set in config', () => {
setStatusLineConfig({
type: 'command',
command: 'echo hello',
respectUserColors: true,
});
const { result } = renderHook(() => useStatusLine());
expect(result.current.respectUserColors).toBe(true);
});
it('returns respectUserColors false for preset type', () => {
setStatusLineConfig({
type: 'preset',
items: ['model'],
});
const { result } = renderHook(() => useStatusLine());
expect(result.current.respectUserColors).toBe(false);
});
it('returns hideContextIndicator false by default', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result } = renderHook(() => useStatusLine());
expect(result.current.hideContextIndicator).toBe(false);
});
it('returns hideContextIndicator true when set in command config', () => {
setStatusLineConfig({
type: 'command',
command: 'echo hello',
hideContextIndicator: true,
});
const { result } = renderHook(() => useStatusLine());
expect(result.current.hideContextIndicator).toBe(true);
});
it('returns hideContextIndicator true when set in preset config', () => {
setStatusLineConfig({
type: 'preset',
items: ['model'],
hideContextIndicator: true,
});
const { result } = renderHook(() => useStatusLine());
expect(result.current.hideContextIndicator).toBe(true);
});
});
describe('preset status line', () => {
it('returns the preset theme color preference', () => {
setStatusLineConfig({
type: 'preset',
useThemeColors: true,
items: ['model'],
});
const { result } = renderHook(() => useStatusLine());
expect(result.current.useThemeColors).toBe(true);
expect(result.current.lines).toEqual(['Test Model']);
});
it('looks up the current branch pull request number with gh', async () => {
mockUIState.branchName = 'dragon/feat-reproduce-skill';
setStatusLineConfig({
type: 'preset',
items: ['pull-request-number'],
});
const { result } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledOnce();
expect(lastExecCommand).toBe('gh pr view --json number --jq .number');
expect(result.current.lines).toEqual([]);
await act(async () => {
execCallback(null, '4118\n', '');
});
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(result.current.lines).toEqual(['#4118']);
});
it('does not run gh when pull request number is not selected', () => {
setStatusLineConfig({
type: 'preset',
items: ['model'],
});
const { result } = renderHook(() => useStatusLine());
expect(child_process.exec).not.toHaveBeenCalled();
expect(result.current.lines).toEqual(['Test Model']);
});
it('renders model-with-reasoning and model-only together', () => {
mockConfig.getContentGeneratorConfig.mockReturnValue({
contextWindowSize: 131072,
reasoning: { effort: 'high' },
});
setStatusLineConfig({
type: 'preset',
items: ['model', 'model-with-reasoning'],
});
const { result } = renderHook(() => useStatusLine());
expect(child_process.exec).not.toHaveBeenCalled();
expect(result.current.lines).toEqual(['Test Model high · Test Model']);
});
it('refreshes when status line settings are saved in the same process', async () => {
mockUIState.branchName = 'dragon/feat-reproduce-skill';
setStatusLineConfig({
type: 'preset',
items: ['model-with-reasoning'],
});
const { result, rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).not.toHaveBeenCalled();
expect(result.current.lines).toEqual(['Test Model']);
setStatusLineConfig({
type: 'preset',
items: ['model-with-reasoning', 'pull-request-number'],
});
mockUIState.statusLineConfigOverride = {
type: 'preset',
items: ['model-with-reasoning', 'pull-request-number'],
};
mockUIState.statusLineSettingsVersion += 1;
rerender();
expect(child_process.exec).toHaveBeenCalledOnce();
expect(lastExecCommand).toBe('gh pr view --json number --jq .number');
await act(async () => {
execCallback(null, '4118\n', '');
});
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(result.current.lines).toEqual(['Test Model · #4118']);
});
it('reloads status line settings from disk when streaming becomes idle', async () => {
setStatusLineConfig({
type: 'preset',
items: ['model'],
});
const { result, rerender } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual(['Test Model']);
mockSettings.reloadScopeFromDisk.mockImplementationOnce(() => {
setStatusLineConfig({
type: 'preset',
items: ['model-with-reasoning'],
});
});
mockUIState.streamingState = StreamingState.Responding;
rerender();
mockConfig.getContentGeneratorConfig.mockReturnValue({
contextWindowSize: 131072,
reasoning: { effort: 'high' },
});
mockUIState.streamingState = StreamingState.Idle;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(mockSettings.reloadScopeFromDisk).toHaveBeenCalledOnce();
expect(result.current.lines).toEqual(['Test Model high']);
});
it('uses command settings when a stale preset override no longer matches the settings type', () => {
setStatusLineConfig({
type: 'command',
command: 'echo from-settings',
});
mockUIState.statusLineConfigOverride = {
type: 'preset',
items: ['model'],
};
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledOnce();
expect(lastExecCommand).toBe('echo from-settings');
});
it('ignores a stale preset override when status line is disabled (null)', () => {
setStatusLineConfig(null);
mockUIState.statusLineConfigOverride = {
type: 'preset',
items: ['model'],
};
const { result } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).not.toHaveBeenCalled();
});
it('logs and retries pull request lookup failures after state changes', async () => {
mockUIState.branchName = 'dragon/feat-reproduce-skill';
setStatusLineConfig({
type: 'preset',
items: ['pull-request-number'],
});
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledOnce();
await act(async () => {
execCallback(new Error('gh not authenticated'), '', '');
});
expect(debugLogMock.warn).toHaveBeenCalledWith(
'statusline: gh pr view failed:',
'gh not authenticated',
);
mockUIState.sessionStats.lastPromptTokenCount = 101;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
expect(lastExecCommand).toBe('gh pr view --json number --jq .number');
});
});
// --- Command execution ---
describe('command execution', () => {
it('executes configured command on mount', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledOnce();
expect(lastExecCommand).toBe('echo hello');
});
it('passes correct options to exec', () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
renderHook(() => useStatusLine());
const callArgs = vi.mocked(child_process.exec).mock.calls[0];
const opts = callArgs[1] as {
cwd: string;
timeout: number;
maxBuffer: number;
};
expect(opts.cwd).toBe('/test/dir');
expect(opts.timeout).toBe(5000);
expect(opts.maxBuffer).toBe(1024 * 10);
});
it('returns single line as array', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello world\n', '');
});
expect(result.current.lines).toEqual(['hello world']);
});
it('returns all lines when stdout has multiple lines', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'first line\nsecond line\n', '');
});
expect(result.current.lines).toEqual(['first line', 'second line']);
});
it('filters empty lines from output', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, '\n\nreal content\n', '');
});
expect(result.current.lines).toEqual(['real content']);
});
it('caps output at 2 lines', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'line1\nline2\nline3\nline4\n', '');
});
expect(result.current.lines).toEqual(['line1', 'line2']);
});
it('handles \\r\\n line endings', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'line1\r\nline2\r\n', '');
});
expect(result.current.lines).toEqual(['line1', 'line2']);
});
it('returns empty when stdout is only newlines', async () => {
setStatusLineConfig({ type: 'command', command: 'echo lines' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, '\n\n', '');
});
expect(result.current.lines).toEqual([]);
});
it('returns null when command fails', async () => {
setStatusLineConfig({ type: 'command', command: 'bad-cmd' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(new Error('command not found'), '', '');
});
expect(result.current.lines).toEqual([]);
});
it('returns null when stdout is empty', async () => {
setStatusLineConfig({ type: 'command', command: 'echo -n' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, '', '');
});
expect(result.current.lines).toEqual([]);
});
});
// --- stdin JSON input ---
describe('stdin JSON input', () => {
it('writes JSON to stdin with session context', () => {
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.session_id).toBe('test-session');
expect(input.version).toBe('1.0.0');
expect(input.model.display_name).toBe('Test Model');
expect(input.workspace.current_dir).toBe('/test/dir');
});
it('includes git branch when available', () => {
mockUIState.branchName = 'feature/test';
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.git.branch).toBe('feature/test');
});
it('omits git when branchName is falsy', () => {
mockUIState.branchName = undefined;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.git).toBeUndefined();
});
it('includes vim mode when enabled', () => {
mockVimMode.vimEnabled = true;
mockVimMode.vimMode = 'NORMAL';
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.vim.mode).toBe('NORMAL');
});
it('omits vim when not enabled', () => {
mockVimMode.vimEnabled = false;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.vim).toBeUndefined();
});
it('includes context window usage data', () => {
mockUIState.sessionStats.lastPromptTokenCount = 65536;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.context_window.context_window_size).toBe(131072);
expect(input.context_window.used_percentage).toBe(50);
expect(input.context_window.remaining_percentage).toBe(50);
expect(input.context_window.current_usage).toBe(65536);
});
it('includes per-model metrics and aggregated token counts', () => {
mockUIState.sessionStats.metrics.models = {
'test-model': {
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 2000 },
tokens: {
prompt: 1000,
candidates: 500,
total: 1500,
cached: 200,
thoughts: 100,
},
},
} as never;
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.metrics.models['test-model'].api.total_requests).toBe(5);
expect(input.metrics.models['test-model'].api.total_errors).toBe(1);
expect(input.metrics.models['test-model'].tokens.prompt).toBe(1000);
expect(input.metrics.models['test-model'].tokens.completion).toBe(500);
expect(input.metrics.models['test-model'].tokens.cached).toBe(200);
expect(input.metrics.models['test-model'].tokens.thoughts).toBe(100);
expect(input.context_window.total_input_tokens).toBe(1000);
expect(input.context_window.total_output_tokens).toBe(500);
});
it('falls back to zero when contextWindowSize is unavailable', () => {
// Persistent (not Once): the hook reads getContentGeneratorConfig both at
// render scope (reasoning-effort trigger key) and inside the data builder,
// so simulate "unavailable" for every call rather than just the first.
mockConfig.getContentGeneratorConfig.mockReturnValue(null as never);
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.context_window.context_window_size).toBe(0);
expect(input.context_window.used_percentage).toBe(0);
});
it('falls back to "unknown" when getCliVersion returns empty', () => {
mockConfig.getCliVersion.mockReturnValueOnce('');
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.version).toBe('unknown');
});
it('falls back to model from config when currentModel is empty', () => {
mockUIState.currentModel = '';
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.model.display_name).toBe('Test Model');
});
it('sends the session model when config is scoped to a fast subagent', () => {
mockUIState.currentModel = 'main-model';
mockConfig.getModelDisplayName.mockReturnValue('Fast Model');
setStatusLineConfig({ type: 'command', command: 'cat' });
renderHook(() => useStatusLine());
const input = JSON.parse(stdinWrittenData);
expect(input.model.display_name).toBe('Main Model');
});
});
// --- Stale generation handling ---
describe('stale generation', () => {
it('ignores callback from stale generation and accepts fresh one', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { result, rerender } = renderHook(() => useStatusLine());
// Capture first callback
const firstCallback = execCallback;
// Trigger a state change to cause re-execution
mockUIState.currentModel = 'new-model';
rerender();
// Advance debounce timer — triggers second exec
await act(async () => {
vi.advanceTimersByTime(300);
});
// Capture second (current) callback
const secondCallback = execCallback;
// Resolve the stale first callback — should be ignored
await act(async () => {
firstCallback(null, 'stale output\n', '');
});
expect(result.current.lines).toEqual([]);
// Resolve the fresh second callback — should be accepted
await act(async () => {
secondCallback(null, 'fresh output\n', '');
});
expect(result.current.lines).toEqual(['fresh output']);
});
});
// --- Debouncing ---
describe('debouncing', () => {
it('debounces rapid state changes to a single exec', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
// Initial mount exec
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Rapid state changes
mockUIState.currentModel = 'model-1';
rerender();
mockUIState.currentModel = 'model-2';
rerender();
mockUIState.currentModel = 'model-3';
rerender();
// Before debounce expires, no additional execs
expect(child_process.exec).toHaveBeenCalledTimes(1);
// After debounce
await act(async () => {
vi.advanceTimersByTime(300);
});
// Should have exactly one debounced exec (total 2: mount + debounced)
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
// --- Explicit opt-out via `ui.statusLine: null` ---
describe('config removal', () => {
it('clears output when status line is explicitly disabled (null)', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result, rerender } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello\n', '');
});
expect(result.current.lines).toEqual(['hello']);
// Explicit opt-out
setStatusLineConfig(null);
rerender();
expect(result.current.lines).toEqual([]);
});
it('cancels pending debounce and kills child when status line is disabled (null)', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Trigger a debounced update (timer is pending)
mockUIState.currentModel = 'new-model';
rerender();
// Disable before debounce fires
setStatusLineConfig(null);
rerender();
expect(mockKill).toHaveBeenCalled();
// Advancing past debounce should not trigger another exec
const callsBefore = vi.mocked(child_process.exec).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(vi.mocked(child_process.exec).mock.calls.length).toBe(callsBefore);
});
});
// --- Cleanup on unmount ---
describe('cleanup', () => {
it('kills active child process on unmount', () => {
setStatusLineConfig({ type: 'command', command: 'sleep 10' });
const { unmount } = renderHook(() => useStatusLine());
expect(mockKill).not.toHaveBeenCalled();
unmount();
expect(mockKill).toHaveBeenCalled();
});
it('clears debounce timer on unmount', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender, unmount } = renderHook(() => useStatusLine());
// Trigger a debounced update
mockUIState.currentModel = 'new-model';
rerender();
// Unmount before debounce fires
unmount();
// Advance past debounce — should not cause additional exec
const callsBefore = vi.mocked(child_process.exec).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(vi.mocked(child_process.exec).mock.calls.length).toBe(callsBefore);
});
});
// --- stdin error handling ---
describe('stdin error handling', () => {
it('silently handles EPIPE errors', () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
renderHook(() => useStatusLine());
expect(stdinErrorHandler).toBeDefined();
const epipeError = new Error('EPIPE') as NodeJS.ErrnoException;
epipeError.code = 'EPIPE';
// Should not throw
expect(() => stdinErrorHandler!(epipeError)).not.toThrow();
});
it('logs non-EPIPE stdin errors', () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
renderHook(() => useStatusLine());
expect(stdinErrorHandler).toBeDefined();
const otherError = new Error('EIO') as NodeJS.ErrnoException;
otherError.code = 'EIO';
// Should not throw but should log (we can't easily check debugLog here,
// but we verify it doesn't crash)
expect(() => stdinErrorHandler!(otherError)).not.toThrow();
});
});
// --- Command change triggers immediate re-execution ---
describe('command change', () => {
it('re-executes immediately when command changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo first' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Resolve first exec
await act(async () => {
execCallback(null, 'first\n', '');
});
// Change command
setStatusLineConfig({ type: 'command', command: 'echo second' });
rerender();
// Should re-execute immediately (not debounced)
expect(child_process.exec).toHaveBeenCalledTimes(2);
expect(lastExecCommand).toBe('echo second');
});
it('cancels pending debounce when command changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo first' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Trigger a debounced update
mockUIState.currentModel = 'new-model';
rerender();
// Change command before debounce fires
setStatusLineConfig({ type: 'command', command: 'echo second' });
rerender();
// Immediate re-exec from command change (mount + command change = 2)
expect(child_process.exec).toHaveBeenCalledTimes(2);
// Debounce fires but should not cause a third exec (was cleared)
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
// --- State change triggers ---
describe('state change triggers', () => {
it('triggers update when prompt token count changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.lastPromptTokenCount = 200;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when branch changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.branchName = 'feature/new';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when tool calls change', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.metrics.tools.totalCalls = 5;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when vim mode is toggled off', async () => {
mockVimMode.vimEnabled = true;
mockVimMode.vimMode = 'NORMAL';
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Disable vim — effectiveVim changes from 'NORMAL' to undefined
mockVimMode.vimEnabled = false;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('triggers update when file lines change', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
mockUIState.sessionStats.metrics.files.totalLinesAdded = 50;
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
// --- Process killed on new update ---
describe('process management', () => {
it('kills previous process when starting new execution', async () => {
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { rerender } = renderHook(() => useStatusLine());
const firstKill = mockKill;
// Trigger re-execution via state change
mockUIState.currentModel = 'new-model';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(firstKill).toHaveBeenCalled();
});
});
// --- Spawn failure handling (issue #3264) ---
//
// On macOS with Node 22, exec() can throw synchronously with EBADF when
// stdio pipe setup fails. The throw must not escape doUpdate() — or the
// setTimeout callback — or the whole CLI crashes.
describe('spawn failure handling', () => {
it('does not crash when exec throws synchronously (EBADF)', () => {
vi.mocked(child_process.exec).mockImplementationOnce((() => {
const err = new Error('spawn EBADF') as NodeJS.ErrnoException;
err.code = 'EBADF';
throw err;
}) as unknown as typeof child_process.exec);
setStatusLineConfig({ type: 'command', command: 'echo test' });
let result: { current: { lines: string[] } } | undefined;
expect(() => {
result = renderHook(() => useStatusLine()).result;
}).not.toThrow();
expect(result!.current.lines).toEqual([]);
});
it('recovers on subsequent state changes after a sync exec failure', async () => {
// First call throws, subsequent calls succeed with the default mock.
// Verifies activeChildRef and generationRef don't get wedged.
vi.mocked(child_process.exec).mockImplementationOnce((() => {
const err = new Error('spawn EBADF') as NodeJS.ErrnoException;
err.code = 'EBADF';
throw err;
}) as unknown as typeof child_process.exec);
setStatusLineConfig({ type: 'command', command: 'echo test' });
const { result, rerender } = renderHook(() => useStatusLine());
expect(result.current.lines).toEqual([]);
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Trigger a re-execution via state change — should use the default mock.
mockUIState.currentModel = 'new-model';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
await act(async () => {
execCallback(null, 'recovered\n', '');
});
expect(result.current.lines).toEqual(['recovered']);
});
});
// --- Output deduplication (cuts unnecessary Footer re-renders) ---
describe('output deduplication', () => {
it('preserves the same lines array reference when output is unchanged', async () => {
setStatusLineConfig({ type: 'command', command: 'echo same' });
const { result, rerender } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'same output\n', '');
});
const firstRef = result.current.lines;
expect(firstRef).toEqual(['same output']);
// Trigger another exec with identical output (e.g. via state change).
mockUIState.currentModel = 'new-model';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
await act(async () => {
execCallback(null, 'same output\n', '');
});
// Reference preserved → React can skip the Footer re-render.
expect(result.current.lines).toBe(firstRef);
});
it('produces a new reference when output changes', async () => {
setStatusLineConfig({ type: 'command', command: 'echo tick' });
const { result, rerender } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'first\n', '');
});
const firstRef = result.current.lines;
expect(firstRef).toEqual(['first']);
mockUIState.currentModel = 'new-model';
rerender();
await act(async () => {
vi.advanceTimersByTime(300);
});
await act(async () => {
execCallback(null, 'second\n', '');
});
expect(result.current.lines).not.toBe(firstRef);
expect(result.current.lines).toEqual(['second']);
});
});
// --- refreshInterval (periodic refresh) ---
describe('refreshInterval', () => {
it('re-executes the command every N seconds', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 2,
});
renderHook(() => useStatusLine());
// Mount executes once immediately
expect(child_process.exec).toHaveBeenCalledTimes(1);
await act(async () => {
execCallback(null, 'tick 1\n', '');
});
// First interval tick after 2s — previous exec has completed, so
// the tick is free to spawn a new one.
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
await act(async () => {
execCallback(null, 'tick 2\n', '');
});
// Second interval tick after another 2s
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(child_process.exec).toHaveBeenCalledTimes(3);
});
it('does not start an interval when refreshInterval is omitted', async () => {
setStatusLineConfig({ type: 'command', command: 'echo static' });
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(60_000);
});
// Still only the mount exec — no periodic refresh
expect(child_process.exec).toHaveBeenCalledTimes(1);
});
it('rejects refreshInterval < 1 (no interval scheduled)', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 0.5,
});
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(child_process.exec).toHaveBeenCalledTimes(1);
});
it('rejects non-finite refreshInterval (no interval scheduled)', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: Number.POSITIVE_INFINITY,
});
renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(child_process.exec).toHaveBeenCalledTimes(1);
});
it('clears the interval when config is removed', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 1,
});
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Remove the config — the interval should be torn down.
setStatusLineConfig(undefined);
rerender();
const callsAfterRemoval = vi.mocked(child_process.exec).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(vi.mocked(child_process.exec).mock.calls.length).toBe(
callsAfterRemoval,
);
});
it('reschedules when refreshInterval changes', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 5,
});
const { rerender } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
await act(async () => {
execCallback(null, 'tick\n', '');
});
// 2s passes — not yet a tick on the 5s schedule.
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(child_process.exec).toHaveBeenCalledTimes(1);
// Swap to a 1s interval — the old 5s timer must be cleared, not kept.
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 1,
});
rerender();
// 1s later — fires on the new schedule.
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
it('clears the interval on unmount', async () => {
setStatusLineConfig({
type: 'command',
command: 'echo tick',
refreshInterval: 1,
});
const { unmount } = renderHook(() => useStatusLine());
expect(child_process.exec).toHaveBeenCalledTimes(1);
unmount();
const callsAfterUnmount = vi.mocked(child_process.exec).mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(10_000);
});
expect(vi.mocked(child_process.exec).mock.calls.length).toBe(
callsAfterUnmount,
);
});
it('skips periodic ticks while a previous exec is still running', async () => {
// Starvation regression (#3383 review): with refreshInterval < command
// latency, if every tick called doUpdate() it would kill the in-flight
// child, generation++ would stale the eventual callback, and the user
// would never see any output. This test asserts BOTH the guard (exec
// call count) AND the user-visible result (rendered lines).
setStatusLineConfig({
type: 'command',
command: 'slow-command',
refreshInterval: 1,
});
const { result } = renderHook(() => useStatusLine());
// Mount exec: child is spawned, callback NOT yet resolved — child is
// still "running" from the hook's perspective.
expect(child_process.exec).toHaveBeenCalledTimes(1);
const pendingCallback = execCallback;
// Several interval ticks pass while the first exec is in flight.
// Each tick must detect the running child and skip doUpdate().
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(child_process.exec).toHaveBeenCalledTimes(1);
// First exec finally completes — activeChildRef clears. Without the
// guard, generationRef would have bumped 3 times above and the next
// line would be ignored as stale, leaving `lines` permanently empty.
await act(async () => {
pendingCallback(null, 'done\n', '');
});
expect(result.current.lines).toEqual(['done']);
// Next tick is now free to spawn a new exec.
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(child_process.exec).toHaveBeenCalledTimes(2);
});
});
});