mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 11:53:52 +08:00
Add end-to-end tests for workspace switching and backend tests for ask_question tool
- Implemented E2E tests for workspace switching functionality, covering scenarios such as switching workspaces, data isolation, language preference maintenance, and UI updates. - Added tests to ensure workspace data is cleared on logout and handles unsaved changes during workspace switches. - Created comprehensive backend tests for the ask_question tool, validating question creation, execution, answer handling, cancellation, and timeout scenarios. - Included edge case tests to ensure robustness against duplicate questions and invalid answers.
This commit is contained in:
412
ccw/frontend/src/stores/__tests__/notificationStore.test.ts
Normal file
412
ccw/frontend/src/stores/__tests__/notificationStore.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
// ========================================
|
||||
// NotificationStore A2UI Methods Tests
|
||||
// ========================================
|
||||
// Tests for A2UI-related notification store functionality
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useNotificationStore } from '../notificationStore';
|
||||
import type { SurfaceUpdate } from '../packages/a2ui-runtime/core/A2UITypes';
|
||||
|
||||
describe('NotificationStore A2UI Methods', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useNotificationStore.setState({
|
||||
toasts: [],
|
||||
a2uiSurfaces: new Map(),
|
||||
currentQuestion: null,
|
||||
persistentNotifications: [],
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up any listeners
|
||||
window.removeEventListener('a2ui-action', vi.fn());
|
||||
});
|
||||
|
||||
describe('addA2UINotification()', () => {
|
||||
it('should add A2UI notification to toasts array', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test-surface',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: { Text: { text: { literalString: 'Hello' } } },
|
||||
},
|
||||
],
|
||||
initialState: { key: 'value' },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface, 'Test Surface');
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
expect(result.current.toasts[0]).toMatchObject({
|
||||
type: 'a2ui',
|
||||
title: 'Test Surface',
|
||||
a2uiSurface: surface,
|
||||
a2uiState: { key: 'value' },
|
||||
dismissible: true,
|
||||
duration: 0, // Persistent by default
|
||||
});
|
||||
});
|
||||
|
||||
it('should store surface in a2uiSurfaces Map', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'surface-123',
|
||||
components: [
|
||||
{
|
||||
id: 'comp-1',
|
||||
component: { Button: { onClick: { actionId: 'click' }, content: { Text: { text: { literalString: 'Click' } } } } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(result.current.a2uiSurfaces.has('surface-123')).toBe(true);
|
||||
expect(result.current.a2uiSurfaces.get('surface-123')).toEqual(surface);
|
||||
});
|
||||
|
||||
it('should respect maxToasts limit for A2UI notifications', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
// Set max toasts to 3
|
||||
act(() => {
|
||||
result.current.maxToasts = 3;
|
||||
});
|
||||
|
||||
// Add 4 A2UI notifications
|
||||
for (let i = 0; i < 4; i++) {
|
||||
act(() => {
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: `surface-${i}`,
|
||||
components: [{ id: `comp-${i}`, component: { Text: { text: { literalString: `Test ${i}` } } } }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Should only keep last 3
|
||||
expect(result.current.toasts).toHaveLength(3);
|
||||
expect(result.current.toasts[0].a2uiSurface?.surfaceId).toBe('surface-1');
|
||||
expect(result.current.toasts[2].a2uiSurface?.surfaceId).toBe('surface-3');
|
||||
});
|
||||
|
||||
it('should use default title when not provided', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0].title).toBe('A2UI Surface');
|
||||
});
|
||||
|
||||
it('should return toast ID', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
let toastId: string;
|
||||
act(() => {
|
||||
toastId = result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(toastId).toBeDefined();
|
||||
expect(typeof toastId).toBe('string');
|
||||
expect(result.current.toasts[0].id).toBe(toastId);
|
||||
});
|
||||
|
||||
it('should include initialState in a2uiState', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
initialState: { counter: 0, user: 'Alice' },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0].a2uiState).toEqual({ counter: 0, user: 'Alice' });
|
||||
});
|
||||
|
||||
it('should default to empty a2uiState when initialState is not provided', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0].a2uiState).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateA2UIState()', () => {
|
||||
it('should update a2uiState for matching toast', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test-surface',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
initialState: { count: 0 },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateA2UIState('test-surface', { count: 5, newField: 'value' });
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0].a2uiState).toEqual({ count: 5, newField: 'value' });
|
||||
});
|
||||
|
||||
it('should update surface initialState in a2uiSurfaces Map', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test-surface',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
initialState: { value: 'initial' },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateA2UIState('test-surface', { value: 'updated' });
|
||||
});
|
||||
|
||||
const updatedSurface = result.current.a2uiSurfaces.get('test-surface');
|
||||
expect(updatedSurface?.initialState).toEqual({ value: 'updated' });
|
||||
});
|
||||
|
||||
it('should not affect other toasts with different surface IDs', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: 'surface-1',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }],
|
||||
initialState: { value: 'A' },
|
||||
});
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: 'surface-2',
|
||||
components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }],
|
||||
initialState: { value: 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.updateA2UIState('surface-1', { value: 'A-updated' });
|
||||
});
|
||||
|
||||
expect(result.current.toasts[0].a2uiState).toEqual({ value: 'A-updated' });
|
||||
expect(result.current.toasts[1].a2uiState).toEqual({ value: 'B' });
|
||||
});
|
||||
|
||||
it('should handle updates for non-existent surface gracefully', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.updateA2UIState('non-existent', { value: 'test' });
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendA2UIAction()', () => {
|
||||
it('should dispatch custom event with action details', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
const mockListener = vi.fn();
|
||||
window.addEventListener('a2ui-action', mockListener);
|
||||
|
||||
act(() => {
|
||||
result.current.sendA2UIAction('test-action', 'surface-123', { key: 'value' });
|
||||
});
|
||||
|
||||
expect(mockListener).toHaveBeenCalledTimes(1);
|
||||
const event = mockListener.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail).toEqual({
|
||||
type: 'a2ui-action',
|
||||
actionId: 'test-action',
|
||||
surfaceId: 'surface-123',
|
||||
parameters: { key: 'value' },
|
||||
});
|
||||
|
||||
window.removeEventListener('a2ui-action', mockListener);
|
||||
});
|
||||
|
||||
it('should use empty parameters object when not provided', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
const mockListener = vi.fn();
|
||||
window.addEventListener('a2ui-action', mockListener);
|
||||
|
||||
act(() => {
|
||||
result.current.sendA2UIAction('action-1', 'surface-1');
|
||||
});
|
||||
|
||||
const event = mockListener.mock.calls[0][0] as CustomEvent;
|
||||
expect(event.detail.parameters).toEqual({});
|
||||
|
||||
window.removeEventListener('a2ui-action', mockListener);
|
||||
});
|
||||
|
||||
it('should dispatch event on window object', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
act(() => {
|
||||
result.current.sendA2UIAction('test', 'surface-1', { data: 'test' });
|
||||
});
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalled();
|
||||
expect(dispatchSpy.mock.calls[0][0]).toBeInstanceOf(CustomEvent);
|
||||
expect((dispatchSpy.mock.calls[0][0] as CustomEvent).type).toBe('a2ui-action');
|
||||
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentQuestion()', () => {
|
||||
it('should set current question state', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
const mockQuestion = {
|
||||
surfaceId: 'question-1',
|
||||
title: 'Test Question',
|
||||
questions: [
|
||||
{ id: 'q1', question: 'What is your name?', type: 'text', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentQuestion(mockQuestion);
|
||||
});
|
||||
|
||||
expect(result.current.currentQuestion).toEqual(mockQuestion);
|
||||
});
|
||||
|
||||
it('should clear question when set to null', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
const mockQuestion = {
|
||||
surfaceId: 'question-1',
|
||||
title: 'Test',
|
||||
questions: [{ id: 'q1', question: 'Test?', type: 'text' }],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentQuestion(mockQuestion);
|
||||
});
|
||||
expect(result.current.currentQuestion).toEqual(mockQuestion);
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentQuestion(null);
|
||||
});
|
||||
expect(result.current.currentQuestion).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with toast actions', () => {
|
||||
it('should allow removing A2UI toast via removeToast', () => {
|
||||
const surface: SurfaceUpdate = {
|
||||
surfaceId: 'test',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
let toastId: string;
|
||||
act(() => {
|
||||
toastId = result.current.addA2UINotification(surface);
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.removeToast(toastId);
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear all A2UI toasts with clearAllToasts', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: 's1',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'A' } } } }],
|
||||
});
|
||||
result.current.addToast({ type: 'info', title: 'Regular toast' });
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: 's2',
|
||||
components: [{ id: 'c2', component: { Text: { text: { literalString: 'B' } } } }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(3);
|
||||
|
||||
act(() => {
|
||||
result.current.clearAllToasts();
|
||||
});
|
||||
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2UI surfaces Map management', () => {
|
||||
it('should maintain separate surfaces Map from toasts', () => {
|
||||
const { result } = renderHook(() => useNotificationStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addA2UINotification({
|
||||
surfaceId: 'surface-1',
|
||||
components: [{ id: 'c1', component: { Text: { text: { literalString: 'Test' } } } }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.a2uiSurfaces.size).toBe(1);
|
||||
expect(result.current.toasts).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.removeToast(result.current.toasts[0].id);
|
||||
});
|
||||
|
||||
// Surface should remain in Map even after toast is removed
|
||||
expect(result.current.a2uiSurfaces.size).toBe(1);
|
||||
expect(result.current.toasts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
selectWsLastMessage,
|
||||
selectIsPanelVisible,
|
||||
selectPersistentNotifications,
|
||||
selectCurrentQuestion,
|
||||
toast,
|
||||
} from './notificationStore';
|
||||
|
||||
@@ -113,6 +114,9 @@ export type {
|
||||
ToastType,
|
||||
WebSocketStatus,
|
||||
WebSocketMessage,
|
||||
QuestionType,
|
||||
Question,
|
||||
AskQuestionPayload,
|
||||
} from '../types/store';
|
||||
|
||||
// Execution Types
|
||||
|
||||
@@ -74,6 +74,9 @@ const initialState: NotificationState = {
|
||||
|
||||
// A2UI surfaces
|
||||
a2uiSurfaces: new Map<string, SurfaceUpdate>(),
|
||||
|
||||
// Current question dialog state
|
||||
currentQuestion: null,
|
||||
};
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>()(
|
||||
@@ -334,6 +337,12 @@ export const useNotificationStore = create<NotificationStore>()(
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
// ========== Current Question Actions ==========
|
||||
|
||||
setCurrentQuestion: (question: any) => {
|
||||
set({ currentQuestion: question }, false, 'setCurrentQuestion');
|
||||
},
|
||||
}),
|
||||
{ name: 'NotificationStore' }
|
||||
)
|
||||
@@ -354,6 +363,7 @@ export const selectWsLastMessage = (state: NotificationStore) => state.wsLastMes
|
||||
export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelVisible;
|
||||
export const selectPersistentNotifications = (state: NotificationStore) =>
|
||||
state.persistentNotifications;
|
||||
export const selectCurrentQuestion = (state: NotificationStore) => state.currentQuestion;
|
||||
|
||||
// Helper to create toast shortcuts
|
||||
export const toast = {
|
||||
|
||||
Reference in New Issue
Block a user