import { beforeEach, describe, expect, it, vi } from 'vitest'; const getStoredGatewayUrlMock = vi.fn(() => 'ws://127.0.0.1:18789'); const getStoredGatewayTokenMock = vi.fn(() => 'stored-token'); const setStoredGatewayUrlMock = vi.fn((url: string) => url); const setStoredGatewayTokenMock = vi.fn((token: string) => token); const getLocalDeviceIdentityMock = vi.fn(async () => ({ deviceId: 'device_local', publicKeyBase64: 'public_key_base64', })); const syncAgentsMock = vi.fn(); const mockClient = { connect: vi.fn(async () => { mockClient.onStateChange?.('connected'); }), disconnect: vi.fn(), health: vi.fn(async () => ({ version: '2026.3.11' })), chat: vi.fn(), listClones: vi.fn(async () => ({ clones: [ { id: 'clone_alpha', name: 'Alpha', role: '代码助手', createdAt: '2026-03-13T00:00:00.000Z', }, ], })), createClone: vi.fn(async (opts: Record) => ({ clone: { id: 'clone_new', name: opts.name, createdAt: '2026-03-13T01:00:00.000Z', }, })), updateClone: vi.fn(async (id: string, updates: Record) => ({ clone: { id, name: updates.name || 'Alpha', role: updates.role, createdAt: '2026-03-13T00:00:00.000Z', updatedAt: '2026-03-13T01:30:00.000Z', }, })), deleteClone: vi.fn(async () => ({ ok: true })), getUsageStats: vi.fn(async () => ({ totalSessions: 1, totalMessages: 2, totalTokens: 3, byModel: {}, })), getPluginStatus: vi.fn(async () => ({ plugins: [{ id: 'zclaw-ui', status: 'active', version: '0.1.0' }], })), getQuickConfig: vi.fn(async () => ({ quickConfig: { gatewayUrl: 'ws://127.0.0.1:18789', gatewayToken: '', theme: 'light', }, })), saveQuickConfig: vi.fn(async (config: Record) => ({ quickConfig: config, })), getWorkspaceInfo: vi.fn(async () => ({ path: '~/.openclaw/zclaw-workspace', resolvedPath: 'C:/Users/test/.openclaw/zclaw-workspace', exists: true, fileCount: 4, totalSize: 128, })), listSkills: vi.fn(async () => ({ skills: [{ id: 'builtin:translation', name: 'translation', path: 'C:/skills/translation/SKILL.md', source: 'builtin' }], extraDirs: ['C:/extra-skills'], })), listChannels: vi.fn(async () => ({ channels: [{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 }], })), getFeishuStatus: vi.fn(async () => ({ configured: true, accounts: 1 })), listScheduledTasks: vi.fn(async () => ({ tasks: [{ id: 'task_1', name: 'Daily Summary', schedule: '0 9 * * *', status: 'active' }], })), // OpenFang methods listHands: vi.fn(async () => ({ hands: [ { name: 'echo', description: 'Echo handler', status: 'active' }, { name: 'notify', description: 'Notification handler', status: 'active' }, ], })), triggerHand: vi.fn(async (name: string, params?: Record) => ({ runId: `run_${name}_${Date.now()}`, status: 'running', })), listWorkflows: vi.fn(async () => ({ workflows: [ { id: 'wf_1', name: 'Data Pipeline', steps: 3 }, { id: 'wf_2', name: 'Report Generator', steps: 5 }, ], })), listWorkflowRuns: vi.fn(async (workflowId: string, opts?: { limit?: number; offset?: number }) => ({ runs: [ { runId: 'run_wf1_001', status: 'completed', startedAt: '2026-03-14T10:00:00Z', completedAt: '2026-03-14T10:05:00Z' }, { runId: 'run_wf1_002', status: 'running', startedAt: '2026-03-14T11:00:00Z' }, ], })), executeWorkflow: vi.fn(async (id: string, input?: Record) => ({ runId: `wfrun_${id}_${Date.now()}`, status: 'running', })), listTriggers: vi.fn(async () => ({ triggers: [ { id: 'trig_1', type: 'webhook', enabled: true }, { id: 'trig_2', type: 'schedule', enabled: false }, ], })), getAuditLogs: vi.fn(async (opts?: { limit?: number; offset?: number }) => ({ logs: [ { id: 'log_1', timestamp: '2026-03-13T10:00:00Z', action: 'hand.trigger', actor: 'user1', details: { hand: 'echo' } }, { id: 'log_2', timestamp: '2026-03-13T11:00:00Z', action: 'workflow.execute', actor: 'user2', details: { workflow: 'wf_1' } }, ], })), getSecurityStatus: vi.fn(async () => ({ layers: [ { name: 'Device Authentication', enabled: true, description: 'Ed25519 device signature verification' }, { name: 'JWT Tokens', enabled: true, description: 'Short-lived JWT for session management' }, { name: 'RBAC', enabled: true, description: 'Role-based access control' }, { name: 'Rate Limiting', enabled: true, description: 'Per-user rate limits' }, { name: 'Input Validation', enabled: true, description: 'Z-schema input validation' }, { name: 'Sandboxing', enabled: true, description: 'Code execution sandbox' }, { name: 'Audit Logging', enabled: true, description: 'Merkle hash chain audit logs' }, { name: 'Encryption at Rest', enabled: true, description: 'AES-256 encryption' }, { name: 'Encryption in Transit', enabled: true, description: 'TLS 1.3 encryption' }, { name: 'Secrets Management', enabled: true, description: 'Secure secrets storage' }, { name: 'Permission Gates', enabled: true, description: 'Capability-based permissions' }, { name: 'Content Filtering', enabled: false, description: 'Content moderation' }, { name: 'PII Detection', enabled: false, description: 'PII scanning' }, { name: 'Malware Scanning', enabled: false, description: 'File malware detection' }, { name: 'Network Isolation', enabled: false, description: 'Container network isolation' }, { name: 'HSM Integration', enabled: false, description: 'Hardware security module' }, ], })), getCapabilities: vi.fn(async () => ({ capabilities: ['operator.read', 'operator.write', 'hand.trigger', 'workflow.execute'], })), updateOptions: vi.fn(), onStateChange: undefined as undefined | ((state: string) => void), onLog: undefined as undefined | ((level: string, message: string) => void), }; vi.mock('../../desktop/src/lib/gateway-client', () => ({ DEFAULT_GATEWAY_URL: 'ws://127.0.0.1:4200/ws', FALLBACK_GATEWAY_URLS: ['ws://127.0.0.1:4200/ws', 'ws://127.0.0.1:4201/ws'], GatewayClient: class {}, getGatewayClient: () => mockClient, getStoredGatewayUrl: () => getStoredGatewayUrlMock(), getStoredGatewayToken: () => getStoredGatewayTokenMock(), setStoredGatewayUrl: (url: string) => setStoredGatewayUrlMock(url), setStoredGatewayToken: (token: string) => setStoredGatewayTokenMock(token), getLocalDeviceIdentity: () => getLocalDeviceIdentityMock(), })); vi.mock('../../desktop/src/lib/tauri-gateway', () => ({ isTauriRuntime: () => false, approveLocalGatewayDevicePairing: vi.fn(async () => ({ approved: false, requestId: null, deviceId: null })), getLocalGatewayAuth: vi.fn(async () => ({ configPath: null, gatewayToken: null })), getLocalGatewayStatus: vi.fn(async () => ({ supported: false, cliAvailable: false, runtimeSource: null, runtimePath: null, serviceLabel: null, serviceLoaded: false, serviceStatus: null, configOk: false, port: null, portStatus: null, probeUrl: null, listenerPids: [], error: null, raw: {}, })), getUnsupportedLocalGatewayStatus: () => ({ supported: false, cliAvailable: false, runtimeSource: null, runtimePath: null, serviceLabel: null, serviceLoaded: false, serviceStatus: null, configOk: false, port: null, portStatus: null, probeUrl: null, listenerPids: [], error: null, raw: {}, }), prepareLocalGatewayForTauri: vi.fn(async () => ({ configPath: null, originsUpdated: false, gatewayRestarted: false, })), restartLocalGateway: vi.fn(async () => undefined), startLocalGateway: vi.fn(async () => undefined), stopLocalGateway: vi.fn(async () => undefined), })); vi.mock('../../desktop/src/store/chatStore', () => ({ useChatStore: { getState: () => ({ syncAgents: syncAgentsMock, }), }, })); function resetClientMocks() { mockClient.connect.mockClear(); mockClient.disconnect.mockClear(); mockClient.health.mockReset(); mockClient.chat.mockReset(); mockClient.listClones.mockReset(); mockClient.createClone.mockReset(); mockClient.updateClone.mockReset(); mockClient.deleteClone.mockReset(); mockClient.getUsageStats.mockReset(); mockClient.getPluginStatus.mockReset(); mockClient.getQuickConfig.mockReset(); mockClient.saveQuickConfig.mockReset(); mockClient.getWorkspaceInfo.mockReset(); mockClient.listSkills.mockReset(); mockClient.listChannels.mockReset(); mockClient.getFeishuStatus.mockReset(); mockClient.listScheduledTasks.mockReset(); // OpenFang mocks mockClient.listHands.mockReset(); mockClient.triggerHand.mockReset(); mockClient.listWorkflows.mockReset(); mockClient.listWorkflowRuns.mockReset(); mockClient.executeWorkflow.mockReset(); mockClient.listTriggers.mockReset(); mockClient.getAuditLogs.mockReset(); mockClient.updateOptions.mockClear(); mockClient.onStateChange = undefined; mockClient.onLog = undefined; mockClient.health.mockResolvedValue({ version: '2026.3.11' }); mockClient.listClones.mockResolvedValue({ clones: [ { id: 'clone_alpha', name: 'Alpha', role: '代码助手', createdAt: '2026-03-13T00:00:00.000Z', }, ], }); mockClient.createClone.mockImplementation(async (opts: Record) => ({ clone: { id: 'clone_new', name: opts.name, createdAt: '2026-03-13T01:00:00.000Z', }, })); mockClient.updateClone.mockImplementation(async (id: string, updates: Record) => ({ clone: { id, name: updates.name || 'Alpha', role: updates.role, createdAt: '2026-03-13T00:00:00.000Z', updatedAt: '2026-03-13T01:30:00.000Z', }, })); mockClient.deleteClone.mockResolvedValue({ ok: true }); mockClient.getUsageStats.mockResolvedValue({ totalSessions: 1, totalMessages: 2, totalTokens: 3, byModel: {}, }); mockClient.getPluginStatus.mockResolvedValue({ plugins: [{ id: 'zclaw-ui', status: 'active', version: '0.1.0' }], }); mockClient.getQuickConfig.mockResolvedValue({ quickConfig: { gatewayUrl: 'ws://127.0.0.1:18789', gatewayToken: '', theme: 'light', }, }); mockClient.saveQuickConfig.mockImplementation(async (config: Record) => ({ quickConfig: config, })); mockClient.getWorkspaceInfo.mockResolvedValue({ path: '~/.openclaw/zclaw-workspace', resolvedPath: 'C:/Users/test/.openclaw/zclaw-workspace', exists: true, fileCount: 4, totalSize: 128, }); mockClient.listSkills.mockResolvedValue({ skills: [{ id: 'builtin:translation', name: 'translation', path: 'C:/skills/translation/SKILL.md', source: 'builtin' }], extraDirs: ['C:/extra-skills'], }); mockClient.listChannels.mockResolvedValue({ channels: [{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 }], }); mockClient.getFeishuStatus.mockResolvedValue({ configured: true, accounts: 1 }); mockClient.listScheduledTasks.mockResolvedValue({ tasks: [{ id: 'task_1', name: 'Daily Summary', schedule: '0 9 * * *', status: 'active' }], }); // OpenFang mock defaults mockClient.listHands.mockResolvedValue({ hands: [ { name: 'echo', description: 'Echo handler', status: 'idle', requirements_met: true }, { name: 'notify', description: 'Notification handler', status: 'idle', requirements_met: true }, ], }); mockClient.triggerHand.mockImplementation(async (name: string) => ({ runId: `run_${name}_123`, status: 'running', })); mockClient.listWorkflows.mockResolvedValue({ workflows: [ { id: 'wf_1', name: 'Data Pipeline', steps: 3 }, ], }); mockClient.executeWorkflow.mockImplementation(async (id: string) => ({ runId: `wfrun_${id}_123`, status: 'running', })); mockClient.listTriggers.mockResolvedValue({ triggers: [ { id: 'trig_1', type: 'webhook', enabled: true }, ], }); mockClient.getAuditLogs.mockResolvedValue({ logs: [ { id: 'log_1', timestamp: '2026-03-13T10:00:00Z', action: 'hand.trigger', actor: 'user1' }, ], }); } // Helper to inject mockClient into all domain stores async function injectMockClient() { const { setAgentStoreClient } = await import('../../desktop/src/store/agentStore'); const { setHandStoreClient } = await import('../../desktop/src/store/handStore'); const { setWorkflowStoreClient } = await import('../../desktop/src/store/workflowStore'); const { setConfigStoreClient } = await import('../../desktop/src/store/configStore'); const { setSecurityStoreClient } = await import('../../desktop/src/store/securityStore'); const { setSessionStoreClient } = await import('../../desktop/src/store/sessionStore'); setAgentStoreClient(mockClient); setHandStoreClient(mockClient); setWorkflowStoreClient(mockClient); setConfigStoreClient(mockClient); setSecurityStoreClient(mockClient); setSessionStoreClient(mockClient); } describe('gatewayStore desktop flows', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); resetClientMocks(); }); it('loads post-connect data and syncs agents after a successful connection', async () => { await injectMockClient(); const { useConnectionStore } = await import('../../desktop/src/store/connectionStore'); const { useAgentStore } = await import('../../desktop/src/store/agentStore'); const { useConfigStore } = await import('../../desktop/src/store/configStore'); await useConnectionStore.getState().connect('ws://127.0.0.1:18789', 'token-123'); // Post-connect: load data from domain stores (mimics facade connect) await Promise.allSettled([ useConfigStore.getState().loadQuickConfig(), useConfigStore.getState().loadWorkspaceInfo(), useAgentStore.getState().loadClones(), useAgentStore.getState().loadUsageStats(), useAgentStore.getState().loadPluginStatus(), useConfigStore.getState().loadScheduledTasks(), useConfigStore.getState().loadSkillsCatalog(), useConfigStore.getState().loadChannels(), ]); expect(mockClient.updateOptions).toHaveBeenCalledWith({ url: 'ws://127.0.0.1:18789', token: 'token-123', }); expect(mockClient.connect).toHaveBeenCalledTimes(1); expect(useConnectionStore.getState().connectionState).toBe('connected'); expect(useConnectionStore.getState().gatewayVersion).toBe('2026.3.11'); expect(useConfigStore.getState().quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789'); expect(useConfigStore.getState().workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace'); expect(useAgentStore.getState().pluginStatus).toHaveLength(1); expect(useConfigStore.getState().skillsCatalog).toHaveLength(1); expect(useConfigStore.getState().channels).toEqual([ { id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 }, ]); expect(setStoredGatewayUrlMock).toHaveBeenCalledWith('ws://127.0.0.1:18789'); }); it('falls back to feishu probing with the correct chinese label when channels.list is unavailable', async () => { mockClient.listChannels.mockRejectedValueOnce(new Error('channels.list unavailable')); await injectMockClient(); const { useConfigStore } = await import('../../desktop/src/store/configStore'); await useConfigStore.getState().loadChannels(); expect(useConfigStore.getState().channels).toEqual([ { id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 }, ]); }); it('merges and persists quick config updates through the config store', async () => { await injectMockClient(); const { useConfigStore } = await import('../../desktop/src/store/configStore'); useConfigStore.setState({ quickConfig: { agentName: 'Alpha', theme: 'light', gatewayUrl: 'ws://127.0.0.1:18789', gatewayToken: 'old-token', }, }); await useConfigStore.getState().saveQuickConfig({ gatewayToken: 'new-token', workspaceDir: 'C:/workspace-next', }); expect(mockClient.saveQuickConfig).toHaveBeenCalledWith({ agentName: 'Alpha', theme: 'light', gatewayUrl: 'ws://127.0.0.1:18789', gatewayToken: 'new-token', workspaceDir: 'C:/workspace-next', }); expect(setStoredGatewayTokenMock).toHaveBeenCalledWith('new-token'); expect(useConfigStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next'); }); it('returns the updated clone and refreshes the clone list after update', async () => { const initialClones = [ { id: 'clone_alpha', name: 'Alpha', role: '代码助手', createdAt: '2026-03-13T00:00:00.000Z', }, ]; const refreshedClones = [ { id: 'clone_alpha', name: 'Alpha Prime', role: '架构助手', createdAt: '2026-03-13T00:00:00.000Z', updatedAt: '2026-03-13T01:30:00.000Z', }, ]; mockClient.listClones .mockResolvedValueOnce({ clones: initialClones, }) .mockResolvedValueOnce({ clones: refreshedClones, }); await injectMockClient(); const { useAgentStore } = await import('../../desktop/src/store/agentStore'); await useAgentStore.getState().loadClones(); const updated = await useAgentStore.getState().updateClone('clone_alpha', { name: 'Alpha Prime', role: '架构助手', }); expect(updated).toMatchObject({ id: 'clone_alpha', name: 'Alpha Prime', role: '架构助手', }); expect(useAgentStore.getState().clones).toEqual(refreshedClones); }); }); describe('OpenFang actions', () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); resetClientMocks(); }); it('loads hands from the gateway', async () => { await injectMockClient(); const { useHandStore } = await import('../../desktop/src/store/handStore'); await useHandStore.getState().loadHands(); expect(mockClient.listHands).toHaveBeenCalledTimes(1); expect(useHandStore.getState().hands).toEqual([ { id: 'echo', name: 'echo', description: 'Echo handler', status: 'idle', requirements_met: true, category: undefined, icon: undefined, toolCount: undefined, metricCount: undefined, }, { id: 'notify', name: 'notify', description: 'Notification handler', status: 'idle', requirements_met: true, category: undefined, icon: undefined, toolCount: undefined, metricCount: undefined, }, ]); }); it('triggers a hand and returns the run result', async () => { await injectMockClient(); const { useHandStore } = await import('../../desktop/src/store/handStore'); const result = await useHandStore.getState().triggerHand('echo', { message: 'hello' }); expect(mockClient.triggerHand).toHaveBeenCalledWith('echo', { message: 'hello' }); expect(result).toMatchObject({ runId: 'run_echo_123', status: 'running', }); }); it('sets error when triggerHand fails', async () => { mockClient.triggerHand.mockRejectedValueOnce(new Error('Hand not found')); await injectMockClient(); const { useHandStore } = await import('../../desktop/src/store/handStore'); const result = await useHandStore.getState().triggerHand('nonexistent'); expect(result).toBeUndefined(); expect(useHandStore.getState().error).toBe('Hand not found'); }); it('loads workflows from the gateway', async () => { await injectMockClient(); const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore'); await useWorkflowStore.getState().loadWorkflows(); expect(mockClient.listWorkflows).toHaveBeenCalledTimes(1); expect(useWorkflowStore.getState().workflows).toEqual([ { id: 'wf_1', name: 'Data Pipeline', steps: 3 }, ]); }); it('executes a workflow and returns the run result', async () => { await injectMockClient(); const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore'); const result = await useWorkflowStore.getState().triggerWorkflow('wf_1', { input: 'data' }); expect(mockClient.executeWorkflow).toHaveBeenCalledWith('wf_1', { input: 'data' }); expect(result).toMatchObject({ runId: 'wfrun_wf_1_123', status: 'running', }); }); it('loads triggers from the gateway', async () => { await injectMockClient(); const { useHandStore } = await import('../../desktop/src/store/handStore'); await useHandStore.getState().loadTriggers(); expect(mockClient.listTriggers).toHaveBeenCalledTimes(1); expect(useHandStore.getState().triggers).toEqual([ { id: 'trig_1', type: 'webhook', enabled: true }, ]); }); it('loads audit logs from the gateway', async () => { await injectMockClient(); const { useSecurityStore } = await import('../../desktop/src/store/securityStore'); await useSecurityStore.getState().loadAuditLogs({ limit: 50, offset: 0 }); expect(mockClient.getAuditLogs).toHaveBeenCalledWith({ limit: 50, offset: 0 }); expect(useSecurityStore.getState().auditLogs).toEqual([ { id: 'log_1', timestamp: '2026-03-13T10:00:00Z', action: 'hand.trigger', actor: 'user1' }, ]); }); it('initializes OpenFang state with empty arrays', async () => { const { useHandStore } = await import('../../desktop/src/store/handStore'); const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore'); const { useSecurityStore } = await import('../../desktop/src/store/securityStore'); expect(useHandStore.getState().hands).toEqual([]); expect(useWorkflowStore.getState().workflows).toEqual([]); expect(useHandStore.getState().triggers).toEqual([]); expect(useSecurityStore.getState().auditLogs).toEqual([]); }); // === Security Tests === it('loads security status from the gateway', async () => { await injectMockClient(); const { useSecurityStore } = await import('../../desktop/src/store/securityStore'); await useSecurityStore.getState().loadSecurityStatus(); expect(mockClient.getSecurityStatus).toHaveBeenCalledTimes(1); const { securityStatus } = useSecurityStore.getState(); expect(securityStatus).not.toBeNull(); expect(securityStatus?.totalCount).toBe(16); expect(securityStatus?.enabledCount).toBe(11); expect(securityStatus?.layers).toHaveLength(16); }); it('calculates security level correctly (critical for 14+ layers)', async () => { await injectMockClient(); const { useSecurityStore } = await import('../../desktop/src/store/securityStore'); await useSecurityStore.getState().loadSecurityStatus(); const { securityStatus } = useSecurityStore.getState(); // 11/16 enabled = 68.75% = 'high' level expect(securityStatus?.securityLevel).toBe('high'); }); it('identifies disabled security layers', async () => { await injectMockClient(); const { useSecurityStore } = await import('../../desktop/src/store/securityStore'); await useSecurityStore.getState().loadSecurityStatus(); const { securityStatus } = useSecurityStore.getState(); const disabledLayers = securityStatus?.layers.filter(l => !l.enabled) || []; expect(disabledLayers.length).toBe(5); expect(disabledLayers.map(l => l.name)).toContain('Content Filtering'); expect(disabledLayers.map(l => l.name)).toContain('HSM Integration'); }); it('sets isLoading during loadHands', async () => { await injectMockClient(); const { useHandStore } = await import('../../desktop/src/store/handStore'); // Reset store state useHandStore.setState({ hands: [], isLoading: false }); const loadPromise = useHandStore.getState().loadHands(); // Check isLoading was set to true at start // (this might be false again by the time we check due to async) await loadPromise; // After completion, isLoading should be false expect(useHandStore.getState().isLoading).toBe(false); expect(useHandStore.getState().hands.length).toBeGreaterThan(0); }); it('sets isLoading during loadWorkflows', async () => { await injectMockClient(); const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore'); // Reset store state useWorkflowStore.setState({ workflows: [], isLoading: false }); await useWorkflowStore.getState().loadWorkflows(); expect(useWorkflowStore.getState().isLoading).toBe(false); expect(useWorkflowStore.getState().workflows.length).toBeGreaterThan(0); }); });