refactor(store): split gatewayStore into specialized domain stores
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
This commit is contained in:
@@ -342,6 +342,22 @@ function resetClientMocks() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -350,51 +366,59 @@ describe('gatewayStore desktop flows', () => {
|
||||
});
|
||||
|
||||
it('loads post-connect data and syncs agents after a successful connection', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
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 useGatewayStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
|
||||
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(),
|
||||
]);
|
||||
|
||||
const state = useGatewayStore.getState();
|
||||
expect(mockClient.updateOptions).toHaveBeenCalledWith({
|
||||
url: 'ws://127.0.0.1:18789',
|
||||
token: 'token-123',
|
||||
});
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(state.connectionState).toBe('connected');
|
||||
expect(state.gatewayVersion).toBe('2026.3.11');
|
||||
expect(state.quickConfig.gatewayUrl).toBe('ws://127.0.0.1:18789');
|
||||
expect(state.workspaceInfo?.resolvedPath).toBe('C:/Users/test/.openclaw/zclaw-workspace');
|
||||
expect(state.pluginStatus).toHaveLength(1);
|
||||
expect(state.skillsCatalog).toHaveLength(1);
|
||||
expect(state.channels).toEqual([
|
||||
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(syncAgentsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
role: '代码助手',
|
||||
createdAt: '2026-03-13T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
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'));
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useConfigStore } = await import('../../desktop/src/store/configStore');
|
||||
|
||||
await useGatewayStore.getState().loadChannels();
|
||||
await useConfigStore.getState().loadChannels();
|
||||
|
||||
expect(useGatewayStore.getState().channels).toEqual([
|
||||
expect(useConfigStore.getState().channels).toEqual([
|
||||
{ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'active', accounts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges and persists quick config updates through the gateway store', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
it('merges and persists quick config updates through the config store', async () => {
|
||||
await injectMockClient();
|
||||
const { useConfigStore } = await import('../../desktop/src/store/configStore');
|
||||
|
||||
useGatewayStore.setState({
|
||||
useConfigStore.setState({
|
||||
quickConfig: {
|
||||
agentName: 'Alpha',
|
||||
theme: 'light',
|
||||
@@ -403,7 +427,7 @@ describe('gatewayStore desktop flows', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await useGatewayStore.getState().saveQuickConfig({
|
||||
await useConfigStore.getState().saveQuickConfig({
|
||||
gatewayToken: 'new-token',
|
||||
workspaceDir: 'C:/workspace-next',
|
||||
});
|
||||
@@ -416,7 +440,7 @@ describe('gatewayStore desktop flows', () => {
|
||||
workspaceDir: 'C:/workspace-next',
|
||||
});
|
||||
expect(setStoredGatewayTokenMock).toHaveBeenCalledWith('new-token');
|
||||
expect(useGatewayStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
|
||||
expect(useConfigStore.getState().quickConfig.workspaceDir).toBe('C:/workspace-next');
|
||||
});
|
||||
|
||||
it('returns the updated clone and refreshes the clone list after update', async () => {
|
||||
@@ -446,10 +470,11 @@ describe('gatewayStore desktop flows', () => {
|
||||
clones: refreshedClones,
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useAgentStore } = await import('../../desktop/src/store/agentStore');
|
||||
|
||||
await useGatewayStore.getState().loadClones();
|
||||
const updated = await useGatewayStore.getState().updateClone('clone_alpha', {
|
||||
await useAgentStore.getState().loadClones();
|
||||
const updated = await useAgentStore.getState().updateClone('clone_alpha', {
|
||||
name: 'Alpha Prime',
|
||||
role: '架构助手',
|
||||
});
|
||||
@@ -459,7 +484,7 @@ describe('gatewayStore desktop flows', () => {
|
||||
name: 'Alpha Prime',
|
||||
role: '架构助手',
|
||||
});
|
||||
expect(useGatewayStore.getState().clones).toEqual(refreshedClones);
|
||||
expect(useAgentStore.getState().clones).toEqual(refreshedClones);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -471,12 +496,13 @@ describe('OpenFang actions', () => {
|
||||
});
|
||||
|
||||
it('loads hands from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
|
||||
await useGatewayStore.getState().loadHands();
|
||||
await useHandStore.getState().loadHands();
|
||||
|
||||
expect(mockClient.listHands).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.getState().hands).toEqual([
|
||||
expect(useHandStore.getState().hands).toEqual([
|
||||
{
|
||||
id: 'echo',
|
||||
name: 'echo',
|
||||
@@ -503,9 +529,10 @@ describe('OpenFang actions', () => {
|
||||
});
|
||||
|
||||
it('triggers a hand and returns the run result', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
|
||||
const result = await useGatewayStore.getState().triggerHand('echo', { message: 'hello' });
|
||||
const result = await useHandStore.getState().triggerHand('echo', { message: 'hello' });
|
||||
|
||||
expect(mockClient.triggerHand).toHaveBeenCalledWith('echo', { message: 'hello' });
|
||||
expect(result).toMatchObject({
|
||||
@@ -516,29 +543,32 @@ describe('OpenFang actions', () => {
|
||||
|
||||
it('sets error when triggerHand fails', async () => {
|
||||
mockClient.triggerHand.mockRejectedValueOnce(new Error('Hand not found'));
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
|
||||
const result = await useGatewayStore.getState().triggerHand('nonexistent');
|
||||
const result = await useHandStore.getState().triggerHand('nonexistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(useGatewayStore.getState().error).toBe('Hand not found');
|
||||
expect(useHandStore.getState().error).toBe('Hand not found');
|
||||
});
|
||||
|
||||
it('loads workflows from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
|
||||
|
||||
await useGatewayStore.getState().loadWorkflows();
|
||||
await useWorkflowStore.getState().loadWorkflows();
|
||||
|
||||
expect(mockClient.listWorkflows).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.getState().workflows).toEqual([
|
||||
expect(useWorkflowStore.getState().workflows).toEqual([
|
||||
{ id: 'wf_1', name: 'Data Pipeline', steps: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('executes a workflow and returns the run result', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
|
||||
|
||||
const result = await useGatewayStore.getState().executeWorkflow('wf_1', { input: 'data' });
|
||||
const result = await useWorkflowStore.getState().triggerWorkflow('wf_1', { input: 'data' });
|
||||
|
||||
expect(mockClient.executeWorkflow).toHaveBeenCalledWith('wf_1', { input: 'data' });
|
||||
expect(result).toMatchObject({
|
||||
@@ -548,46 +578,50 @@ describe('OpenFang actions', () => {
|
||||
});
|
||||
|
||||
it('loads triggers from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
|
||||
await useGatewayStore.getState().loadTriggers();
|
||||
await useHandStore.getState().loadTriggers();
|
||||
|
||||
expect(mockClient.listTriggers).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.getState().triggers).toEqual([
|
||||
expect(useHandStore.getState().triggers).toEqual([
|
||||
{ id: 'trig_1', type: 'webhook', enabled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads audit logs from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
|
||||
|
||||
await useGatewayStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
|
||||
await useSecurityStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
|
||||
|
||||
expect(mockClient.getAuditLogs).toHaveBeenCalledWith({ limit: 50, offset: 0 });
|
||||
expect(useGatewayStore.getState().auditLogs).toEqual([
|
||||
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 { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
|
||||
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
|
||||
|
||||
const state = useGatewayStore.getState();
|
||||
expect(state.hands).toEqual([]);
|
||||
expect(state.workflows).toEqual([]);
|
||||
expect(state.triggers).toEqual([]);
|
||||
expect(state.auditLogs).toEqual([]);
|
||||
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 () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
|
||||
|
||||
await useGatewayStore.getState().loadSecurityStatus();
|
||||
await useSecurityStore.getState().loadSecurityStatus();
|
||||
|
||||
expect(mockClient.getSecurityStatus).toHaveBeenCalledTimes(1);
|
||||
const { securityStatus } = useGatewayStore.getState();
|
||||
const { securityStatus } = useSecurityStore.getState();
|
||||
expect(securityStatus).not.toBeNull();
|
||||
expect(securityStatus?.totalCount).toBe(16);
|
||||
expect(securityStatus?.enabledCount).toBe(11);
|
||||
@@ -595,21 +629,23 @@ describe('OpenFang actions', () => {
|
||||
});
|
||||
|
||||
it('calculates security level correctly (critical for 14+ layers)', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
|
||||
|
||||
await useGatewayStore.getState().loadSecurityStatus();
|
||||
await useSecurityStore.getState().loadSecurityStatus();
|
||||
|
||||
const { securityStatus } = useGatewayStore.getState();
|
||||
const { securityStatus } = useSecurityStore.getState();
|
||||
// 11/16 enabled = 68.75% = 'high' level
|
||||
expect(securityStatus?.securityLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('identifies disabled security layers', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useSecurityStore } = await import('../../desktop/src/store/securityStore');
|
||||
|
||||
await useGatewayStore.getState().loadSecurityStatus();
|
||||
await useSecurityStore.getState().loadSecurityStatus();
|
||||
|
||||
const { securityStatus } = useGatewayStore.getState();
|
||||
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');
|
||||
@@ -617,31 +653,33 @@ describe('OpenFang actions', () => {
|
||||
});
|
||||
|
||||
it('sets isLoading during loadHands', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useHandStore } = await import('../../desktop/src/store/handStore');
|
||||
|
||||
// Reset store state
|
||||
useGatewayStore.setState({ hands: [], isLoading: false });
|
||||
useHandStore.setState({ hands: [], isLoading: false });
|
||||
|
||||
const loadPromise = useGatewayStore.getState().loadHands();
|
||||
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(useGatewayStore.getState().isLoading).toBe(false);
|
||||
expect(useGatewayStore.getState().hands.length).toBeGreaterThan(0);
|
||||
expect(useHandStore.getState().isLoading).toBe(false);
|
||||
expect(useHandStore.getState().hands.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets isLoading during loadWorkflows', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
await injectMockClient();
|
||||
const { useWorkflowStore } = await import('../../desktop/src/store/workflowStore');
|
||||
|
||||
// Reset store state
|
||||
useGatewayStore.setState({ workflows: [], isLoading: false });
|
||||
useWorkflowStore.setState({ workflows: [], isLoading: false });
|
||||
|
||||
await useGatewayStore.getState().loadWorkflows();
|
||||
await useWorkflowStore.getState().loadWorkflows();
|
||||
|
||||
expect(useGatewayStore.getState().isLoading).toBe(false);
|
||||
expect(useGatewayStore.getState().workflows.length).toBeGreaterThan(0);
|
||||
expect(useWorkflowStore.getState().isLoading).toBe(false);
|
||||
expect(useWorkflowStore.getState().workflows.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user