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).
454 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|