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:
catlog22
2026-01-31 16:02:20 +08:00
parent 715ef12c92
commit 345437415f
33 changed files with 7049 additions and 105 deletions

View 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);
});
});
});

View File

@@ -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

View File

@@ -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 = {