Major restructuring: - Split monolithic gatewayStore into 5 focused stores: - connectionStore: WebSocket connection and gateway lifecycle - configStore: quickConfig, workspaceInfo, MCP services - agentStore: clones, usage stats, agent management - handStore: hands, approvals, triggers, hand runs - workflowStore: workflows, workflow runs, execution - Update all components to use new stores with selector pattern - Remove
686 lines
24 KiB
TypeScript
686 lines
24 KiB
TypeScript
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<string, unknown>) => ({
|
|
clone: {
|
|
id: 'clone_new',
|
|
name: opts.name,
|
|
createdAt: '2026-03-13T01:00:00.000Z',
|
|
},
|
|
})),
|
|
updateClone: vi.fn(async (id: string, updates: Record<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
clone: {
|
|
id: 'clone_new',
|
|
name: opts.name,
|
|
createdAt: '2026-03-13T01:00:00.000Z',
|
|
},
|
|
}));
|
|
mockClient.updateClone.mockImplementation(async (id: string, updates: Record<string, unknown>) => ({
|
|
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<string, unknown>) => ({
|
|
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);
|
|
});
|
|
});
|