Add unit tests for various components and stores in the terminal dashboard

- Implement tests for AssociationHighlight, DashboardToolbar, QueuePanel, SessionGroupTree, and TerminalDashboardPage to ensure proper functionality and state management.
- Create tests for cliSessionStore, issueQueueIntegrationStore, queueExecutionStore, queueSchedulerStore, sessionManagerStore, and terminalGridStore to validate state resets and workspace scoping.
- Mock necessary dependencies and state management hooks to isolate tests and ensure accurate behavior.
This commit is contained in:
catlog22
2026-03-08 21:38:20 +08:00
parent 9aa07e8d01
commit 62d8aa3623
157 changed files with 36544 additions and 71 deletions

View File

@@ -0,0 +1,39 @@
// ========================================
// CLI Session Store Tests
// ========================================
import { beforeEach, describe, expect, it } from 'vitest';
import { useCliSessionStore } from './cliSessionStore';
describe('cliSessionStore', () => {
beforeEach(() => {
useCliSessionStore.getState().resetState();
});
it('resetState clears workspace-scoped sessions and output buffers', () => {
const store = useCliSessionStore.getState();
store.setSessions([
{
sessionKey: 'session-1',
shellKind: 'bash',
workingDir: 'D:/workspace-a',
tool: 'codex',
createdAt: '2026-03-08T12:00:00.000Z',
updatedAt: '2026-03-08T12:00:00.000Z',
isPaused: false,
},
]);
store.appendOutput('session-1', 'hello world', 1_741_430_000_000);
expect(useCliSessionStore.getState().sessions['session-1']).toBeDefined();
expect(useCliSessionStore.getState().outputChunks['session-1']).toHaveLength(1);
store.resetState();
const nextState = useCliSessionStore.getState();
expect(nextState.sessions).toEqual({});
expect(nextState.outputChunks).toEqual({});
expect(nextState.outputBytes).toEqual({});
});
});

View File

@@ -34,6 +34,7 @@ interface CliSessionState {
upsertSession: (session: CliSessionMeta) => void;
removeSession: (sessionKey: string) => void;
updateSessionPausedState: (sessionKey: string, isPaused: boolean) => void;
resetState: () => void;
setBuffer: (sessionKey: string, buffer: string) => void;
appendOutput: (sessionKey: string, data: string, timestamp?: number) => void;
@@ -48,12 +49,16 @@ function utf8ByteLength(value: string): number {
return utf8Encoder.encode(value).length;
}
const initialState = {
sessions: {},
outputChunks: {},
outputBytes: {},
};
export const useCliSessionStore = create<CliSessionState>()(
devtools(
(set, get) => ({
sessions: {},
outputChunks: {},
outputBytes: {},
...initialState,
setSessions: (sessions) =>
set((state) => {
@@ -103,6 +108,8 @@ export const useCliSessionStore = create<CliSessionState>()(
};
}),
resetState: () => set({ ...initialState }),
setBuffer: (sessionKey, buffer) =>
set((state) => ({
outputChunks: {

View File

@@ -52,7 +52,8 @@ export interface ExecutionWSMessage {
payload: {
executionId: string;
flowId: string;
sessionKey: string;
sessionKey?: string;
projectPath?: string;
stepId?: string;
stepName?: string;
totalSteps?: number;
@@ -117,7 +118,7 @@ export const useExecutionMonitorStore = create<ExecutionMonitorStore>()(
executionId,
flowId,
flowName: stepName || 'Workflow',
sessionKey,
sessionKey: sessionKey ?? '',
status: 'running',
totalSteps: totalSteps || 0,
completedSteps: 0,

View File

@@ -0,0 +1,45 @@
// ========================================
// Issue Queue Integration Store Tests
// ========================================
import { beforeEach, describe, expect, it } from 'vitest';
import { useIssueQueueIntegrationStore } from './issueQueueIntegrationStore';
import { useQueueExecutionStore } from './queueExecutionStore';
describe('issueQueueIntegrationStore', () => {
beforeEach(() => {
useIssueQueueIntegrationStore.getState().resetState();
useQueueExecutionStore.getState().resetState();
});
it('resetState clears selected issue and association chain', () => {
useQueueExecutionStore.getState().addExecution({
id: 'queue-exec-1',
queueItemId: 'Q-1',
issueId: 'ISSUE-1',
solutionId: 'SOL-1',
type: 'session',
sessionKey: 'session-1',
tool: 'codex',
mode: 'analysis',
status: 'running',
startedAt: '2026-03-08T12:00:00.000Z',
});
const store = useIssueQueueIntegrationStore.getState();
store.buildAssociationChain('ISSUE-1', 'issue');
expect(useIssueQueueIntegrationStore.getState().selectedIssueId).toBe('ISSUE-1');
expect(useIssueQueueIntegrationStore.getState().associationChain).toEqual({
issueId: 'ISSUE-1',
queueItemId: 'Q-1',
sessionId: 'session-1',
});
store.resetState();
const nextState = useIssueQueueIntegrationStore.getState();
expect(nextState.selectedIssueId).toBeNull();
expect(nextState.associationChain).toBeNull();
});
});

View File

@@ -85,6 +85,10 @@ export const useIssueQueueIntegrationStore = create<IssueQueueIntegrationStore>(
);
},
resetState: () => {
set({ ...initialState }, false, 'resetState');
},
// ========== Queue Status Bridge ==========
_updateQueueItemStatus: (

View File

@@ -0,0 +1,35 @@
// ========================================
// Queue Execution Store Tests
// ========================================
import { beforeEach, describe, expect, it } from 'vitest';
import { useQueueExecutionStore } from './queueExecutionStore';
describe('queueExecutionStore', () => {
beforeEach(() => {
useQueueExecutionStore.getState().resetState();
});
it('resetState clears workspace-scoped queue execution tracking', () => {
const store = useQueueExecutionStore.getState();
store.addExecution({
id: 'queue-exec-1',
queueItemId: 'Q-1',
issueId: 'ISSUE-1',
solutionId: 'SOL-1',
type: 'session',
sessionKey: 'session-1',
tool: 'codex',
mode: 'analysis',
status: 'running',
startedAt: '2026-03-08T12:00:00.000Z',
});
expect(useQueueExecutionStore.getState().executions['queue-exec-1']).toBeDefined();
store.resetState();
expect(useQueueExecutionStore.getState().executions).toEqual({});
});
});

View File

@@ -68,6 +68,8 @@ export interface QueueExecutionActions {
removeExecution: (id: string) => void;
/** Remove all completed and failed executions */
clearCompleted: () => void;
/** Reset workspace-scoped queue execution state */
resetState: () => void;
}
export type QueueExecutionStore = QueueExecutionState & QueueExecutionActions;
@@ -150,6 +152,10 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
'clearCompleted'
);
},
resetState: () => {
set({ ...initialState }, false, 'resetState');
},
}),
{ name: 'QueueExecutionStore' }
)

View File

@@ -0,0 +1,131 @@
// ========================================
// Queue Scheduler Store Tests
// ========================================
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueueSchedulerState } from '@/types/queue-frontend-types';
type QueueSchedulerModule = typeof import('./queueSchedulerStore');
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createState(status: QueueSchedulerState['status'], issueId: string): QueueSchedulerState {
return {
status,
items: [
{
item_id: `${issueId}-Q1`,
issue_id: issueId,
status: status === 'running' ? 'executing' : 'pending',
tool: 'codex',
prompt: `Handle ${issueId}`,
depends_on: [],
execution_order: 1,
execution_group: 'wave-1',
createdAt: '2026-03-08T12:00:00.000Z',
},
],
sessionPool: {},
config: {
maxConcurrentSessions: 3,
sessionIdleTimeoutMs: 60_000,
resumeKeySessionBindingTimeoutMs: 300_000,
},
currentConcurrency: status === 'running' ? 1 : 0,
lastActivityAt: '2026-03-08T12:00:00.000Z',
error: undefined,
};
}
function createFetchResponse(state: QueueSchedulerState) {
return {
ok: true,
json: vi.fn().mockResolvedValue(state),
};
}
describe('queueSchedulerStore', () => {
let useQueueSchedulerStore: QueueSchedulerModule['useQueueSchedulerStore'];
let fetchMock: ReturnType<typeof vi.fn>;
const originalFetch = global.fetch;
beforeAll(async () => {
vi.useFakeTimers();
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
({ useQueueSchedulerStore } = await import('./queueSchedulerStore'));
});
afterAll(() => {
vi.clearAllTimers();
vi.useRealTimers();
global.fetch = originalFetch;
});
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
useQueueSchedulerStore.getState().resetState();
});
it('resetState clears workspace-scoped scheduler state', () => {
useQueueSchedulerStore.getState().handleSchedulerMessage({
type: 'QUEUE_SCHEDULER_STATE_UPDATE',
state: createState('running', 'ISSUE-1'),
timestamp: '2026-03-08T12:00:00.000Z',
});
expect(useQueueSchedulerStore.getState().status).toBe('running');
expect(useQueueSchedulerStore.getState().items).toHaveLength(1);
useQueueSchedulerStore.getState().resetState();
const nextState = useQueueSchedulerStore.getState();
expect(nextState.status).toBe('idle');
expect(nextState.items).toEqual([]);
expect(nextState.sessionPool).toEqual({});
expect(nextState.currentConcurrency).toBe(0);
expect(nextState.error).toBeNull();
});
it('ignores stale loadInitialState responses after workspace reset', async () => {
const staleResponse = createDeferred<ReturnType<typeof createFetchResponse>>();
const freshResponse = createDeferred<ReturnType<typeof createFetchResponse>>();
fetchMock
.mockImplementationOnce(() => staleResponse.promise)
.mockImplementationOnce(() => freshResponse.promise);
const firstLoad = useQueueSchedulerStore.getState().loadInitialState();
useQueueSchedulerStore.getState().resetState();
const secondLoad = useQueueSchedulerStore.getState().loadInitialState();
freshResponse.resolve(createFetchResponse(createState('paused', 'ISSUE-NEW')));
await secondLoad;
expect(useQueueSchedulerStore.getState().status).toBe('paused');
expect(useQueueSchedulerStore.getState().items[0]?.issue_id).toBe('ISSUE-NEW');
staleResponse.resolve(createFetchResponse(createState('running', 'ISSUE-OLD')));
await firstLoad;
expect(useQueueSchedulerStore.getState().status).toBe('paused');
expect(useQueueSchedulerStore.getState().items[0]?.issue_id).toBe('ISSUE-NEW');
});
});

View File

@@ -57,6 +57,8 @@ interface QueueSchedulerActions {
stopQueue: () => Promise<void>;
/** Reset the queue scheduler via POST /api/queue/scheduler/reset */
resetQueue: () => Promise<void>;
/** Clear workspace-scoped scheduler state and invalidate stale loads */
resetState: () => void;
/** Update scheduler config via POST /api/queue/scheduler/config */
updateConfig: (config: Partial<QueueSchedulerConfig>) => Promise<void>;
}
@@ -75,6 +77,8 @@ const initialState: QueueSchedulerStoreState = {
error: null,
};
let loadInitialStateRequestVersion = 0;
// ========== Store ==========
export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
@@ -173,6 +177,8 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
},
loadInitialState: async () => {
const requestVersion = ++loadInitialStateRequestVersion;
try {
const response = await fetch('/api/queue/scheduler/state', {
credentials: 'same-origin',
@@ -181,6 +187,11 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
throw new Error(`Failed to load scheduler state: ${response.statusText}`);
}
const data: QueueSchedulerState = await response.json();
if (requestVersion !== loadInitialStateRequestVersion) {
return;
}
set(
{
status: data.status,
@@ -195,6 +206,10 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
'loadInitialState'
);
} catch (error) {
if (requestVersion !== loadInitialStateRequestVersion) {
return;
}
// Silently ignore network errors (backend not connected)
// Only log non-network errors
const message = error instanceof Error ? error.message : 'Unknown error';
@@ -287,6 +302,11 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
}
},
resetState: () => {
loadInitialStateRequestVersion += 1;
set({ ...initialState }, false, 'resetState');
},
updateConfig: async (config: Partial<QueueSchedulerConfig>) => {
try {
const response = await fetch('/api/queue/scheduler/config', {

View File

@@ -0,0 +1,37 @@
// ========================================
// Session Manager Store Tests
// ========================================
import { beforeEach, describe, expect, it } from 'vitest';
import { useSessionManagerStore } from './sessionManagerStore';
describe('sessionManagerStore', () => {
beforeEach(() => {
useSessionManagerStore.getState().resetState();
});
it('resetState clears workspace-scoped terminal metadata and selection', () => {
const store = useSessionManagerStore.getState();
store.createGroup('Workspace Group');
store.setActiveTerminal('session-1');
store.updateTerminalMeta('session-1', {
title: 'Session 1',
status: 'active',
alertCount: 2,
tag: 'workspace-a',
});
const activeState = useSessionManagerStore.getState();
expect(activeState.groups).toHaveLength(1);
expect(activeState.activeTerminalId).toBe('session-1');
expect(activeState.terminalMetas['session-1']?.status).toBe('active');
store.resetState();
const nextState = useSessionManagerStore.getState();
expect(nextState.groups).toEqual([]);
expect(nextState.activeTerminalId).toBeNull();
expect(nextState.terminalMetas).toEqual({});
});
});

View File

@@ -182,6 +182,14 @@ export const useSessionManagerStore = create<SessionManagerStore>()(
);
},
resetState: () => {
if (_workerRef) {
_workerRef.terminate();
_workerRef = null;
}
set({ ...initialState }, false, 'resetState');
},
// ========== Layout Management ==========
setGroupLayout: (layout: SessionLayout) => {

View File

@@ -0,0 +1,38 @@
// ========================================
// Terminal Grid Store Tests
// ========================================
import { beforeEach, describe, expect, it } from 'vitest';
import { useTerminalGridStore } from './terminalGridStore';
describe('terminalGridStore', () => {
beforeEach(() => {
useTerminalGridStore.getState().resetLayout('single');
});
it('resetWorkspaceState clears pane session bindings while preserving layout', () => {
const store = useTerminalGridStore.getState();
store.resetLayout('split-h');
const configuredState = useTerminalGridStore.getState();
const paneIds = Object.keys(configuredState.panes);
const originalLayout = configuredState.layout;
store.assignSession(paneIds[0], 'session-a', 'codex');
store.showFileInPane(paneIds[1], 'D:/workspace-a/file.ts');
store.setFocused(paneIds[1]);
store.resetWorkspaceState();
const nextState = useTerminalGridStore.getState();
expect(nextState.layout).toEqual(originalLayout);
expect(Object.keys(nextState.panes)).toEqual(paneIds);
expect(nextState.focusedPaneId).toBe(paneIds[1]);
for (const paneId of paneIds) {
expect(nextState.panes[paneId]?.sessionId).toBeNull();
expect(nextState.panes[paneId]?.cliTool).toBeNull();
expect(nextState.panes[paneId]?.displayMode).toBe('terminal');
expect(nextState.panes[paneId]?.filePath).toBeNull();
}
});
});

View File

@@ -45,6 +45,8 @@ export interface TerminalGridActions {
assignSession: (paneId: PaneId, sessionId: string | null, cliTool?: string | null) => void;
setFocused: (paneId: PaneId) => void;
resetLayout: (preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
/** Clear workspace-scoped pane bindings while preserving layout */
resetWorkspaceState: () => void;
/** Create a new CLI session and assign it to a new pane (auto-split from specified pane) */
createSessionAndAssign: (
paneId: PaneId,
@@ -302,6 +304,42 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
);
},
resetWorkspaceState: () => {
const state = get();
const paneIds = Object.keys(state.panes) as PaneId[];
if (paneIds.length === 0) {
set({ ...initialState }, false, 'terminalGrid/resetWorkspaceState');
return;
}
const nextPanes = paneIds.reduce<Record<PaneId, TerminalPaneState>>((acc, paneId) => {
const pane = state.panes[paneId];
acc[paneId] = {
...pane,
sessionId: null,
cliTool: null,
displayMode: 'terminal',
filePath: null,
};
return acc;
}, {} as Record<PaneId, TerminalPaneState>);
const nextFocusedPaneId = state.focusedPaneId && nextPanes[state.focusedPaneId]
? state.focusedPaneId
: paneIds[0] ?? null;
set(
{
layout: state.layout,
panes: nextPanes,
focusedPaneId: nextFocusedPaneId,
nextPaneIdCounter: state.nextPaneIdCounter,
},
false,
'terminalGrid/resetWorkspaceState'
);
},
createSessionAndAssign: async (paneId, config, projectPath) => {
try {
// 1. Create the CLI session via API