Files
zclaw_openfang/desktop/tests/store/handStore.test.ts
iven d758a4477f
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
test(desktop): Phase 3 store unit tests — 112 new tests for 5 stores
- 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).
2026-04-07 17:08:34 +08:00

454 lines
18 KiB
TypeScript

/**
* 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();
});
});