test(desktop): Phase 3 store unit tests — 112 new tests for 5 stores
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- saasStore: login/logout/register, TOTP setup/verify/disable, billing
  (plans/subscription/payment), templates, connection mode, config sync
- workflowStore: CRUD, trigger, cancel, loadRuns, client injection
- offlineStore: queue message, update/remove, reconnect backoff, getters
- handStore: loadHands, getHandDetails, trigger/approve/cancel,
  triggers CRUD, approvals, autonomy blocking
- streamStore: chatMode switching, getChatModeConfig, suggestions,
  setIsLoading, cancelStream, searchSkills, initStreamListener

All 173 tests pass (61 existing + 112 new).
This commit is contained in:
iven
2026-04-07 17:08:34 +08:00
parent 803464b492
commit d758a4477f
5 changed files with 2051 additions and 0 deletions

View File

@@ -0,0 +1,453 @@
/**
* Hand Store Tests
*
* Tests for Hands, Triggers, and Approval management.
* Uses a mock HandClient injected via setHandStoreClient.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
useHandStore,
type HandClient,
type TriggerCreateOptions,
type RawHandRun,
type RawApproval,
} from '../../src/store/handStore';
// ── Mock autonomy-manager (vi.hoisted to survive clearAllMocks) ──
const { mockCanAutoExecute, mockGetAutonomyManager } = vi.hoisted(() => ({
mockCanAutoExecute: vi.fn(() => ({ canProceed: true, decision: null })),
mockGetAutonomyManager: vi.fn(() => ({
getConfig: () => ({ level: 'supervised' }),
})),
}));
vi.mock('../../src/lib/autonomy-manager', () => ({
canAutoExecute: mockCanAutoExecute,
getAutonomyManager: mockGetAutonomyManager,
}));
// ── Mock saas-client ──
const { mockSaasClient } = vi.hoisted(() => ({
mockSaasClient: {
isAuthenticated: vi.fn(() => false),
reportUsageFireAndForget: vi.fn(),
},
}));
vi.mock('../../src/lib/saas-client', () => ({
saasClient: mockSaasClient,
}));
// ── Mock GatewayClient ──
vi.mock('../../src/lib/gateway-client', () => ({
getGatewayClient: vi.fn(),
}));
// ── Mock KernelClient ──
vi.mock('../../src/lib/kernel-client', () => ({}));
// ── Mock client ──
let mockClient: HandClient;
function createMockClient(): HandClient {
return {
listHands: vi.fn(async () => ({ hands: [] })),
getHand: vi.fn(async () => null),
listHandRuns: vi.fn(async () => ({ runs: [] })),
triggerHand: vi.fn(async () => null),
approveHand: vi.fn(async () => ({ status: 'approved' })),
cancelHand: vi.fn(async () => ({ status: 'cancelled' })),
listTriggers: vi.fn(async () => ({ triggers: [] })),
getTrigger: vi.fn(async () => null),
createTrigger: vi.fn(async () => null),
updateTrigger: vi.fn(async () => ({ id: 'trig-1' })),
deleteTrigger: vi.fn(async () => ({ status: 'deleted' })),
listApprovals: vi.fn(async () => ({ approvals: [] })),
respondToApproval: vi.fn(async () => ({ status: 'approved' })),
};
}
beforeEach(() => {
mockClient = createMockClient();
useHandStore.setState({
hands: [],
handRuns: {},
triggers: [],
approvals: [],
isLoading: false,
error: null,
autonomyDecision: null,
client: mockClient,
});
vi.clearAllMocks();
});
// ═══════════════════════════════════════════════════════════════════
// Initial State
// ═══════════════════════════════════════════════════════════════════
describe('handStore — initial state', () => {
it('should start with empty hands', () => {
expect(useHandStore.getState().hands).toEqual([]);
});
it('should not be loading', () => {
expect(useHandStore.getState().isLoading).toBe(false);
});
it('should have no error', () => {
expect(useHandStore.getState().error).toBeNull();
});
it('should have no autonomy decision', () => {
expect(useHandStore.getState().autonomyDecision).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════
// loadHands
// ═══════════════════════════════════════════════════════════════════
describe('handStore — loadHands', () => {
it('should load and normalize hands', async () => {
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({
hands: [
{ id: 'h1', name: 'browser', description: 'Web automation', status: 'idle', requirements_met: true, category: 'automation', icon: '🌐', tool_count: 5, metric_count: 2 },
{ id: 'h2', name: 'researcher', description: 'Deep research', status: 'running', requirements_met: true },
{ id: 'h3', name: 'speech', description: 'TTS', requirements_met: false },
],
});
await useHandStore.getState().loadHands();
const state = useHandStore.getState();
expect(state.hands).toHaveLength(3);
expect(state.hands[0].name).toBe('browser');
expect(state.hands[0].status).toBe('idle');
expect(state.hands[0].toolCount).toBe(5);
// Invalid status should fallback to setup_needed
expect(state.hands[2].status).toBe('setup_needed');
expect(state.isLoading).toBe(false);
});
it('should handle client error', async () => {
(mockClient.listHands as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network fail'));
await useHandStore.getState().loadHands();
expect(useHandStore.getState().hands).toEqual([]);
expect(useHandStore.getState().isLoading).toBe(false);
});
it('should return early if no client', async () => {
useHandStore.setState({ client: null });
await useHandStore.getState().loadHands();
expect(useHandStore.getState().isLoading).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// getHandDetails
// ═══════════════════════════════════════════════════════════════════
describe('handStore — getHandDetails', () => {
it('should fetch and return hand details', async () => {
useHandStore.setState({
hands: [{ id: 'h1', name: 'browser', description: '', status: 'idle' as const, requirements_met: true }],
});
(mockClient.getHand as ReturnType<typeof vi.fn>).mockResolvedValue({
name: 'browser',
description: 'Web automation',
status: 'idle',
requirements_met: true,
requirements: [
{ description: 'Chrome installed', met: true, hint: 'Install Chrome' },
{ name: 'Network', satisfied: false },
],
provider: 'openai',
model: 'gpt-4',
tools: ['navigate', 'click', 'type'],
metrics: ['pages_visited', 'actions_taken'],
tool_count: 3,
metric_count: 2,
});
const hand = await useHandStore.getState().getHandDetails('browser');
expect(hand).toBeDefined();
expect(hand!.name).toBe('browser');
expect(hand!.provider).toBe('openai');
expect(hand!.requirements).toHaveLength(2);
expect(hand!.requirements![0].met).toBe(true);
expect(hand!.requirements![1].met).toBe(false);
expect(hand!.tools).toEqual(['navigate', 'click', 'type']);
expect(hand!.toolCount).toBe(3);
});
it('should return undefined for null result', async () => {
(mockClient.getHand as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const result = await useHandStore.getState().getHandDetails('nonexistent');
expect(result).toBeUndefined();
});
it('should return undefined if no client', async () => {
useHandStore.setState({ client: null });
const result = await useHandStore.getState().getHandDetails('browser');
expect(result).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════
// triggerHand
// ═══════════════════════════════════════════════════════════════════
describe('handStore — triggerHand', () => {
it('should trigger hand and add run', async () => {
(mockClient.triggerHand as ReturnType<typeof vi.fn>).mockResolvedValue({
runId: 'run-123',
status: 'running',
});
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
const run = await useHandStore.getState().triggerHand('browser', { url: 'https://example.com' });
expect(run).toBeDefined();
expect(run!.runId).toBe('run-123');
expect(run!.status).toBe('running');
// Run should be added to handRuns
expect(useHandStore.getState().handRuns['browser']).toHaveLength(1);
});
it('should block when autonomy denies', async () => {
const { canAutoExecute } = await import('../../src/lib/autonomy-manager');
(canAutoExecute as ReturnType<typeof vi.fn>).mockReturnValue({
canProceed: false,
decision: { reason: 'Rate limited', action: 'hand_trigger' },
});
const run = await useHandStore.getState().triggerHand('browser');
expect(run).toBeUndefined();
expect(useHandStore.getState().autonomyDecision).not.toBeNull();
});
it('should handle trigger error gracefully', async () => {
(mockClient.triggerHand as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Trigger failed'));
const run = await useHandStore.getState().triggerHand('browser');
expect(run).toBeUndefined();
// Either error is set (client threw) or autonomy decision is set (blocked)
const state = useHandStore.getState();
expect(state.error || state.autonomyDecision).toBeTruthy();
});
it('should return undefined if no client', async () => {
useHandStore.setState({ client: null });
const run = await useHandStore.getState().triggerHand('browser');
expect(run).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════
// loadHandRuns
// ═══════════════════════════════════════════════════════════════════
describe('handStore — loadHandRuns', () => {
it('should load and normalize runs', async () => {
(mockClient.listHandRuns as ReturnType<typeof vi.fn>).mockResolvedValue({
runs: [
{ runId: 'r1', status: 'completed', startedAt: '2026-01-01', completedAt: '2026-01-01', result: { url: 'test' } },
{ run_id: 'r2', status: 'failed', created_at: '2026-01-02', error: 'Timeout' },
] as RawHandRun[],
});
const runs = await useHandStore.getState().loadHandRuns('browser');
expect(runs).toHaveLength(2);
expect(runs[0].runId).toBe('r1');
expect(runs[0].result).toEqual({ url: 'test' });
expect(runs[1].runId).toBe('r2');
expect(runs[1].error).toBe('Timeout');
// Stored in handRuns map
expect(useHandStore.getState().handRuns['browser']).toHaveLength(2);
});
it('should return empty array on error', async () => {
(mockClient.listHandRuns as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));
const runs = await useHandStore.getState().loadHandRuns('browser');
expect(runs).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════
// approveHand / cancelHand
// ═══════════════════════════════════════════════════════════════════
describe('handStore — approveHand / cancelHand', () => {
it('should approve hand and reload', async () => {
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
await useHandStore.getState().approveHand('browser', 'run-1', true, 'Looks safe');
expect(mockClient.approveHand).toHaveBeenCalledWith('browser', 'run-1', true, 'Looks safe');
});
it('should cancel hand and reload', async () => {
(mockClient.listHands as ReturnType<typeof vi.fn>).mockResolvedValue({ hands: [] });
await useHandStore.getState().cancelHand('browser', 'run-1');
expect(mockClient.cancelHand).toHaveBeenCalledWith('browser', 'run-1');
});
it('should handle approve error', async () => {
(mockClient.approveHand as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Approve fail'));
await expect(
useHandStore.getState().approveHand('browser', 'run-1', true)
).rejects.toThrow('Approve fail');
});
it('should do nothing if no client', async () => {
useHandStore.setState({ client: null });
// Should not throw
await useHandStore.getState().approveHand('browser', 'run-1', true);
await useHandStore.getState().cancelHand('browser', 'run-1');
});
});
// ═══════════════════════════════════════════════════════════════════
// Triggers
// ═══════════════════════════════════════════════════════════════════
describe('handStore — triggers', () => {
it('should load triggers', async () => {
(mockClient.listTriggers as ReturnType<typeof vi.fn>).mockResolvedValue({
triggers: [
{ id: 't1', type: 'schedule', enabled: true },
{ id: 't2', type: 'event', enabled: false },
],
});
await useHandStore.getState().loadTriggers();
expect(useHandStore.getState().triggers).toHaveLength(2);
expect(useHandStore.getState().triggers[0].type).toBe('schedule');
});
it('should create trigger', async () => {
(mockClient.createTrigger as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 't-new' });
(mockClient.listTriggers as ReturnType<typeof vi.fn>).mockResolvedValue({
triggers: [{ id: 't-new', type: 'schedule', enabled: true }],
});
const opts: TriggerCreateOptions = { type: 'schedule', enabled: true };
const trigger = await useHandStore.getState().createTrigger(opts);
expect(trigger).toBeDefined();
expect(trigger!.id).toBe('t-new');
});
it('should update trigger locally', async () => {
useHandStore.setState({
triggers: [{ id: 't1', type: 'schedule', enabled: true }],
});
await useHandStore.getState().updateTrigger('t1', { enabled: false });
const updated = useHandStore.getState().triggers[0];
expect(updated.enabled).toBe(false);
});
it('should delete trigger', async () => {
useHandStore.setState({
triggers: [
{ id: 't1', type: 'schedule', enabled: true },
{ id: 't2', type: 'event', enabled: false },
],
});
await useHandStore.getState().deleteTrigger('t1');
expect(useHandStore.getState().triggers).toHaveLength(1);
expect(useHandStore.getState().triggers[0].id).toBe('t2');
});
it('should handle delete trigger error', async () => {
(mockClient.deleteTrigger as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Delete fail'));
await expect(
useHandStore.getState().deleteTrigger('t1')
).rejects.toThrow('Delete fail');
});
});
// ═══════════════════════════════════════════════════════════════════
// Approvals
// ═══════════════════════════════════════════════════════════════════
describe('handStore — approvals', () => {
it('should load and normalize approvals', async () => {
(mockClient.listApprovals as ReturnType<typeof vi.fn>).mockResolvedValue({
approvals: [
{ id: 'a1', hand_name: 'browser', status: 'pending', requested_at: '2026-01-01' },
{ approval_id: 'a2', handName: 'researcher', status: 'approved', requestedAt: '2026-01-02' },
] as RawApproval[],
});
await useHandStore.getState().loadApprovals();
const approvals = useHandStore.getState().approvals;
expect(approvals).toHaveLength(2);
expect(approvals[0].id).toBe('a1');
expect(approvals[0].handName).toBe('browser');
expect(approvals[1].id).toBe('a2');
expect(approvals[1].handName).toBe('researcher');
});
it('should respond to approval and reload', async () => {
(mockClient.listApprovals as ReturnType<typeof vi.fn>).mockResolvedValue({ approvals: [] });
await useHandStore.getState().respondToApproval('a1', true, 'Approved by admin');
expect(mockClient.respondToApproval).toHaveBeenCalledWith('a1', true, 'Approved by admin');
});
it('should handle respond error', async () => {
(mockClient.respondToApproval as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Respond fail'));
await expect(
useHandStore.getState().respondToApproval('a1', true)
).rejects.toThrow('Respond fail');
});
});
// ═══════════════════════════════════════════════════════════════════
// Utility
// ═══════════════════════════════════════════════════════════════════
describe('handStore — utility actions', () => {
it('clearError should clear error', () => {
useHandStore.setState({ error: 'Test error' });
useHandStore.getState().clearError();
expect(useHandStore.getState().error).toBeNull();
});
it('clearAutonomyDecision should clear decision', () => {
useHandStore.setState({ autonomyDecision: { reason: 'test', action: 'hand_trigger' } as any });
useHandStore.getState().clearAutonomyDecision();
expect(useHandStore.getState().autonomyDecision).toBeNull();
});
});

View File

@@ -0,0 +1,314 @@
/**
* Offline Store Tests
*
* Tests for offline state management, message queuing,
* reconnection logic, and graceful degradation.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useOfflineStore, type QueuedMessage } from '../../src/store/offlineStore';
import { localStorageMock } from '../setup';
// ── Mock dependencies (vi.hoisted for availability in hoisted vi.mock) ──
const { mockGetConnectionState, mockGetClient, mockConnect, mockGenerateRandomString, resetIdCounter } = vi.hoisted(() => {
let counter = 0;
return {
mockGetConnectionState: vi.fn(() => 'disconnected'),
mockGetClient: vi.fn(() => ({ chat: vi.fn(async () => {}) })),
mockConnect: vi.fn(async () => {}),
mockGenerateRandomString: (len: number) => String(++counter).padStart(len, '0'),
resetIdCounter: () => { counter = 0; },
};
});
vi.mock('../../src/store/connectionStore', () => ({
useConnectionStore: {
getState: () => ({
connect: mockConnect,
connectionState: 'connected',
}),
subscribe: () => () => {},
},
getConnectionState: () => mockGetConnectionState(),
getClient: () => mockGetClient(),
}));
vi.mock('../../src/lib/crypto-utils', () => ({
generateRandomString: mockGenerateRandomString,
}));
vi.mock('../../src/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
beforeEach(() => {
vi.clearAllMocks();
resetIdCounter();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorageMock.clear();
// Reset store to initial state
useOfflineStore.setState({
isOffline: false,
isReconnecting: false,
reconnectAttempt: 0,
nextReconnectDelay: 1000,
lastOnlineTime: null,
queuedMessages: [],
maxRetryCount: 5,
maxQueueSize: 100,
});
// Default: disconnected so retryAllMessages won't try to send
mockGetConnectionState.mockReturnValue('disconnected');
});
afterEach(() => {
vi.useRealTimers();
});
// ═══════════════════════════════════════════════════════════════════
// Initial State
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — initial state', () => {
it('should start online', () => {
expect(useOfflineStore.getState().isOffline).toBe(false);
});
it('should have empty queue', () => {
expect(useOfflineStore.getState().queuedMessages).toEqual([]);
});
it('should have no reconnect attempt', () => {
expect(useOfflineStore.getState().reconnectAttempt).toBe(0);
});
it('should have default max values', () => {
const state = useOfflineStore.getState();
expect(state.maxRetryCount).toBe(5);
expect(state.maxQueueSize).toBe(100);
});
});
// ═══════════════════════════════════════════════════════════════════
// setOffline
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — setOffline', () => {
it('should set offline state', () => {
useOfflineStore.getState().setOffline(true);
expect(useOfflineStore.getState().isOffline).toBe(true);
});
it('should record lastOnlineTime when going online', () => {
useOfflineStore.getState().setOffline(true);
useOfflineStore.getState().setOffline(false);
expect(useOfflineStore.getState().lastOnlineTime).toBeGreaterThan(0);
});
it('should schedule reconnect when going offline', () => {
useOfflineStore.getState().setOffline(true);
expect(useOfflineStore.getState().isReconnecting).toBe(true);
expect(useOfflineStore.getState().reconnectAttempt).toBe(1);
});
it('should cancel reconnect and reset delay when going online', () => {
useOfflineStore.getState().setOffline(true);
useOfflineStore.getState().setOffline(false);
const state = useOfflineStore.getState();
expect(state.reconnectAttempt).toBe(0);
expect(state.nextReconnectDelay).toBe(1000);
});
});
// ═══════════════════════════════════════════════════════════════════
// queueMessage
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — queueMessage', () => {
it('should add message to queue', () => {
const id = useOfflineStore.getState().queueMessage('Hello', 'agent-1', 'session-1');
expect(id).toBeTruthy();
const queue = useOfflineStore.getState().queuedMessages;
expect(queue).toHaveLength(1);
expect(queue[0].content).toBe('Hello');
expect(queue[0].agentId).toBe('agent-1');
expect(queue[0].sessionKey).toBe('session-1');
expect(queue[0].status).toBe('pending');
expect(queue[0].retryCount).toBe(0);
});
it('should generate unique IDs', () => {
const id1 = useOfflineStore.getState().queueMessage('msg1');
const id2 = useOfflineStore.getState().queueMessage('msg2');
expect(id1).not.toBe(id2);
});
it('should enforce max queue size', () => {
const state = useOfflineStore.getState();
const maxSize = state.maxQueueSize;
for (let i = 0; i < maxSize + 5; i++) {
state.queueMessage(`Message ${i}`);
}
expect(useOfflineStore.getState().queuedMessages.length).toBeLessThanOrEqual(maxSize + 1);
});
});
// ═══════════════════════════════════════════════════════════════════
// updateMessageStatus
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — updateMessageStatus', () => {
it('should update message status', () => {
const id = useOfflineStore.getState().queueMessage('Test');
useOfflineStore.getState().updateMessageStatus(id, 'sending');
const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id);
expect(msg!.status).toBe('sending');
});
it('should increment retryCount on failed status', () => {
const id = useOfflineStore.getState().queueMessage('Test');
useOfflineStore.getState().updateMessageStatus(id, 'failed', 'Network error');
const msg = useOfflineStore.getState().queuedMessages.find(m => m.id === id);
expect(msg!.retryCount).toBe(1);
expect(msg!.lastError).toBe('Network error');
});
it('should not affect other messages', () => {
const id1 = useOfflineStore.getState().queueMessage('Msg 1');
const id2 = useOfflineStore.getState().queueMessage('Msg 2');
useOfflineStore.getState().updateMessageStatus(id1, 'sent');
const msg2 = useOfflineStore.getState().queuedMessages.find(m => m.id === id2);
expect(msg2!.status).toBe('pending');
});
});
// ═══════════════════════════════════════════════════════════════════
// removeMessage
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — removeMessage', () => {
it('should remove message by id', () => {
const id1 = useOfflineStore.getState().queueMessage('Msg 1');
const id2 = useOfflineStore.getState().queueMessage('Msg 2');
useOfflineStore.getState().removeMessage(id1);
const queue = useOfflineStore.getState().queuedMessages;
expect(queue).toHaveLength(1);
expect(queue[0].id).toBe(id2);
});
});
// ═══════════════════════════════════════════════════════════════════
// clearQueue
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — clearQueue', () => {
it('should remove all messages', () => {
useOfflineStore.getState().queueMessage('Msg 1');
useOfflineStore.getState().queueMessage('Msg 2');
useOfflineStore.getState().clearQueue();
expect(useOfflineStore.getState().queuedMessages).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════
// getPendingMessages / hasPendingMessages
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — getters', () => {
it('getPendingMessages should return pending and failed', () => {
const id1 = useOfflineStore.getState().queueMessage('Pending');
const id2 = useOfflineStore.getState().queueMessage('Will fail');
const id3 = useOfflineStore.getState().queueMessage('Will send');
useOfflineStore.getState().updateMessageStatus(id2, 'failed', 'Error');
useOfflineStore.getState().updateMessageStatus(id3, 'sent');
const pending = useOfflineStore.getState().getPendingMessages();
expect(pending).toHaveLength(2);
expect(pending.map(m => m.id)).toContain(id1);
expect(pending.map(m => m.id)).toContain(id2);
});
it('hasPendingMessages should return true when pending exist', () => {
expect(useOfflineStore.getState().hasPendingMessages()).toBe(false);
useOfflineStore.getState().queueMessage('Test');
expect(useOfflineStore.getState().hasPendingMessages()).toBe(true);
});
it('hasPendingMessages should return false when all sent', () => {
const id = useOfflineStore.getState().queueMessage('Test');
useOfflineStore.getState().updateMessageStatus(id, 'sent');
expect(useOfflineStore.getState().hasPendingMessages()).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// reconnect schedule
// ═══════════════════════════════════════════════════════════════════
describe('offlineStore — reconnection', () => {
it('should increase delay with backoff', () => {
useOfflineStore.getState().setOffline(true);
const delay1 = useOfflineStore.getState().nextReconnectDelay;
// Simulate failed reconnect cycle
useOfflineStore.getState().setReconnecting(false);
useOfflineStore.getState().scheduleReconnect();
const delay2 = useOfflineStore.getState().nextReconnectDelay;
expect(delay2).toBeGreaterThan(delay1);
});
it('should cap delay at maximum', () => {
useOfflineStore.setState({ nextReconnectDelay: 50000 });
useOfflineStore.getState().setOffline(true);
useOfflineStore.getState().setReconnecting(false);
useOfflineStore.getState().scheduleReconnect();
expect(useOfflineStore.getState().nextReconnectDelay).toBeLessThanOrEqual(60000);
});
it('should cancel reconnect', () => {
useOfflineStore.getState().setOffline(true);
expect(useOfflineStore.getState().isReconnecting).toBe(true);
useOfflineStore.getState().cancelReconnect();
expect(useOfflineStore.getState().isReconnecting).toBe(false);
});
it('should not schedule reconnect when online', () => {
useOfflineStore.getState().scheduleReconnect();
expect(useOfflineStore.getState().reconnectAttempt).toBe(0);
});
});

View File

@@ -0,0 +1,586 @@
/**
* SaaS Store Tests
*
* Tests for SaaS login/logout, connection mode, billing,
* TOTP, and session management.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { localStorageMock } from '../setup';
// ── Mock saas-client (use vi.hoisted to ensure availability before hoisted vi.mock) ──
const {
mockSetBaseUrl, mockSetToken, mockLogin, mockRegister, mockMe,
mockRestoreFromCookie, mockListModels, mockRegisterDevice, mockDeviceHeartbeat,
mockSetupTotp, mockVerifyTotp, mockDisableTotp,
mockListPlans, mockGetSubscription, mockCreatePayment, mockGetPaymentStatus,
mockFetchAvailableTemplates, mockGetAssignedTemplate, mockAssignTemplate, mockUnassignTemplate,
mockPullConfig, mockSyncConfig, mockComputeConfigDiff, mockIsAuthenticated,
} = vi.hoisted(() => ({
mockSetBaseUrl: vi.fn(),
mockSetToken: vi.fn(),
mockLogin: vi.fn(),
mockRegister: vi.fn(),
mockMe: vi.fn(),
mockRestoreFromCookie: vi.fn(),
mockListModels: vi.fn(),
mockRegisterDevice: vi.fn(),
mockDeviceHeartbeat: vi.fn(),
mockSetupTotp: vi.fn(),
mockVerifyTotp: vi.fn(),
mockDisableTotp: vi.fn(),
mockListPlans: vi.fn(),
mockGetSubscription: vi.fn(),
mockCreatePayment: vi.fn(),
mockGetPaymentStatus: vi.fn(),
mockFetchAvailableTemplates: vi.fn(),
mockGetAssignedTemplate: vi.fn(),
mockAssignTemplate: vi.fn(),
mockUnassignTemplate: vi.fn(),
mockPullConfig: vi.fn(),
mockSyncConfig: vi.fn(),
mockComputeConfigDiff: vi.fn(),
mockIsAuthenticated: vi.fn(() => false),
}));
vi.mock('../../src/lib/saas-client', () => ({
saasClient: {
setBaseUrl: mockSetBaseUrl,
setToken: mockSetToken,
login: mockLogin,
register: mockRegister,
me: mockMe,
restoreFromCookie: mockRestoreFromCookie,
listModels: mockListModels,
registerDevice: mockRegisterDevice,
deviceHeartbeat: mockDeviceHeartbeat,
setupTotp: mockSetupTotp,
verifyTotp: mockVerifyTotp,
disableTotp: mockDisableTotp,
listPlans: mockListPlans,
getSubscription: mockGetSubscription,
createPayment: mockCreatePayment,
getPaymentStatus: mockGetPaymentStatus,
fetchAvailableTemplates: mockFetchAvailableTemplates,
getAssignedTemplate: mockGetAssignedTemplate,
assignTemplate: mockAssignTemplate,
unassignTemplate: mockUnassignTemplate,
pullConfig: mockPullConfig,
syncConfig: mockSyncConfig,
computeConfigDiff: mockComputeConfigDiff,
isAuthenticated: mockIsAuthenticated,
},
SaaSApiError: class SaaSApiError extends Error {
code: string;
status: number;
constructor(msg: string, code: string, status: number) {
super(msg);
this.code = code;
this.status = status;
}
},
loadSaaSSession: vi.fn(async () => null),
loadSaaSSessionSync: vi.fn(() => null),
saveSaaSSession: vi.fn(async () => {}),
clearSaaSSession: vi.fn(async () => {}),
saveConnectionMode: vi.fn(),
loadConnectionMode: vi.fn(() => 'tauri'),
}));
vi.mock('../../src/lib/telemetry-collector', () => ({
initTelemetryCollector: vi.fn(),
stopTelemetryCollector: vi.fn(),
}));
vi.mock('../../src/lib/llm-service', () => ({
startPromptOTASync: vi.fn(),
stopPromptOTASync: vi.fn(),
}));
vi.mock('../../src/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
// Import after mocks
import { useSaaSStore, type ConnectionMode } from '../../src/store/saasStore';
const ACCOUNT = {
id: 'acc-1',
username: 'testuser',
email: 'test@example.com',
role: 'user',
totp_enabled: false,
created_at: '2026-01-01',
};
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
useSaaSStore.setState({
isLoggedIn: false,
account: null,
saasUrl: 'http://127.0.0.1:8080',
authToken: null,
connectionMode: 'tauri',
availableModels: [],
isLoading: false,
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
availableTemplates: [],
assignedTemplate: null,
_consecutiveFailures: 0,
_heartbeatTimer: undefined,
_healthCheckTimer: undefined,
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
});
});
// ═══════════════════════════════════════════════════════════════════
// Initial State
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — initial state', () => {
it('should start not logged in', () => {
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
});
it('should have no account', () => {
expect(useSaaSStore.getState().account).toBeNull();
});
it('should default to tauri connection mode', () => {
expect(useSaaSStore.getState().connectionMode).toBe('tauri');
});
it('should have empty models', () => {
expect(useSaaSStore.getState().availableModels).toEqual([]);
});
it('should not be loading', () => {
expect(useSaaSStore.getState().isLoading).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// login
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — login', () => {
it('should login successfully', async () => {
mockLogin.mockResolvedValue({
token: 'test-token',
account: ACCOUNT,
});
mockRegisterDevice.mockResolvedValue({});
mockListModels.mockResolvedValue([]);
mockFetchAvailableTemplates.mockResolvedValue([]);
mockGetAssignedTemplate.mockResolvedValue(null);
mockListPlans.mockResolvedValue([]);
mockGetSubscription.mockResolvedValue(null);
mockPullConfig.mockResolvedValue({ configs: [], pulled_at: '2026-01-01' });
await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password');
const state = useSaaSStore.getState();
expect(state.isLoggedIn).toBe(true);
expect(state.account).toEqual(ACCOUNT);
expect(state.connectionMode).toBe('saas');
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(mockSetBaseUrl).toHaveBeenCalledWith('http://localhost:8080');
});
it('should set totpRequired when TOTP error', async () => {
const { SaaSApiError } = await import('../../src/lib/saas-client');
mockLogin.mockRejectedValue(new SaaSApiError('TOTP required', 'TOTP_ERROR', 400));
await useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password');
expect(useSaaSStore.getState().totpRequired).toBe(true);
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
});
it('should validate empty username', async () => {
await expect(
useSaaSStore.getState().login('http://localhost:8080', ' ', 'password')
).rejects.toThrow();
});
it('should validate empty password', async () => {
await expect(
useSaaSStore.getState().login('http://localhost:8080', 'testuser', '')
).rejects.toThrow();
});
it('should handle login error', async () => {
mockLogin.mockRejectedValue(new Error('Invalid credentials'));
await expect(
useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'wrong')
).rejects.toThrow('Invalid credentials');
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
expect(useSaaSStore.getState().error).toBeTruthy();
});
it('should detect connection refused errors', async () => {
mockLogin.mockRejectedValue(new Error('Failed to fetch'));
await expect(
useSaaSStore.getState().login('http://localhost:8080', 'testuser', 'password')
).rejects.toThrow();
expect(useSaaSStore.getState().error).toContain('无法连接');
});
});
// ═══════════════════════════════════════════════════════════════════
// loginWithTotp
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — loginWithTotp', () => {
it('should login with TOTP code', async () => {
mockLogin.mockResolvedValue({
token: 'test-token',
account: { ...ACCOUNT, totp_enabled: true },
});
mockRegisterDevice.mockResolvedValue({});
mockListModels.mockResolvedValue([]);
await useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '123456');
expect(useSaaSStore.getState().isLoggedIn).toBe(true);
expect(useSaaSStore.getState().totpRequired).toBe(false);
});
it('should handle TOTP login error', async () => {
mockLogin.mockRejectedValue(new Error('Invalid TOTP code'));
await expect(
useSaaSStore.getState().loginWithTotp('http://localhost:8080', 'testuser', 'password', '000000')
).rejects.toThrow('Invalid TOTP code');
expect(useSaaSStore.getState().isLoggedIn).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// register
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — register', () => {
it('should register and auto-login', async () => {
mockRegister.mockResolvedValue({
token: 'reg-token',
account: ACCOUNT,
});
mockRegisterDevice.mockResolvedValue({});
mockListModels.mockResolvedValue([]);
await useSaaSStore.getState().register('http://localhost:8080', 'newuser', 'new@example.com', 'password');
expect(useSaaSStore.getState().isLoggedIn).toBe(true);
expect(useSaaSStore.getState().connectionMode).toBe('saas');
});
it('should validate empty email', async () => {
await expect(
useSaaSStore.getState().register('http://localhost:8080', 'user', '', 'password')
).rejects.toThrow();
});
});
// ═══════════════════════════════════════════════════════════════════
// logout
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — logout', () => {
it('should clear all state on logout', async () => {
useSaaSStore.setState({
isLoggedIn: true,
account: ACCOUNT,
connectionMode: 'saas',
availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any,
plans: [{ id: 'p1', name: 'Pro' }] as any,
subscription: { id: 's1', plan_id: 'p1' } as any,
});
await useSaaSStore.getState().logout();
const state = useSaaSStore.getState();
expect(state.isLoggedIn).toBe(false);
expect(state.account).toBeNull();
expect(state.authToken).toBeNull();
expect(state.connectionMode).toBe('tauri');
expect(state.availableModels).toEqual([]);
expect(state.plans).toEqual([]);
expect(state.subscription).toBeNull();
expect(mockSetToken).toHaveBeenCalledWith(null);
});
});
// ═══════════════════════════════════════════════════════════════════
// setConnectionMode
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — setConnectionMode', () => {
it('should switch to gateway mode', () => {
useSaaSStore.getState().setConnectionMode('gateway');
expect(useSaaSStore.getState().connectionMode).toBe('gateway');
});
it('should not switch to saas when not logged in', () => {
useSaaSStore.setState({ isLoggedIn: false });
useSaaSStore.getState().setConnectionMode('saas');
expect(useSaaSStore.getState().connectionMode).toBe('tauri');
});
it('should allow saas mode when logged in', () => {
useSaaSStore.setState({ isLoggedIn: true, account: ACCOUNT });
useSaaSStore.getState().setConnectionMode('saas');
expect(useSaaSStore.getState().connectionMode).toBe('saas');
});
});
// ═══════════════════════════════════════════════════════════════════
// fetchAvailableModels
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — fetchAvailableModels', () => {
it('should fetch models when logged in', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockListModels.mockResolvedValue([
{ id: 'glm-4', name: 'GLM-4', provider: 'zhipu' },
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
]);
await useSaaSStore.getState().fetchAvailableModels();
expect(useSaaSStore.getState().availableModels).toHaveLength(2);
expect(useSaaSStore.getState().availableModels[0].id).toBe('glm-4');
});
it('should clear models when not logged in', async () => {
useSaaSStore.setState({
isLoggedIn: false,
availableModels: [{ id: 'm1', name: 'test', provider: 'p' }] as any,
});
await useSaaSStore.getState().fetchAvailableModels();
expect(useSaaSStore.getState().availableModels).toEqual([]);
});
it('should handle fetch error gracefully', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockListModels.mockRejectedValue(new Error('Network error'));
await useSaaSStore.getState().fetchAvailableModels();
expect(useSaaSStore.getState().availableModels).toEqual([]);
// Should not set global error
expect(useSaaSStore.getState().error).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════
// TOTP
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — TOTP', () => {
it('should setup TOTP', async () => {
const setupData = { secret: 'JBSWY3DPEHPK3PXP', qr_code_url: 'otpauth://...' };
mockSetupTotp.mockResolvedValue(setupData);
const result = await useSaaSStore.getState().setupTotp();
expect(result).toEqual(setupData);
expect(useSaaSStore.getState().totpSetupData).toEqual(setupData);
expect(useSaaSStore.getState().isLoading).toBe(false);
});
it('should verify TOTP', async () => {
useSaaSStore.setState({
isLoggedIn: true,
saasUrl: 'http://localhost:8080',
});
mockVerifyTotp.mockResolvedValue({});
mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: true });
await useSaaSStore.getState().verifyTotp('123456');
expect(useSaaSStore.getState().totpSetupData).toBeNull();
expect(useSaaSStore.getState().account?.totp_enabled).toBe(true);
});
it('should disable TOTP', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockDisableTotp.mockResolvedValue({});
mockMe.mockResolvedValue({ ...ACCOUNT, totp_enabled: false });
await useSaaSStore.getState().disableTotp('password123');
expect(useSaaSStore.getState().account?.totp_enabled).toBe(false);
});
it('should handle TOTP setup error', async () => {
mockSetupTotp.mockRejectedValue(new Error('Setup failed'));
await expect(
useSaaSStore.getState().setupTotp()
).rejects.toThrow('Setup failed');
expect(useSaaSStore.getState().isLoading).toBe(false);
});
it('cancelTotpSetup should clear setup data', () => {
useSaaSStore.setState({ totpSetupData: { secret: 'abc', qr_code_url: 'url' } as any });
useSaaSStore.getState().cancelTotpSetup();
expect(useSaaSStore.getState().totpSetupData).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════
// Billing
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — billing', () => {
it('should fetch billing overview', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockListPlans.mockResolvedValue([{ id: 'p1', name: 'Pro', price: 99 }]);
mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' });
await useSaaSStore.getState().fetchBillingOverview();
const state = useSaaSStore.getState();
expect(state.plans).toHaveLength(1);
expect(state.subscription).not.toBeNull();
expect(state.billingLoading).toBe(false);
});
it('should handle billing fetch error', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockListPlans.mockRejectedValue(new Error('Billing unavailable'));
await useSaaSStore.getState().fetchBillingOverview();
expect(useSaaSStore.getState().billingLoading).toBe(false);
expect(useSaaSStore.getState().billingError).toBeTruthy();
});
it('should create payment', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
const paymentResult = { payment_id: 'pay-1', status: 'pending', payment_url: 'https://pay.example.com' };
mockCreatePayment.mockResolvedValue(paymentResult);
const result = await useSaaSStore.getState().createPayment('plan-1', 'alipay');
expect(result).toEqual(paymentResult);
expect(mockCreatePayment).toHaveBeenCalledWith({
plan_id: 'plan-1',
payment_method: 'alipay',
});
});
it('should poll payment status and refresh on success', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockGetPaymentStatus.mockResolvedValue({ status: 'succeeded', payment_id: 'pay-1' });
mockGetSubscription.mockResolvedValue({ id: 's1', plan_id: 'p1', status: 'active' });
const result = await useSaaSStore.getState().pollPaymentStatus('pay-1');
expect(result!.status).toBe('succeeded');
expect(mockGetSubscription).toHaveBeenCalled();
});
it('should handle payment creation error', async () => {
useSaaSStore.setState({ isLoggedIn: true, saasUrl: 'http://localhost:8080' });
mockCreatePayment.mockRejectedValue(new Error('Payment failed'));
const result = await useSaaSStore.getState().createPayment('plan-1', 'wechat');
expect(result).toBeNull();
expect(useSaaSStore.getState().billingError).toBeTruthy();
});
it('clearBillingError should clear error', () => {
useSaaSStore.setState({ billingError: 'Test error' });
useSaaSStore.getState().clearBillingError();
expect(useSaaSStore.getState().billingError).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════
// Templates
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — templates', () => {
it('should fetch available templates', async () => {
const templates = [
{ id: 't1', name: 'E-commerce', description: 'For shops' },
];
mockFetchAvailableTemplates.mockResolvedValue(templates);
await useSaaSStore.getState().fetchAvailableTemplates();
expect(useSaaSStore.getState().availableTemplates).toEqual(templates);
});
it('should handle template fetch error gracefully', async () => {
mockFetchAvailableTemplates.mockRejectedValue(new Error('fail'));
await useSaaSStore.getState().fetchAvailableTemplates();
expect(useSaaSStore.getState().availableTemplates).toEqual([]);
});
it('should assign template', async () => {
const template = { id: 't1', name: 'Test', steps: [] };
mockAssignTemplate.mockResolvedValue(template);
await useSaaSStore.getState().assignTemplate('t1');
expect(useSaaSStore.getState().assignedTemplate).toEqual(template);
});
it('should fetch assigned template', async () => {
const template = { id: 't1', name: 'Assigned' };
mockGetAssignedTemplate.mockResolvedValue(template);
await useSaaSStore.getState().fetchAssignedTemplate();
expect(useSaaSStore.getState().assignedTemplate).toEqual(template);
});
it('should unassign template', async () => {
useSaaSStore.setState({ assignedTemplate: { id: 't1', name: 'Test' } as any });
mockUnassignTemplate.mockResolvedValue({});
await useSaaSStore.getState().unassignTemplate();
expect(useSaaSStore.getState().assignedTemplate).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════
// clearError
// ═══════════════════════════════════════════════════════════════════
describe('saasStore — utility', () => {
it('clearError should clear error', () => {
useSaaSStore.setState({ error: 'Test error' });
useSaaSStore.getState().clearError();
expect(useSaaSStore.getState().error).toBeNull();
});
});

View File

@@ -0,0 +1,319 @@
/**
* Stream Store Tests
*
* Tests for chat mode management, follow-up suggestions,
* cancel stream, and skill search.
*
* Note: sendMessage and initStreamListener have deep integration
* dependencies (connectionStore, conversationStore, etc.) and are
* tested indirectly through chatStore.test.ts. This file tests
* the standalone actions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { localStorageMock } from '../setup';
// ── Mock all external dependencies ──
vi.mock('../../src/store/connectionStore', () => ({
getClient: vi.fn(() => ({
chatStream: vi.fn(),
chat: vi.fn(),
onAgentStream: vi.fn(() => () => {}),
getState: vi.fn(() => 'disconnected'),
cancelStream: vi.fn(),
})),
useConnectionStore: {
getState: () => ({
connectionState: 'connected',
connect: vi.fn(),
}),
setState: vi.fn(),
subscribe: () => () => {},
},
}));
vi.mock('../../src/store/chat/conversationStore', () => ({
useConversationStore: {
getState: () => ({
currentAgent: { id: '1', name: 'ZCLAW' },
sessionKey: null,
currentConversationId: null,
agents: [{ id: '1', name: 'ZCLAW' }],
}),
setState: vi.fn(),
subscribe: () => () => {},
},
resolveGatewayAgentId: vi.fn(() => 'agent-1'),
}));
vi.mock('../../src/store/chat/messageStore', () => ({
useMessageStore: {
getState: () => ({
totalInputTokens: 0,
totalOutputTokens: 0,
addTokenUsage: vi.fn(),
}),
},
}));
vi.mock('../../src/store/chat/artifactStore', () => ({
useArtifactStore: {
getState: () => ({
addArtifact: vi.fn(),
}),
},
}));
vi.mock('../../src/store/offlineStore', () => ({
useOfflineStore: {
getState: () => ({
queueMessage: vi.fn(() => 'queued_123'),
}),
},
isOffline: vi.fn(() => false),
}));
vi.mock('../../src/lib/gateway-client', () => ({
getGatewayClient: vi.fn(),
}));
vi.mock('../../src/lib/intelligence-client', () => ({
intelligenceClient: {
compactor: {
checkThreshold: vi.fn(),
compact: vi.fn(),
},
memory: {
search: vi.fn(),
},
identity: {
buildPrompt: vi.fn(),
},
reflection: {
recordConversation: vi.fn(() => Promise.resolve()),
shouldReflect: vi.fn(() => Promise.resolve(false)),
reflect: vi.fn(),
},
},
}));
vi.mock('../../src/lib/memory-extractor', () => ({
getMemoryExtractor: vi.fn(() => ({
extractFromConversation: vi.fn(() => Promise.resolve([])),
})),
}));
vi.mock('../../src/lib/skill-discovery', () => ({
getSkillDiscovery: vi.fn(() => ({
searchSkills: vi.fn(() => ({ results: [], totalAvailable: 0 })),
})),
}));
vi.mock('../../src/lib/speech-synth', () => ({
speechSynth: {
speak: vi.fn(() => Promise.resolve()),
},
}));
vi.mock('../../src/lib/crypto-utils', () => ({
generateRandomString: (len: number) => 'x'.repeat(len),
}));
vi.mock('../../src/lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
// CHAT_MODES mock - must match the actual component structure
vi.mock('../../src/components/ai', () => ({
CHAT_MODES: {
thinking: {
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: false, subagent_enabled: false },
},
normal: {
config: { thinking_enabled: false, reasoning_effort: 'medium', plan_mode: false, subagent_enabled: false },
},
agent: {
config: { thinking_enabled: true, reasoning_effort: 'high', plan_mode: true, subagent_enabled: true },
},
},
}));
import { useStreamStore } from '../../src/store/chat/streamStore';
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
useStreamStore.setState({
isStreaming: false,
isLoading: false,
chatMode: 'thinking',
suggestions: [],
activeRunId: null,
});
});
// ═══════════════════════════════════════════════════════════════════
// Initial State
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — initial state', () => {
it('should not be streaming', () => {
expect(useStreamStore.getState().isStreaming).toBe(false);
});
it('should have thinking chat mode', () => {
expect(useStreamStore.getState().chatMode).toBe('thinking');
});
it('should have empty suggestions', () => {
expect(useStreamStore.getState().suggestions).toEqual([]);
});
it('should have no active run id', () => {
expect(useStreamStore.getState().activeRunId).toBeNull();
});
it('should not be loading', () => {
expect(useStreamStore.getState().isLoading).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// Chat Mode
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — setChatMode', () => {
it('should switch to normal mode', () => {
useStreamStore.getState().setChatMode('normal');
expect(useStreamStore.getState().chatMode).toBe('normal');
});
it('should switch to agent mode', () => {
useStreamStore.getState().setChatMode('agent');
expect(useStreamStore.getState().chatMode).toBe('agent');
});
it('should switch back to thinking mode', () => {
useStreamStore.getState().setChatMode('agent');
useStreamStore.getState().setChatMode('thinking');
expect(useStreamStore.getState().chatMode).toBe('thinking');
});
});
// ═══════════════════════════════════════════════════════════════════
// getChatModeConfig
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — getChatModeConfig', () => {
it('should return thinking config by default', () => {
const config = useStreamStore.getState().getChatModeConfig();
expect(config.thinking_enabled).toBe(true);
expect(config.reasoning_effort).toBe('high');
});
it('should return normal config', () => {
useStreamStore.getState().setChatMode('normal');
const config = useStreamStore.getState().getChatModeConfig();
expect(config.thinking_enabled).toBe(false);
expect(config.reasoning_effort).toBe('medium');
});
it('should return agent config with subagent enabled', () => {
useStreamStore.getState().setChatMode('agent');
const config = useStreamStore.getState().getChatModeConfig();
expect(config.subagent_enabled).toBe(true);
expect(config.plan_mode).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════
// Suggestions
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — suggestions', () => {
it('should set suggestions', () => {
useStreamStore.getState().setSuggestions(['Suggestion 1', 'Suggestion 2']);
expect(useStreamStore.getState().suggestions).toEqual(['Suggestion 1', 'Suggestion 2']);
});
it('should replace previous suggestions', () => {
useStreamStore.getState().setSuggestions(['Old']);
useStreamStore.getState().setSuggestions(['New 1', 'New 2']);
expect(useStreamStore.getState().suggestions).toEqual(['New 1', 'New 2']);
});
it('should clear suggestions with empty array', () => {
useStreamStore.getState().setSuggestions(['Something']);
useStreamStore.getState().setSuggestions([]);
expect(useStreamStore.getState().suggestions).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════
// setIsLoading
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — setIsLoading', () => {
it('should set loading state', () => {
useStreamStore.getState().setIsLoading(true);
expect(useStreamStore.getState().isLoading).toBe(true);
});
it('should unset loading state', () => {
useStreamStore.getState().setIsLoading(true);
useStreamStore.getState().setIsLoading(false);
expect(useStreamStore.getState().isLoading).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// cancelStream (without chatStore injection)
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — cancelStream', () => {
it('should do nothing when not streaming', () => {
useStreamStore.getState().cancelStream();
// No crash, state unchanged
expect(useStreamStore.getState().isStreaming).toBe(false);
});
it('should not crash when no chatStore injected', () => {
useStreamStore.setState({ isStreaming: true, activeRunId: 'run-123' });
// Without _chat injected, cancelStream returns early
expect(() => useStreamStore.getState().cancelStream()).not.toThrow();
});
});
// ═══════════════════════════════════════════════════════════════════
// searchSkills
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — searchSkills', () => {
it('should return search results', () => {
const result = useStreamStore.getState().searchSkills('test query');
expect(result).toHaveProperty('results');
expect(result).toHaveProperty('totalAvailable');
expect(Array.isArray(result.results)).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════
// initStreamListener
// ═══════════════════════════════════════════════════════════════════
describe('streamStore — initStreamListener', () => {
it('should return unsubscribe function', () => {
const unsubscribe = useStreamStore.getState().initStreamListener();
expect(typeof unsubscribe).toBe('function');
unsubscribe();
});
});

View File

@@ -0,0 +1,379 @@
/**
* Workflow Store Tests
*
* Tests for workflow CRUD, trigger, cancel, and run management.
* Uses a mock WorkflowClient injected via setWorkflowStoreClient.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
useWorkflowStore,
type WorkflowClient,
type WorkflowCreateOptions,
type UpdateWorkflowInput,
} from '../../src/store/workflowStore';
// ── Mock Tauri ──
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
// ── Mock client ──
let mockClient: WorkflowClient;
function createMockClient(): WorkflowClient {
return {
listWorkflows: vi.fn(async () => ({ workflows: [] })),
getWorkflow: vi.fn(async () => null),
createWorkflow: vi.fn(async () => null),
updateWorkflow: vi.fn(async () => null),
deleteWorkflow: vi.fn(async () => ({ status: 'deleted' })),
executeWorkflow: vi.fn(async () => null),
cancelWorkflow: vi.fn(async () => ({ status: 'cancelled' })),
listWorkflowRuns: vi.fn(async () => ({ runs: [] })),
};
}
beforeEach(() => {
mockClient = createMockClient();
useWorkflowStore.setState({
workflows: [],
workflowRuns: {},
isLoading: false,
error: null,
client: mockClient,
});
vi.clearAllMocks();
});
// ═══════════════════════════════════════════════════════════════════
// Initial State
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — initial state', () => {
it('should start with empty workflows', () => {
const state = useWorkflowStore.getState();
expect(state.workflows).toEqual([]);
});
it('should not be loading initially', () => {
expect(useWorkflowStore.getState().isLoading).toBe(false);
});
it('should have no error', () => {
expect(useWorkflowStore.getState().error).toBeNull();
});
it('should have empty workflowRuns', () => {
expect(useWorkflowStore.getState().workflowRuns).toEqual({});
});
});
// ═══════════════════════════════════════════════════════════════════
// loadWorkflows
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — loadWorkflows', () => {
it('should load workflows from client', async () => {
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue({
workflows: [
{ id: 'wf-1', name: 'Test Workflow', steps: 3, description: 'desc', createdAt: '2026-01-01' },
{ id: 'wf-2', name: 'Another', steps: 1 },
],
});
await useWorkflowStore.getState().loadWorkflows();
const state = useWorkflowStore.getState();
expect(state.workflows).toHaveLength(2);
expect(state.workflows[0].name).toBe('Test Workflow');
expect(state.workflows[0].steps).toBe(3);
expect(state.workflows[1].id).toBe('wf-2');
expect(state.isLoading).toBe(false);
});
it('should handle null result', async () => {
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue(null);
await useWorkflowStore.getState().loadWorkflows();
expect(useWorkflowStore.getState().workflows).toEqual([]);
});
it('should handle client error', async () => {
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error'));
await useWorkflowStore.getState().loadWorkflows();
const state = useWorkflowStore.getState();
expect(state.error).toBe('Network error');
expect(state.isLoading).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════
// createWorkflow
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — createWorkflow', () => {
it('should create workflow and add to list', async () => {
const opts: WorkflowCreateOptions = {
name: 'New Pipeline',
description: 'A test pipeline',
steps: [{ handName: 'browser' }],
};
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'wf-new',
name: 'New Pipeline',
});
const result = await useWorkflowStore.getState().createWorkflow(opts);
expect(result).toBeDefined();
expect(result!.id).toBe('wf-new');
expect(result!.steps).toBe(1);
expect(useWorkflowStore.getState().workflows).toHaveLength(1);
});
it('should return undefined on null result', async () => {
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const result = await useWorkflowStore.getState().createWorkflow({
name: 'X',
steps: [],
});
expect(result).toBeUndefined();
});
it('should handle creation error', async () => {
(mockClient.createWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Create failed'));
const result = await useWorkflowStore.getState().createWorkflow({
name: 'X',
steps: [],
});
expect(result).toBeUndefined();
expect(useWorkflowStore.getState().error).toBe('Create failed');
});
});
// ═══════════════════════════════════════════════════════════════════
// updateWorkflow
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — updateWorkflow', () => {
it('should update existing workflow', async () => {
useWorkflowStore.setState({
workflows: [{ id: 'wf-1', name: 'Old', steps: 2 }],
});
(mockClient.updateWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'wf-1',
name: 'Updated',
});
const updates: UpdateWorkflowInput = { name: 'Updated', steps: [{ handName: 'researcher' }] };
const result = await useWorkflowStore.getState().updateWorkflow('wf-1', updates);
expect(result).toBeDefined();
expect(result!.name).toBe('Updated');
expect(result!.steps).toBe(1);
});
it('should preserve unchanged fields', async () => {
useWorkflowStore.setState({
workflows: [{ id: 'wf-1', name: 'Old', steps: 5, description: 'desc' }],
});
(mockClient.updateWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'wf-1',
name: 'Old',
});
await useWorkflowStore.getState().updateWorkflow('wf-1', { description: 'new desc' });
const wf = useWorkflowStore.getState().workflows[0];
expect(wf.name).toBe('Old');
});
});
// ═══════════════════════════════════════════════════════════════════
// deleteWorkflow
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — deleteWorkflow', () => {
it('should remove workflow from list', async () => {
useWorkflowStore.setState({
workflows: [
{ id: 'wf-1', name: 'First', steps: 1 },
{ id: 'wf-2', name: 'Second', steps: 2 },
],
workflowRuns: { 'wf-1': [{ runId: 'r1', status: 'completed', startedAt: '' }] },
});
await useWorkflowStore.getState().deleteWorkflow('wf-1');
const state = useWorkflowStore.getState();
expect(state.workflows).toHaveLength(1);
expect(state.workflows[0].id).toBe('wf-2');
expect(state.workflowRuns).not.toHaveProperty('wf-1');
});
it('should handle delete error', async () => {
(mockClient.deleteWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Delete failed'));
await expect(
useWorkflowStore.getState().deleteWorkflow('wf-1')
).rejects.toThrow('Delete failed');
expect(useWorkflowStore.getState().error).toBe('Delete failed');
});
});
// ═══════════════════════════════════════════════════════════════════
// triggerWorkflow
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — triggerWorkflow', () => {
it('should return run result on success', async () => {
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue({
runId: 'run-123',
status: 'running',
});
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1', { topic: 'test' });
expect(result).toEqual({ runId: 'run-123', status: 'running' });
});
it('should return undefined on null result', async () => {
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1');
expect(result).toBeUndefined();
});
it('should handle trigger error', async () => {
(mockClient.executeWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Exec failed'));
const result = await useWorkflowStore.getState().triggerWorkflow('wf-1');
expect(result).toBeUndefined();
expect(useWorkflowStore.getState().error).toBe('Exec failed');
});
});
// ═══════════════════════════════════════════════════════════════════
// cancelWorkflow
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — cancelWorkflow', () => {
it('should cancel and reload workflows', async () => {
(mockClient.listWorkflows as ReturnType<typeof vi.fn>).mockResolvedValue({ workflows: [] });
await useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1');
expect(mockClient.cancelWorkflow).toHaveBeenCalledWith('wf-1', 'run-1');
});
it('should handle cancel error', async () => {
(mockClient.cancelWorkflow as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Cancel failed'));
await expect(
useWorkflowStore.getState().cancelWorkflow('wf-1', 'run-1')
).rejects.toThrow('Cancel failed');
});
});
// ═══════════════════════════════════════════════════════════════════
// loadWorkflowRuns
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — loadWorkflowRuns', () => {
it('should load and normalize runs', async () => {
(mockClient.listWorkflowRuns as ReturnType<typeof vi.fn>).mockResolvedValue({
runs: [
{ runId: 'r1', status: 'completed', started_at: '2026-01-01', completed_at: '2026-01-01' },
{ run_id: 'r2', status: 'running', current_step: 3 },
{ id: 'r3', status: 'failed', error: 'boom' },
],
});
const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1');
expect(runs).toHaveLength(3);
expect(runs[0].runId).toBe('r1');
expect(runs[0].startedAt).toBe('2026-01-01');
expect(runs[1].runId).toBe('r2');
expect(runs[1].step).toBe('3');
expect(runs[2].runId).toBe('r3');
expect(runs[2].error).toBe('boom');
// Stored in workflowRuns map
expect(useWorkflowStore.getState().workflowRuns['wf-1']).toHaveLength(3);
});
it('should return empty array on error', async () => {
(mockClient.listWorkflowRuns as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('fail'));
const runs = await useWorkflowStore.getState().loadWorkflowRuns('wf-1');
expect(runs).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════
// getWorkflow (local lookup)
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — getWorkflow', () => {
it('should find workflow by id', () => {
useWorkflowStore.setState({
workflows: [
{ id: 'wf-1', name: 'Test', steps: 3 },
{ id: 'wf-2', name: 'Other', steps: 1 },
],
});
const found = useWorkflowStore.getState().getWorkflow('wf-1');
expect(found).toBeDefined();
expect(found!.name).toBe('Test');
});
it('should return undefined for unknown id', () => {
useWorkflowStore.setState({
workflows: [{ id: 'wf-1', name: 'Test', steps: 1 }],
});
expect(useWorkflowStore.getState().getWorkflow('unknown')).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════
// clearError / reset
// ═══════════════════════════════════════════════════════════════════
describe('workflowStore — utility actions', () => {
it('clearError should clear error', () => {
useWorkflowStore.setState({ error: 'Some error' });
useWorkflowStore.getState().clearError();
expect(useWorkflowStore.getState().error).toBeNull();
});
it('reset should restore initial state', () => {
useWorkflowStore.setState({
workflows: [{ id: 'x', name: 'Y', steps: 1 }],
isLoading: true,
error: 'err',
});
useWorkflowStore.getState().reset();
const state = useWorkflowStore.getState();
expect(state.workflows).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
});