feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
262
desktop/tests/config-parser.test.ts
Normal file
262
desktop/tests/config-parser.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Config Parser Tests
|
||||
*
|
||||
* Tests for configuration parsing and validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
configParser,
|
||||
ConfigParseError,
|
||||
ConfigValidationError,
|
||||
} from '../src/lib/config-parser';
|
||||
import type { OpenFangConfig } from '../src/types/config';
|
||||
|
||||
describe('configParser', () => {
|
||||
const validToml = `
|
||||
# Valid OpenFang configuration
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4200
|
||||
|
||||
websocket_port = 4200
|
||||
websocket_path = "/ws"
|
||||
|
||||
[agent.defaults]
|
||||
workspace = "~/.openfang/workspace"
|
||||
default_model = "gpt-4"
|
||||
|
||||
[llm]
|
||||
default_provider = "openai"
|
||||
default_model = "gpt-4"
|
||||
`;
|
||||
|
||||
describe('parseConfig', () => {
|
||||
it('should parse valid TOML configuration', () => {
|
||||
const config = configParser.parseConfig(validToml);
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.server).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
websocket_port: 4200,
|
||||
websocket_path: '/ws',
|
||||
});
|
||||
expect(config.agent).toBeDefined();
|
||||
expect(config.agent.defaults).toEqual({
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
});
|
||||
expect(config.llm).toEqual({
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('should validate correct configuration', () => {
|
||||
const config: OpenFangConfig = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect missing required fields', () => {
|
||||
const config = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
// missing port
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate port range', () => {
|
||||
const config = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 99999, // invalid port
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const portError = result.errors.find(e => e.path === 'server.port');
|
||||
expect(portError).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect empty required fields', () => {
|
||||
const config = {
|
||||
server: {
|
||||
host: '',
|
||||
port: 4200,
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: '',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: '',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAndValidate', () => {
|
||||
it('should parse and validate valid configuration', () => {
|
||||
const config = configParser.parseAndValidate(validToml);
|
||||
expect(config).toBeDefined();
|
||||
expect(config.server.host).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('should throw on invalid configuration', () => {
|
||||
const invalidToml = `
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
# missing port
|
||||
`;
|
||||
|
||||
expect(() => configParser.parseAndValidate(invalidToml)).toThrow(ConfigValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringifyConfig', () => {
|
||||
it('should stringify configuration to TOML', () => {
|
||||
const config: OpenFangConfig = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.stringifyConfig(config);
|
||||
expect(result).toContain('host = "127.0.0.1"');
|
||||
expect(result).toContain('port = 4200');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMetadata', () => {
|
||||
it('should extract metadata from TOML content', () => {
|
||||
const content = `
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4200
|
||||
|
||||
[llm.providers]
|
||||
api_key = "\${API_KEY}"
|
||||
`;
|
||||
const metadata = configParser.extractMetadata(content, '/path/to/config.toml');
|
||||
|
||||
expect(metadata.path).toBe('/path/to/config.toml');
|
||||
expect(metadata.name).toBe('config.toml');
|
||||
expect(metadata.envVars).toContain('API_KEY');
|
||||
expect(metadata.hasUnresolvedEnvVars).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect no env vars when none present', () => {
|
||||
const content = `
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4200
|
||||
`;
|
||||
const metadata = configParser.extractMetadata(content, '/path/to/config.toml');
|
||||
|
||||
expect(metadata.envVars).toEqual([]);
|
||||
expect(metadata.hasUnresolvedEnvVars).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeWithDefaults', () => {
|
||||
it('should merge partial config with defaults', () => {
|
||||
const partial = {
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = configParser.mergeWithDefaults(partial);
|
||||
|
||||
expect(result.server?.port).toBe(3000);
|
||||
expect(result.server?.host).toBe('127.0.0.1'); // from defaults
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenFangConfig', () => {
|
||||
it('should return true for valid config', () => {
|
||||
const config: OpenFangConfig = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
expect(configParser.isOpenFangConfig(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid config', () => {
|
||||
expect(configParser.isOpenFangConfig(null)).toBe(false);
|
||||
expect(configParser.isOpenFangConfig({})).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
417
desktop/tests/lib/request-helper.test.ts
Normal file
417
desktop/tests/lib/request-helper.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Request Helper Tests
|
||||
*
|
||||
* Tests for request timeout, automatic retry with exponential backoff,
|
||||
* and request cancellation support.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
requestWithRetry,
|
||||
requestJson,
|
||||
RequestManager,
|
||||
RequestError,
|
||||
RequestCancelledError,
|
||||
DEFAULT_REQUEST_CONFIG,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
patch,
|
||||
} from '../../src/lib/request-helper';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('request-helper', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('DEFAULT_REQUEST_CONFIG', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(DEFAULT_REQUEST_CONFIG.timeout).toBe(30000);
|
||||
expect(DEFAULT_REQUEST_CONFIG.retries).toBe(3);
|
||||
expect(DEFAULT_REQUEST_CONFIG.retryDelay).toBe(1000);
|
||||
expect(DEFAULT_REQUEST_CONFIG.retryOn).toEqual([408, 429, 500, 502, 503, 504]);
|
||||
expect(DEFAULT_REQUEST_CONFIG.maxRetryDelay).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestError', () => {
|
||||
it('should create error with all properties', () => {
|
||||
const error = new RequestError('Test error', 500, 'Server Error', 'response body');
|
||||
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.statusText).toBe('Server Error');
|
||||
expect(error.responseBody).toBe('response body');
|
||||
});
|
||||
|
||||
it('should detect retryable status codes', () => {
|
||||
const error = new RequestError('Test', 500, 'Error');
|
||||
expect(error.isRetryable()).toBe(true);
|
||||
expect(error.isRetryable([500, 502])).toBe(true);
|
||||
expect(error.isRetryable([401])).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect timeout errors', () => {
|
||||
const timeoutError = new RequestError('timeout', 408, 'Request Timeout');
|
||||
expect(timeoutError.isTimeout()).toBe(true);
|
||||
|
||||
const const otherError = new RequestError('other', 500, 'Error');
|
||||
expect(otherError.isTimeout()).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect auth errors', () => {
|
||||
const authError = new RequestError('Unauthorized', 401, 'Unauthorized');
|
||||
expect(authError.isAuthError()).toBe(true);
|
||||
|
||||
const forbiddenError = new RequestError('Forbidden', 403, 'Forbidden');
|
||||
expect(forbiddenError.isAuthError()).toBe(true);
|
||||
|
||||
const otherError = new RequestError('Server Error', 500, 'Error');
|
||||
expect(otherError.isAuthError()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestCancelledError', () => {
|
||||
it('should create cancellation error', () => {
|
||||
const error = new RequestCancelledError('Request was cancelled');
|
||||
expect(error.message).toBe('Request was cancelled');
|
||||
expect(error.name).toBe('RequestCancelledError');
|
||||
});
|
||||
|
||||
it('should use default message', () => {
|
||||
const error = new RequestCancelledError();
|
||||
expect(error.message).toBe('Request cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestWithRetry', () => {
|
||||
it('should return response on success', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ data: 'success' }),
|
||||
});
|
||||
|
||||
const response = await requestWithRetry('https://api.example.com/test');
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({ data: 'success' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry on retryable status codes', async () => {
|
||||
// First call fails with 503
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
text: async () => 'Error body',
|
||||
});
|
||||
|
||||
// Second call succeeds
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ data: 'retried success' }),
|
||||
});
|
||||
|
||||
const response = await requestWithRetry('https://api.example.com/test', {}, {
|
||||
retries: 2,
|
||||
retryDelay: 10, // Small delay for testing
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({ data: 'retried success' });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not retry on non-retryable status codes', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => '{"error": "Unauthorized"}',
|
||||
});
|
||||
|
||||
await expect(requestWithRetry('https://api.example.com/test')).rejects(RequestError);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw after all retries exhausted', async () => {
|
||||
// All calls fail with 503
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
text: async () => 'Error',
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestWithRetry('https://api.example.com/test', {}, { retries: 2, retryDelay: 10 })
|
||||
).rejects(RequestError);
|
||||
});
|
||||
|
||||
it('should handle timeout correctly', async () => {
|
||||
// Create a promise that never resolves to simulate timeout
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {}));
|
||||
|
||||
await expect(
|
||||
requestWithRetry('https://api.example.com/test', {}, { timeout: 50, retries: 1 })
|
||||
).rejects(RequestError);
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(requestWithRetry('https://api.example.com/test')).rejects(RequestError);
|
||||
});
|
||||
|
||||
it('should pass through request options', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await requestWithRetry('https://api.example.com/test', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
body: JSON.stringify({ test: 'data' }),
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'X-Custom': 'value',
|
||||
}),
|
||||
body: '{"test":"data"}',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestJson', () => {
|
||||
it('should parse JSON response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ message: 'hello' }),
|
||||
});
|
||||
|
||||
const result = await requestJson<{ message: string }>('https://api.example.com/test');
|
||||
expect(result).toEqual({ message: 'hello' });
|
||||
});
|
||||
|
||||
it('should throw on invalid JSON', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => 'not valid json',
|
||||
});
|
||||
|
||||
await expect(requestJson('https://api.example.com/test')).rejects(RequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestManager', () => {
|
||||
let manager: RequestManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RequestManager();
|
||||
});
|
||||
|
||||
it('should track active requests', () => {
|
||||
const controller = manager.createRequest('test-1');
|
||||
|
||||
expect(manager.isRequestActive('test-1')).toBe(true);
|
||||
expect(manager.activeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should cancel request', () => {
|
||||
const controller = manager.createRequest('test-1');
|
||||
expect(manager.cancelRequest('test-1')).toBe(true);
|
||||
expect(manager.isRequestActive('test-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cancelling non-existent request', () => {
|
||||
expect(manager.cancelRequest('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should replace existing request with same ID', () => {
|
||||
const controller1 = manager.createRequest('test-1');
|
||||
const controller2 = manager.createRequest('test-1');
|
||||
|
||||
expect(controller1.signal.aborted).toBe(true);
|
||||
expect(manager.isRequestActive('test-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should get all active request IDs', () => {
|
||||
manager.createRequest('test-1');
|
||||
manager.createRequest('test-2');
|
||||
manager.createRequest('test-3');
|
||||
|
||||
const ids = manager.getActiveRequestIds();
|
||||
expect(ids).toHaveLength(3);
|
||||
expect(ids).toContain('test-1');
|
||||
expect(ids).toContain('test-2');
|
||||
expect(ids).toContain('test-3');
|
||||
});
|
||||
|
||||
it('should cancel all requests', () => {
|
||||
manager.createRequest('test-1');
|
||||
manager.createRequest('test-2');
|
||||
manager.createRequest('test-3');
|
||||
|
||||
manager.cancelAll();
|
||||
|
||||
expect(manager.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should execute managed request successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const response = await manager.executeManaged('test-1', 'https://api.example.com/test');
|
||||
const data = await response.json();
|
||||
|
||||
expect(data).toEqual({ success: true });
|
||||
expect(manager.isRequestActive('test-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should clean up on error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Test error'));
|
||||
|
||||
await expect(
|
||||
manager.executeManaged('test-1', 'https://api.example.com/test')
|
||||
).rejects();
|
||||
|
||||
expect(manager.isRequestActive('test-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should execute managed JSON request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ data: 'test' }),
|
||||
});
|
||||
|
||||
const result = await manager.executeManagedJson<{ data: string }>(
|
||||
'test-1',
|
||||
'https://api.example.com/test'
|
||||
);
|
||||
|
||||
expect(result).toEqual({ data: 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Convenience functions', () => {
|
||||
it('should make GET request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await get('https://api.example.com/test');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should make POST request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await post('https://api.example.com/test', { data: 'test' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: '{"data":"test"}',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should make PUT request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await put('https://api.example.com/test', { data: 'test' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: '{"data":"test"}',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should make DELETE request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await del('https://api.example.com/test');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should make PATCH request', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await patch('https://api.example.com/test', { data: 'test' });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.example.com/test',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: '{"data":"test"}',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
564
desktop/tests/lib/team-client.test.ts
Normal file
564
desktop/tests/lib/team-client.test.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Team Client Tests
|
||||
*
|
||||
* Tests for OpenFang Team API client.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
TeamAPIError,
|
||||
listTeams,
|
||||
getTeam
|
||||
createTeam
|
||||
updateTeam
|
||||
deleteTeam
|
||||
addTeamMember
|
||||
removeTeamMember
|
||||
updateMemberRole
|
||||
addTeamTask
|
||||
updateTaskStatus
|
||||
assignTask
|
||||
submitDeliverable
|
||||
startDevQALoop
|
||||
submitReview
|
||||
updateLoopState
|
||||
getTeamMetrics
|
||||
getTeamEvents
|
||||
subscribeToTeamEvents
|
||||
teamClient,
|
||||
} from '../../src/lib/team-client';
|
||||
import type { Team, TeamMember, TeamTask, TeamMemberRole, DevQALoop } from '../../src/types/team';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('team-client', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('TeamAPIError', () => {
|
||||
it('should create error with all properties', () => {
|
||||
const error = new TeamAPIError('Test error', 404, '/teams/test', { detail: 'test detail' });
|
||||
|
||||
expect(error.message).toBe('Test error');
|
||||
expect(error.statusCode).toBe(404);
|
||||
expect(error.endpoint).toBe('/teams/test');
|
||||
expect(error.details).toEqual({ detail: 'test detail' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTeams', () => {
|
||||
it('should fetch teams list', async () => {
|
||||
const mockTeams: Team[] = [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'sequential',
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ teams: mockTeams, total: 1 }),
|
||||
});
|
||||
|
||||
const result = await listTeams();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams');
|
||||
expect(result).toEqual({ teams: mockTeams, total: 1 });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('getTeam', () => {
|
||||
it('should fetch single team', async () => {
|
||||
const mockTeam = {
|
||||
team: {
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'sequential' as const,
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockTeam,
|
||||
});
|
||||
|
||||
const result = await getTeam('team-1');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1');
|
||||
expect(result).toEqual(mockTeam);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTeam', () => {
|
||||
it('should create a new team', async () => {
|
||||
const createRequest = {
|
||||
name: 'New Team',
|
||||
description: 'Test description',
|
||||
memberAgents: [],
|
||||
pattern: 'parallel' as const,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
team: {
|
||||
id: 'new-team-id',
|
||||
name: 'New Team',
|
||||
description: 'Test description',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'parallel',
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await createTeam(createRequest);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTeam', () => {
|
||||
it('should update a team', async () => {
|
||||
const updateData = { name: 'Updated Team' };
|
||||
const mockResponse = {
|
||||
team: {
|
||||
id: 'team-1',
|
||||
name: 'Updated Team',
|
||||
description: 'Test',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'sequential' as const,
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await updateTeam('team-1', updateData);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1', expect.objectContaining({
|
||||
method: 'PUT',
|
||||
}));
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTeam', () => {
|
||||
it('should delete a team', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const result = await deleteTeam('team-1');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1', expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
}));
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTeamMember', () => {
|
||||
it('should add a member to team', async () => {
|
||||
const mockResponse = {
|
||||
member: {
|
||||
id: 'member-1',
|
||||
agentId: 'agent-1',
|
||||
name: 'Agent 1',
|
||||
role: 'developer',
|
||||
skills: [],
|
||||
workload: 0,
|
||||
status: 'idle',
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: [],
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await addTeamMember('team-1', 'agent-1', 'developer');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTeamMember', () => {
|
||||
it('should remove a member from team', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const result = await removeTeamMember('team-1', 'member-1');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members/member-1');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMemberRole', () => {
|
||||
it('should update member role', async () => {
|
||||
const mockResponse = {
|
||||
member: {
|
||||
id: 'member-1',
|
||||
agentId: 'agent-1',
|
||||
name: 'Agent 1',
|
||||
role: 'reviewer',
|
||||
skills: [],
|
||||
workload: 0,
|
||||
status: 'idle',
|
||||
maxConcurrentTasks: 2,
|
||||
currentTasks: [],
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await updateMemberRole('team-1', 'member-1', 'reviewer');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/members/member-1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTeamTask', () => {
|
||||
it('should add a task to team', async () => {
|
||||
const taskRequest = {
|
||||
teamId: 'team-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test task description',
|
||||
priority: 'high',
|
||||
type: 'implementation',
|
||||
};
|
||||
const mockResponse = {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test task description',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
type: 'implementation',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await addTeamTask(taskRequest);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskStatus', () => {
|
||||
it('should update task status', async () => {
|
||||
const mockResponse = {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
status: 'in_progress',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await updateTaskStatus('team-1', 'task-1', 'in_progress');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignTask', () => {
|
||||
it('should assign task to member', async () => {
|
||||
const mockResponse = {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
assigneeId: 'member-1',
|
||||
status: 'assigned',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await assignTask('team-1', 'task-1', 'member-1');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1/assign');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitDeliverable', () => {
|
||||
it('should submit deliverable for task', async () => {
|
||||
const deliverable = {
|
||||
type: 'code',
|
||||
description: 'Test deliverable',
|
||||
files: ['/test/file.ts'],
|
||||
};
|
||||
const mockResponse = {
|
||||
task: {
|
||||
id: 'task-1',
|
||||
title: 'Test Task',
|
||||
deliverable,
|
||||
status: 'review',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await submitDeliverable('team-1', 'task-1', deliverable);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/tasks/task-1/deliverable');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDevQALoop', () => {
|
||||
it('should start a DevQA loop', async () => {
|
||||
const mockResponse = {
|
||||
loop: {
|
||||
id: 'loop-1',
|
||||
developerId: 'dev-1',
|
||||
reviewerId: 'reviewer-1',
|
||||
taskId: 'task-1',
|
||||
state: 'developing',
|
||||
iterationCount: 0,
|
||||
maxIterations: 3,
|
||||
feedbackHistory: [],
|
||||
startedAt: '2024-01-01T00:00:00Z',
|
||||
lastUpdatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await startDevQALoop('team-1', 'task-1', 'dev-1', 'reviewer-1');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitReview', () => {
|
||||
it('should submit a review', async () => {
|
||||
const feedback = {
|
||||
verdict: 'approved',
|
||||
comments: ['LGTM!'],
|
||||
issues: [],
|
||||
};
|
||||
const mockResponse = {
|
||||
loop: {
|
||||
id: 'loop-1',
|
||||
state: 'approved',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await submitReview('team-1', 'loop-1', feedback);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops/loop-1/review');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLoopState', () => {
|
||||
it('should update loop state', async () => {
|
||||
const mockResponse = {
|
||||
loop: {
|
||||
id: 'loop-1',
|
||||
state: 'reviewing',
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await updateLoopState('team-1', 'loop-1', 'reviewing');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/loops/loop-1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamMetrics', () => {
|
||||
it('should get team metrics', async () => {
|
||||
const mockMetrics = {
|
||||
tasksCompleted: 10,
|
||||
avgCompletionTime: 1000,
|
||||
passRate: 85,
|
||||
avgIterations: 1.5,
|
||||
escalations: 0,
|
||||
efficiency: 80,
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockMetrics,
|
||||
});
|
||||
|
||||
const result = await getTeamMetrics('team-1');
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/metrics');
|
||||
expect(result).toEqual(mockMetrics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamEvents', () => {
|
||||
it('should get team events', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
type: 'task_assigned',
|
||||
teamId: 'team-1',
|
||||
sourceAgentId: 'agent-1',
|
||||
payload: {},
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ events: mockEvents, total: 1 }),
|
||||
});
|
||||
|
||||
const result = await getTeamEvents('team-1', 10);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/teams/team-1/events?limit=10');
|
||||
expect(result).toEqual({ events: mockEvents, total: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToTeamEvents', () => {
|
||||
it('should subscribe to team events', () => {
|
||||
const mockWs = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: vi.fn(),
|
||||
addEventListener: vi.fn((event, handler) => {
|
||||
handler('message');
|
||||
return mockWs;
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
const callback = vi.fn();
|
||||
const unsubscribe = subscribeToTeamEvents('team-1', callback, mockWs as unknown as WebSocket);
|
||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
topic: 'team:team-1',
|
||||
}));
|
||||
unsubscribe();
|
||||
expect(mockWs.removeEventListenerEventListener).toHaveBeenCalled();
|
||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
topic: 'team:team-1',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('teamClient', () => {
|
||||
it('should export all API functions', () => {
|
||||
expect(teamClient.listTeams).toBe(listTeams);
|
||||
expect(teamClient.getTeam).toBe(getTeam);
|
||||
expect(teamClient.createTeam).toBe(createTeam);
|
||||
expect(teamClient.updateTeam).toBe(updateTeam);
|
||||
expect(teamClient.deleteTeam).toBe(deleteTeam);
|
||||
expect(teamClient.addTeamMember).toBe(addTeamMember);
|
||||
expect(teamClient.removeTeamMember).toBe(removeTeamMember);
|
||||
expect(teamClient.updateMemberRole).toBe(updateMemberRole);
|
||||
expect(teamClient.addTeamTask).toBe(addTeamTask);
|
||||
expect(teamClient.updateTaskStatus).toBe(updateTaskStatus);
|
||||
expect(teamClient.assignTask).toBe(assignTask);
|
||||
expect(teamClient.submitDeliverable).toBe(submitDeliverable);
|
||||
expect(teamClient.startDevQALoop).toBe(startDevQALoop);
|
||||
expect(teamClient.submitReview).toBe(submitReview);
|
||||
expect(teamClient.updateLoopState).toBe(updateLoopState);
|
||||
expect(teamClient.getTeamMetrics).toBe(getTeamMetrics);
|
||||
expect(teamClient.getTeamEvents).toBe(getTeamEvents);
|
||||
expect(teamClient.subscribeToTeamEvents).toBe(subscribeToTeamEvents);
|
||||
});
|
||||
});
|
||||
});
|
||||
362
desktop/tests/store/teamStore.test.ts
Normal file
362
desktop/tests/store/teamStore.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Team Store Tests
|
||||
*
|
||||
* Tests for multi-agent team collaboration state management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useTeamStore } from '../../src/store/teamStore';
|
||||
import type { Team, TeamMember, TeamTask, CreateTeamRequest, AddTeamTaskRequest, TeamMemberRole } from '../../src/types/team';
|
||||
import { localStorageMock } from '../../tests/setup';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('teamStore', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
mockFetch.mockClear();
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should have correct initial state', () => {
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.teams).toEqual([]);
|
||||
expect(store.activeTeam).toBeNull();
|
||||
expect(store.metrics).toBeNull();
|
||||
expect(store.isLoading).toBe(false);
|
||||
expect(store.error).toBeNull();
|
||||
expect(store.selectedTaskId).toBeNull();
|
||||
expect(store.selectedMemberId).toBeNull();
|
||||
expect(store.recentEvents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTeams', () => {
|
||||
it('should load teams from localStorage', async () => {
|
||||
const mockTeams: Team[] = [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'sequential',
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
localStorageMock.setItem('zclaw-teams', JSON.stringify(mockTeams));
|
||||
await useTeamStore.getState().loadTeams();
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.teams).toEqual(mockTeams);
|
||||
expect(store.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTeam', () => {
|
||||
it('should create a new team with members', async () => {
|
||||
const request: CreateTeamRequest = {
|
||||
name: 'Dev Team',
|
||||
description: 'Development team',
|
||||
memberAgents: [
|
||||
{ agentId: 'agent-1', role: 'developer' },
|
||||
{ agentId: 'agent-2', role: 'reviewer' },
|
||||
],
|
||||
pattern: 'review_loop',
|
||||
};
|
||||
const team = await useTeamStore.getState().createTeam(request);
|
||||
expect(team).not.toBeNull();
|
||||
expect(team.name).toBe('Dev Team');
|
||||
expect(team.description).toBe('Development team');
|
||||
expect(team.pattern).toBe('review_loop');
|
||||
expect(team.members).toHaveLength(2);
|
||||
expect(team.status).toBe('active');
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.teams).toHaveLength(1);
|
||||
expect(store.activeTeam?.id).toBe(team.id);
|
||||
// Check localStorage was updated
|
||||
const stored = localStorageMock.getItem('zclaw-teams');
|
||||
expect(stored).toBeDefined();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTeam', () => {
|
||||
it('should delete a team', async () => {
|
||||
// First create a team
|
||||
const request: CreateTeamRequest = {
|
||||
name: 'Team to Delete',
|
||||
memberAgents: [],
|
||||
pattern: 'sequential',
|
||||
};
|
||||
await useTeamStore.getState().createTeam(request);
|
||||
// Then delete it
|
||||
const result = await useTeamStore.getState().deleteTeam('team-to-delete-id');
|
||||
expect(result).toBe(true);
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.teams.find(t => t.id === 'team-to-delete-id')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTeam', () => {
|
||||
it('should set active team and () => {
|
||||
const team: Team = {
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
members: [],
|
||||
tasks: [],
|
||||
pattern: 'sequential',
|
||||
activeLoops: [],
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
useTeamStore.getState().setActiveTeam(team);
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.activeTeam).toEqual(team);
|
||||
expect(store.metrics).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
let team: Team;
|
||||
beforeEach(async () => {
|
||||
const request: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [],
|
||||
pattern: 'sequential',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(request))!;
|
||||
});
|
||||
it('should add a member to team', async () => {
|
||||
const member = await useTeamStore.getState().addMember(team.id, 'agent-1', 'developer');
|
||||
expect(member).not.toBeNull();
|
||||
expect(member.agentId).toBe('agent-1');
|
||||
expect(member.role).toBe('developer');
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
expect(updatedTeam?.members).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
let team: Team;
|
||||
let memberId: string;
|
||||
beforeEach(async () => {
|
||||
const request: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [{ agentId: 'agent-1', role: 'developer' }],
|
||||
pattern: 'sequential',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(request))!;
|
||||
memberId = team.members[0].id;
|
||||
});
|
||||
it('should remove a member from team', async () => {
|
||||
const result = await useTeamStore.getState().removeMember(team.id, memberId);
|
||||
expect(result).toBe(true);
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
expect(updatedTeam?.members).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTask', () => {
|
||||
let team: Team;
|
||||
beforeEach(async () => {
|
||||
const request: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [],
|
||||
pattern: 'sequential',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(request))!;
|
||||
});
|
||||
it('should add a task to team', async () => {
|
||||
const taskRequest: AddTeamTaskRequest = {
|
||||
teamId: team.id,
|
||||
title: 'Test Task',
|
||||
description: 'Test task description',
|
||||
priority: 'high',
|
||||
type: 'implementation',
|
||||
};
|
||||
const task = await useTeamStore.getState().addTask(taskRequest);
|
||||
expect(task).not.toBeNull();
|
||||
expect(task.title).toBe('Test Task');
|
||||
expect(task.status).toBe('pending');
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
expect(updatedTeam?.tasks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskStatus', () => {
|
||||
let team: Team;
|
||||
let taskId: string;
|
||||
beforeEach(async () => {
|
||||
const teamRequest: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [],
|
||||
pattern: 'sequential',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(teamRequest))!;
|
||||
const taskRequest: AddTeamTaskRequest = {
|
||||
teamId: team.id,
|
||||
title: 'Test Task',
|
||||
priority: 'medium',
|
||||
type: 'implementation',
|
||||
};
|
||||
const task = await useTeamStore.getState().addTask(taskRequest);
|
||||
taskId = task!.id;
|
||||
});
|
||||
it('should update task status to in_progress', async () => {
|
||||
const result = await useTeamStore.getState().updateTaskStatus(team.id, taskId, 'in_progress');
|
||||
expect(result).toBe(true);
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
const updatedTask = updatedTeam?.tasks.find(t => t.id === taskId);
|
||||
expect(updatedTask?.status).toBe('in_progress');
|
||||
expect(updatedTask?.startedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDevQALoop', () => {
|
||||
let team: Team;
|
||||
let taskId: string;
|
||||
let memberId: string;
|
||||
beforeEach(async () => {
|
||||
const teamRequest: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [
|
||||
{ agentId: 'dev-agent', role: 'developer' },
|
||||
{ agentId: 'qa-agent', role: 'reviewer' },
|
||||
],
|
||||
pattern: 'review_loop',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(teamRequest))!;
|
||||
const taskRequest: AddTeamTaskRequest = {
|
||||
teamId: team.id,
|
||||
title: 'Test Task',
|
||||
priority: 'high',
|
||||
type: 'implementation',
|
||||
assigneeId: team.members[0].id,
|
||||
};
|
||||
const task = await useTeamStore.getState().addTask(taskRequest);
|
||||
taskId = task!.id;
|
||||
memberId = team.members[0].id;
|
||||
});
|
||||
it('should start a Dev-QA loop', async () => {
|
||||
const loop = await useTeamStore.getState().startDevQALoop(
|
||||
team.id,
|
||||
taskId,
|
||||
team.members[0].id,
|
||||
team.members[1].id
|
||||
);
|
||||
expect(loop).not.toBeNull();
|
||||
expect(loop.state).toBe('developing');
|
||||
expect(loop.developerId).toBe(team.members[0].id);
|
||||
expect(loop.reviewerId).toBe(team.members[1].id);
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
expect(updatedTeam?.activeLoops).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitReview', () => {
|
||||
let team: Team;
|
||||
let loop: any;
|
||||
beforeEach(async () => {
|
||||
const teamRequest: CreateTeamRequest = {
|
||||
name: 'Test Team',
|
||||
memberAgents: [
|
||||
{ agentId: 'dev-agent', role: 'developer' },
|
||||
{ agentId: 'qa-agent', role: 'reviewer' },
|
||||
],
|
||||
pattern: 'review_loop',
|
||||
};
|
||||
team = (await useTeamStore.getState().createTeam(teamRequest))!;
|
||||
const taskRequest: AddTeamTaskRequest = {
|
||||
teamId: team.id,
|
||||
title: 'Test Task',
|
||||
priority: 'high',
|
||||
type: 'implementation',
|
||||
assigneeId: team.members[0].id,
|
||||
};
|
||||
const task = await useTeamStore.getState().addTask(taskRequest);
|
||||
loop = await useTeamStore.getState().startDevQALoop(
|
||||
team.id,
|
||||
task!.id,
|
||||
team.members[0].id,
|
||||
team.members[1].id
|
||||
);
|
||||
});
|
||||
it('should submit review and async () => {
|
||||
const feedback = {
|
||||
verdict: 'approved',
|
||||
comments: ['Good work!'],
|
||||
issues: [],
|
||||
};
|
||||
const result = await useTeamStore.getState().submitReview(team.id, loop.id, feedback);
|
||||
expect(result).toBe(true);
|
||||
const store = useTeamStore.getState();
|
||||
const updatedTeam = store.teams.find(t => t.id === team.id);
|
||||
const updatedLoop = updatedTeam?.activeLoops.find(l => l.id === loop.id);
|
||||
expect(updatedLoop?.state).toBe('approved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addEvent', () => {
|
||||
it('should add event to recent events', () => {
|
||||
const event = {
|
||||
type: 'task_completed',
|
||||
teamId: 'team-1',
|
||||
sourceAgentId: 'agent-1',
|
||||
payload: { taskId: 'task-1' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
useTeamStore.getState().addEvent(event);
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.recentEvents).toHaveLength(1);
|
||||
expect(store.recentEvents[0]).toEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearEvents', () => {
|
||||
it('should clear all events', () => {
|
||||
const event = {
|
||||
type: 'task_completed',
|
||||
teamId: 'team-1',
|
||||
sourceAgentId: 'agent-1',
|
||||
payload: {},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
useTeamStore.getState().addEvent(event);
|
||||
useTeamStore.getState().clearEvents();
|
||||
const store = useTeamStore.getState();
|
||||
expect(store.recentEvents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI Actions', () => {
|
||||
it('should set selected task', () => {
|
||||
useTeamStore.getState().setSelectedTask('task-1');
|
||||
expect(useTeamStore.getState().selectedTaskId).toBe('task-1');
|
||||
});
|
||||
it('should set selected member', () => {
|
||||
useTeamStore.getState().setSelectedMember('member-1');
|
||||
expect(useTeamStore.getState().selectedMemberId).toBe('member-1');
|
||||
});
|
||||
it('should clear error', () => {
|
||||
useTeamStore.setState({ error: 'Test error' });
|
||||
useTeamStore.getState().clearError();
|
||||
expect(useTeamStore.getState().error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
169
desktop/tests/toml-utils.test.ts
Normal file
169
desktop/tests/toml-utils.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* TOML Utility Tests
|
||||
*
|
||||
* Tests for TOML parsing and configuration handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tomlUtils, TomlParseError, TomlStringifyError } from '../src/lib/toml-utils';
|
||||
|
||||
describe('tomlUtils', () => {
|
||||
describe('parse', () => {
|
||||
it('should parse basic TOML correctly', () => {
|
||||
const toml = `
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4200
|
||||
`;
|
||||
const result = tomlUtils.parse(toml);
|
||||
expect(result).toEqual({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse nested tables correctly', () => {
|
||||
const toml = `
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
|
||||
[server.websocket]
|
||||
port = 8080
|
||||
path = "/ws"
|
||||
`;
|
||||
const result = tomlUtils.parse(toml);
|
||||
expect(result).toEqual({
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
websocket: {
|
||||
port: 8080,
|
||||
path: '/ws',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse arrays correctly', () => {
|
||||
const toml = `
|
||||
[[servers]]
|
||||
name = "primary"
|
||||
port = 4200
|
||||
|
||||
[[servers]]
|
||||
name = "secondary"
|
||||
port = 4201
|
||||
`;
|
||||
const result = tomlUtils.parse(toml);
|
||||
expect(result).toEqual({
|
||||
servers: [
|
||||
{ name: 'primary', port: 4200 },
|
||||
{ name: 'secondary', port: 4201 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw TomlParseError on invalid TOML', () => {
|
||||
const invalidToml = `
|
||||
[invalid
|
||||
key = value
|
||||
`;
|
||||
expect(() => tomlUtils.parse(invalidToml)).toThrow(TomlParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringify', () => {
|
||||
it('should stringify basic objects correctly', () => {
|
||||
const data = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4200,
|
||||
},
|
||||
};
|
||||
const result = tomlUtils.stringify(data);
|
||||
expect(result).toContain('host = "127.0.0.1"');
|
||||
expect(result).toContain('port = 4200');
|
||||
});
|
||||
|
||||
it('should throw TomlStringifyError on invalid data', () => {
|
||||
const circularData: Record<string, unknown> = { self: {} };
|
||||
circularData.self = circularData;
|
||||
|
||||
expect(() => tomlUtils.stringify(circularData)).toThrow(TomlStringifyError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEnvVars', () => {
|
||||
it('should resolve environment variables', () => {
|
||||
const content = 'api_key = "${API_KEY}"';
|
||||
const envVars = { API_KEY: 'secret-key-123' };
|
||||
const result = tomlUtils.resolveEnvVars(content, envVars);
|
||||
expect(result).toBe('api_key = "secret-key-123"');
|
||||
});
|
||||
|
||||
it('should return empty string for missing env vars', () => {
|
||||
const content = 'api_key = "${MISSING_VAR}"';
|
||||
const result = tomlUtils.resolveEnvVars(content);
|
||||
expect(result).toBe('api_key = ""');
|
||||
});
|
||||
|
||||
it('should handle multiple env vars', () => {
|
||||
const content = `
|
||||
key1 = "${VAR1}"
|
||||
key2 = "${VAR2}"
|
||||
`;
|
||||
const envVars = { VAR1: 'value1', VAR2: 'value2' };
|
||||
const result = tomlUtils.resolveEnvVars(content, envVars);
|
||||
expect(result).toContain('key1 = "value1"');
|
||||
expect(result).toContain('key2 = "value2"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWithEnvVars', () => {
|
||||
it('should parse TOML with env var resolution', () => {
|
||||
const content = `
|
||||
[config]
|
||||
api_key = "${API_KEY}"
|
||||
model = "gpt-4"
|
||||
`;
|
||||
const envVars = { API_KEY: 'test-key-456' };
|
||||
const result = tomlUtils.parseWithEnvVars(content, envVars);
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
api_key: 'test-key-456',
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUnresolvedEnvVars', () => {
|
||||
it('should return true when env vars are present', () => {
|
||||
const content = 'api_key = "${API_KEY}"';
|
||||
expect(tomlUtils.hasUnresolvedEnvVars(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no env vars', () => {
|
||||
const content = 'api_key = "hardcoded-key"';
|
||||
expect(tomlUtils.hasUnresolvedEnvVars(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnvVarNames', () => {
|
||||
it('should extract all env var names', () => {
|
||||
const content = `
|
||||
key1 = "${VAR1}"
|
||||
key2 = "${VAR2}"
|
||||
key1 = "${VAR1}"
|
||||
`;
|
||||
const result = tomlUtils.extractEnvVarNames(content);
|
||||
expect(result).toEqual(['VAR1', 'VAR2']);
|
||||
});
|
||||
|
||||
it('should return empty array for no env vars', () => {
|
||||
const content = 'key = "value"';
|
||||
expect(tomlUtils.extractEnvVarNames(content)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user