mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
1387 lines
45 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|