Skip to content

Commit 9aa07e8

Browse files
author
catlog22
committed
Add comprehensive tests for CLI functionality and CodexLens compatibility
- Introduced tests for stale running fallback in CLI watch functionality to ensure proper handling of saved conversations. - Added compatibility tests for CodexLens CLI to verify index initialization despite compatibility conflicts. - Implemented tests for Smart Search MCP usage to validate default settings and path handling. - Created tests for UV Manager to ensure Python preference handling works as expected. - Added a detailed guide for CCW/Codex commands and skills, covering core commands, execution modes, and templates.
1 parent 4254eee commit 9aa07e8

32 files changed

Lines changed: 2954 additions & 154 deletions

ccw/frontend/src/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { router } from './router';
1212
import queryClient from './lib/query-client';
1313
import type { Locale } from './lib/i18n';
1414
import { useWorkflowStore } from '@/stores/workflowStore';
15-
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
15+
import { useCliStreamStore } from '@/stores/cliStreamStore';
16+
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
17+
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
18+
import { useActiveCliExecutions, ACTIVE_CLI_EXECUTIONS_QUERY_KEY } from '@/hooks/useActiveCliExecutions';
1619
import { DialogStyleProvider } from '@/contexts/DialogStyleContext';
1720
import { initializeCsrfToken } from './lib/api';
1821

@@ -55,6 +58,10 @@ function QueryInvalidator() {
5558
useEffect(() => {
5659
// Register callback to invalidate all workspace-related queries on workspace switch
5760
const callback = () => {
61+
useCliStreamStore.getState().resetState();
62+
useExecutionMonitorStore.getState().resetState();
63+
useTerminalPanelStore.getState().resetState();
64+
queryClient.invalidateQueries({ queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY });
5865
queryClient.invalidateQueries({
5966
predicate: (query) => {
6067
const queryKey = query.queryKey;

ccw/frontend/src/components/shared/CliStreamMonitor/CliStreamMonitorNew.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
// ========================================
44
// Redesigned CLI streaming monitor with smart parsing and message-based layout
55

6-
import { useState, useCallback, useMemo } from 'react';
6+
import { useState, useEffect, useCallback, useMemo } from 'react';
77
import { useIntl } from 'react-intl';
88
import {
99
Terminal,
1010
} from 'lucide-react';
1111
import { cn } from '@/lib/utils';
1212
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
1313
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
14+
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
1415
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
1516

1617
// New layout components
@@ -169,6 +170,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
169170
const executions = useCliStreamStore((state) => state.executions);
170171
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
171172
const removeExecution = useCliStreamStore((state) => state.removeExecution);
173+
const projectPath = useWorkflowStore(selectProjectPath);
172174

173175
// Active execution sync
174176
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
@@ -221,6 +223,12 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
221223
return filtered;
222224
}, [messages, filter, searchQuery]);
223225

226+
useEffect(() => {
227+
setSearchQuery('');
228+
setFilter('all');
229+
setViewMode('preview');
230+
}, [projectPath]);
231+
224232
// Copy message content
225233
const handleCopy = useCallback(async (content: string) => {
226234
try {

ccw/frontend/src/components/shared/CliStreamMonitorLegacy.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Badge } from '@/components/ui/Badge';
2525
import { LogBlockList } from '@/components/shared/LogBlock';
2626
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
2727
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
28+
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
2829
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
2930

3031
// New components for Tab + JSON Cards
@@ -186,6 +187,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
186187
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
187188
const removeExecution = useCliStreamStore((state) => state.removeExecution);
188189
const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser);
190+
const projectPath = useWorkflowStore(selectProjectPath);
189191

190192
// Active execution sync
191193
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
@@ -214,6 +216,13 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
214216
}
215217
}, [executions, currentExecutionId, autoScroll, isUserScrolling]);
216218

219+
useEffect(() => {
220+
setSearchQuery('');
221+
setAutoScroll(true);
222+
setIsUserScrolling(false);
223+
setViewMode('list');
224+
}, [projectPath]);
225+
217226
// Handle scroll to detect user scrolling (with debounce for performance)
218227
const handleScrollRef = useRef<NodeJS.Timeout | null>(null);
219228
const handleScroll = useCallback(() => {
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// ========================================
2+
// useActiveCliExecutions Hook Tests
3+
// ========================================
4+
5+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6+
import { renderHook, waitFor } from '@testing-library/react';
7+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
8+
import * as React from 'react';
9+
import * as api from '@/lib/api';
10+
import { useActiveCliExecutions } from './useActiveCliExecutions';
11+
12+
const mockProjectState = vi.hoisted(() => ({
13+
projectPath: '/test/project',
14+
}));
15+
16+
const mockStoreState = vi.hoisted(() => ({
17+
executions: {} as Record<string, any>,
18+
cleanupUserClosedExecutions: vi.fn(),
19+
isExecutionClosedByUser: vi.fn(() => false),
20+
removeExecution: vi.fn(),
21+
upsertExecution: vi.fn(),
22+
setCurrentExecution: vi.fn(),
23+
}));
24+
25+
const mockUseCliStreamStore = vi.hoisted(() => {
26+
const store = vi.fn();
27+
Object.assign(store, {
28+
getState: vi.fn(() => mockStoreState),
29+
});
30+
return store;
31+
});
32+
33+
vi.mock('@/stores/cliStreamStore', () => ({
34+
useCliStreamStore: mockUseCliStreamStore,
35+
}));
36+
37+
vi.mock('@/stores/workflowStore', () => ({
38+
useWorkflowStore: vi.fn((selector?: (state: { projectPath: string }) => unknown) => (
39+
selector
40+
? selector({ projectPath: mockProjectState.projectPath })
41+
: { projectPath: mockProjectState.projectPath }
42+
)),
43+
selectProjectPath: (state: { projectPath: string }) => state.projectPath,
44+
}));
45+
46+
vi.mock('@/lib/api', async () => {
47+
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api');
48+
return {
49+
...actual,
50+
fetchExecutionDetail: vi.fn(),
51+
};
52+
});
53+
54+
const fetchMock = vi.fn();
55+
56+
function createTestQueryClient() {
57+
return new QueryClient({
58+
defaultOptions: {
59+
queries: {
60+
retry: false,
61+
},
62+
},
63+
});
64+
}
65+
66+
function createWrapper() {
67+
const queryClient = createTestQueryClient();
68+
69+
return ({ children }: { children: React.ReactNode }) => (
70+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
71+
);
72+
}
73+
74+
function createActiveResponse(executions: Array<Record<string, unknown>>) {
75+
return {
76+
ok: true,
77+
statusText: 'OK',
78+
json: vi.fn().mockResolvedValue({ executions }),
79+
};
80+
}
81+
82+
describe('useActiveCliExecutions', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks();
85+
vi.stubGlobal('fetch', fetchMock);
86+
87+
mockProjectState.projectPath = '/test/project';
88+
mockStoreState.executions = {};
89+
mockStoreState.cleanupUserClosedExecutions.mockReset();
90+
mockStoreState.isExecutionClosedByUser.mockReset();
91+
mockStoreState.isExecutionClosedByUser.mockReturnValue(false);
92+
mockStoreState.removeExecution.mockReset();
93+
mockStoreState.upsertExecution.mockReset();
94+
mockStoreState.setCurrentExecution.mockReset();
95+
(mockUseCliStreamStore as any).getState.mockReset();
96+
(mockUseCliStreamStore as any).getState.mockImplementation(() => mockStoreState);
97+
});
98+
99+
afterEach(() => {
100+
vi.unstubAllGlobals();
101+
});
102+
103+
it('requests active executions with scoped project path', async () => {
104+
fetchMock.mockResolvedValue(createActiveResponse([]));
105+
106+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
107+
wrapper: createWrapper(),
108+
});
109+
110+
await waitFor(() => {
111+
expect(result.current.data).toEqual([]);
112+
});
113+
114+
expect(fetchMock).toHaveBeenCalledWith('/api/cli/active?path=%2Ftest%2Fproject');
115+
});
116+
117+
it('filters stale recovered running executions when saved detail is newer', async () => {
118+
const startTime = 1_741_392_000_000;
119+
mockStoreState.executions = {
120+
'exec-stale': {
121+
tool: 'codex',
122+
mode: 'analysis',
123+
status: 'running',
124+
output: [],
125+
startTime,
126+
recovered: true,
127+
},
128+
};
129+
130+
fetchMock.mockResolvedValue(createActiveResponse([
131+
{
132+
id: 'exec-stale',
133+
tool: 'codex',
134+
mode: 'analysis',
135+
status: 'running',
136+
output: '[响应] stale output',
137+
startTime,
138+
},
139+
]));
140+
141+
vi.mocked(api.fetchExecutionDetail).mockResolvedValue({
142+
id: 'exec-stale',
143+
tool: 'codex',
144+
mode: 'analysis',
145+
turns: [],
146+
turn_count: 1,
147+
created_at: new Date(startTime - 2_000).toISOString(),
148+
updated_at: new Date(startTime + 2_000).toISOString(),
149+
} as any);
150+
151+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
152+
wrapper: createWrapper(),
153+
});
154+
155+
await waitFor(() => {
156+
expect(result.current.data).toEqual([]);
157+
});
158+
159+
expect(api.fetchExecutionDetail).toHaveBeenCalledWith('exec-stale', '/test/project');
160+
expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-stale');
161+
expect(mockStoreState.upsertExecution).not.toHaveBeenCalled();
162+
});
163+
164+
it('removes recovered running executions that are absent from the current workspace active list', async () => {
165+
mockStoreState.executions = {
166+
'exec-old-workspace': {
167+
tool: 'codex',
168+
mode: 'analysis',
169+
status: 'running',
170+
output: [],
171+
startTime: 1_741_394_000_000,
172+
recovered: true,
173+
},
174+
};
175+
176+
fetchMock.mockResolvedValue(createActiveResponse([]));
177+
178+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
179+
wrapper: createWrapper(),
180+
});
181+
182+
await waitFor(() => {
183+
expect(result.current.data).toEqual([]);
184+
});
185+
186+
expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-old-workspace');
187+
expect(api.fetchExecutionDetail).not.toHaveBeenCalled();
188+
});
189+
190+
it('reselects the best remaining execution when current selection becomes invalid', async () => {
191+
mockStoreState.executions = {
192+
'exec-running': {
193+
tool: 'codex',
194+
mode: 'analysis',
195+
status: 'running',
196+
output: [],
197+
startTime: 1_741_395_000_000,
198+
recovered: false,
199+
},
200+
'exec-completed': {
201+
tool: 'codex',
202+
mode: 'analysis',
203+
status: 'completed',
204+
output: [],
205+
startTime: 1_741_394_000_000,
206+
recovered: false,
207+
},
208+
};
209+
210+
(mockUseCliStreamStore as any).getState.mockImplementation(() => ({
211+
...mockStoreState,
212+
currentExecutionId: 'exec-missing',
213+
}));
214+
fetchMock.mockResolvedValue(createActiveResponse([]));
215+
216+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
217+
wrapper: createWrapper(),
218+
});
219+
220+
await waitFor(() => {
221+
expect(result.current.data).toEqual([]);
222+
});
223+
224+
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-running');
225+
});
226+
227+
it('clears current selection when no executions remain after sync', async () => {
228+
mockStoreState.executions = {};
229+
(mockUseCliStreamStore as any).getState.mockImplementation(() => ({
230+
...mockStoreState,
231+
currentExecutionId: 'exec-missing',
232+
}));
233+
fetchMock.mockResolvedValue(createActiveResponse([]));
234+
235+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
236+
wrapper: createWrapper(),
237+
});
238+
239+
await waitFor(() => {
240+
expect(result.current.data).toEqual([]);
241+
});
242+
243+
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith(null);
244+
});
245+
246+
it('keeps running executions when saved detail is older than active start time', async () => {
247+
const startTime = 1_741_393_000_000;
248+
249+
fetchMock.mockResolvedValue(createActiveResponse([
250+
{
251+
id: 'exec-live',
252+
tool: 'codex',
253+
mode: 'analysis',
254+
status: 'running',
255+
output: '[响应] live output',
256+
startTime,
257+
},
258+
]));
259+
260+
vi.mocked(api.fetchExecutionDetail).mockResolvedValue({
261+
id: 'exec-live',
262+
tool: 'codex',
263+
mode: 'analysis',
264+
turns: [],
265+
turn_count: 1,
266+
created_at: new Date(startTime - 20_000).toISOString(),
267+
updated_at: new Date(startTime - 10_000).toISOString(),
268+
} as any);
269+
270+
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
271+
wrapper: createWrapper(),
272+
});
273+
274+
await waitFor(() => {
275+
expect(result.current.data?.map((execution) => execution.id)).toEqual(['exec-live']);
276+
});
277+
278+
expect(mockStoreState.removeExecution).not.toHaveBeenCalled();
279+
expect(mockStoreState.upsertExecution).toHaveBeenCalledWith(
280+
'exec-live',
281+
expect.objectContaining({
282+
status: 'running',
283+
recovered: true,
284+
})
285+
);
286+
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-live');
287+
});
288+
});

0 commit comments

Comments
 (0)