test: add comprehensive tests for useStatusLine hook and statuslineCommand

Cover config validation, command execution with exec options, stdin JSON
payload, stale generation rejection, debouncing, config removal, cleanup
on unmount, EPIPE handling, command hot-reload, all state change triggers
(token count, model, branch, vim toggle, file lines), and process management.
This commit is contained in:
wenshao 2026-04-09 15:50:41 +08:00
parent 36aadd7fa1
commit f25fc047f1
2 changed files with 662 additions and 0 deletions

View file

@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { statuslineCommand } from './statuslineCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('statuslineCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(statuslineCommand.name).toBe('statusline');
expect(statuslineCommand.description).toBeDefined();
});
it('should return submit_prompt with default prompt when no args', () => {
if (!statuslineCommand.action) {
throw new Error('statusline command must have an action');
}
const result = statuslineCommand.action(mockContext, '');
expect(result).toEqual({
type: 'submit_prompt',
content: [
{
text: expect.stringContaining('statusline-setup'),
},
],
});
// Default prompt should mention PS1
expect(result).toHaveProperty(
'content.0.text',
expect.stringContaining('PS1'),
);
});
it('should use user-provided args as the prompt', () => {
if (!statuslineCommand.action) {
throw new Error('statusline command must have an action');
}
const result = statuslineCommand.action(
mockContext,
'show model name and git branch',
);
expect(result).toEqual({
type: 'submit_prompt',
content: [
{
text: expect.stringContaining('show model name and git branch'),
},
],
});
});
it('should trim whitespace-only args and use default prompt', () => {
if (!statuslineCommand.action) {
throw new Error('statusline command must have an action');
}
const result = statuslineCommand.action(mockContext, ' ');
expect(result).toHaveProperty(
'content.0.text',
expect.stringContaining('PS1'),
);
});
});

View file

@ -0,0 +1,584 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as child_process from 'child_process';
// --- Mock child_process (auto-mock, then override exec in beforeEach) ---
vi.mock('child_process');
// --- Mock context hooks ---
const mockSettings = {
merged: {} as Record<string, unknown>,
};
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,
};
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
const mockConfig = {
getTargetDir: vi.fn(() => '/test/dir'),
getModel: vi.fn(() => 'test-model'),
getCliVersion: vi.fn(() => '1.0.0'),
getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })),
};
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => mockConfig,
}));
const mockVimMode = {
vimEnabled: false,
vimMode: 'INSERT' as string,
};
vi.mock('../contexts/VimModeContext.js', () => ({
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: () => ({
log: vi.fn(),
error: vi.fn(),
}),
};
});
// --- 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 } | undefined,
) {
mockSettings.merged = config ? { ui: { statusLine: config } } : {};
}
describe('useStatusLine', () => {
// Must import dynamically after mocks are set up
let useStatusLine: typeof import('./useStatusLine.js').useStatusLine;
beforeEach(async () => {
vi.useFakeTimers();
vi.clearAllMocks();
lastExecCommand = undefined;
stdinWrittenData = '';
stdinErrorHandler = undefined;
mockKill = vi.fn();
// 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.sessionStats.metrics.tools.totalCalls = 0;
mockUIState.sessionStats.metrics.files.totalLinesAdded = 0;
mockUIState.sessionStats.metrics.files.totalLinesRemoved = 0;
mockVimMode.vimEnabled = false;
mockVimMode.vimMode = 'INSERT';
// Dynamic import to get fresh module after mocks
const mod = await import('./useStatusLine.js');
useStatusLine = mod.useStatusLine;
});
afterEach(() => {
vi.useRealTimers();
});
// --- getStatusLineConfig validation (tested through the hook) ---
describe('config validation', () => {
it('returns null when no statusLine config is set', () => {
const { result } = renderHook(() => useStatusLine());
expect(result.current.text).toBeNull();
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.text).toBeNull();
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.text).toBeNull();
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.text).toBeNull();
expect(child_process.exec).not.toHaveBeenCalled();
});
});
// --- 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 first line of stdout as text', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello world\n', '');
});
expect(result.current.text).toBe('hello world');
});
it('returns only the first line 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.text).toBe('first line');
});
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.text).toBeNull();
});
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.text).toBeNull();
});
});
// --- 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);
});
});
// --- 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.text).toBeNull();
// Resolve the fresh second callback — should be accepted
await act(async () => {
secondCallback(null, 'fresh output\n', '');
});
expect(result.current.text).toBe('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);
});
});
// --- Config removal clears output ---
describe('config removal', () => {
it('clears output when config is removed', async () => {
setStatusLineConfig({ type: 'command', command: 'echo hello' });
const { result, rerender } = renderHook(() => useStatusLine());
await act(async () => {
execCallback(null, 'hello\n', '');
});
expect(result.current.text).toBe('hello');
// Remove config
setStatusLineConfig(undefined);
rerender();
expect(result.current.text).toBeNull();
});
});
// --- 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');
});
});
// --- 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();
});
});
});