feat(hands): restructure Hands UI with Chinese localization
Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
500
tests/desktop/chatStore.test.ts
Normal file
500
tests/desktop/chatStore.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const chatMock = vi.fn();
|
||||
const onAgentStreamMock = vi.fn((_callback?: (delta: any) => void) => () => {});
|
||||
let agentStreamHandler: ((delta: any) => void) | null = null;
|
||||
|
||||
vi.mock('../../desktop/src/lib/gateway-client', () => ({
|
||||
getGatewayClient: () => ({
|
||||
chat: chatMock,
|
||||
onAgentStream: (callback: (delta: any) => void) => {
|
||||
agentStreamHandler = callback;
|
||||
return onAgentStreamMock();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Re-export AgentStreamDelta type for test usage
|
||||
type AgentStreamDelta = {
|
||||
stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow';
|
||||
delta?: string;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
phase?: 'start' | 'end' | 'error';
|
||||
runId?: string;
|
||||
error?: string;
|
||||
handName?: string;
|
||||
handStatus?: string;
|
||||
handResult?: unknown;
|
||||
workflowId?: string;
|
||||
workflowStep?: string;
|
||||
workflowStatus?: string;
|
||||
workflowResult?: unknown;
|
||||
};
|
||||
|
||||
describe('chatStore sendMessage', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('sends the first message with a generated sessionKey and persists it after success', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_1' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
const [, options] = chatMock.mock.calls[0];
|
||||
expect(options).toMatchObject({
|
||||
sessionKey: expect.stringMatching(/^session_/),
|
||||
});
|
||||
expect(options.agentId).toBeUndefined();
|
||||
expect(useChatStore.getState().sessionKey).toBe(options.sessionKey);
|
||||
});
|
||||
|
||||
it('does not persist a generated sessionKey when the request fails', async () => {
|
||||
chatMock.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1].sessionKey).toMatch(/^session_/);
|
||||
expect(useChatStore.getState().sessionKey).toBeNull();
|
||||
});
|
||||
|
||||
it('does not send local clone ids directly as gateway agent ids', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_clone' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1]).toMatchObject({
|
||||
sessionKey: expect.stringMatching(/^session_/),
|
||||
});
|
||||
expect(chatMock.mock.calls[0][1].agentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send the placeholder default agent id to the gateway', async () => {
|
||||
chatMock.mockResolvedValue({ runId: 'run_default' });
|
||||
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().sendMessage('hello');
|
||||
|
||||
expect(chatMock).toHaveBeenCalledTimes(1);
|
||||
expect(chatMock.mock.calls[0][1].agentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore agent/session isolation', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('saves the active conversation and clears session state when switching to a different agent', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user_1',
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: {
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_alpha',
|
||||
});
|
||||
|
||||
useChatStore.getState().setCurrentAgent({
|
||||
id: 'clone_beta',
|
||||
name: 'Beta',
|
||||
icon: 'B',
|
||||
color: 'bg-blue-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.currentAgent?.id).toBe('clone_beta');
|
||||
expect(state.messages).toEqual([]);
|
||||
expect(state.sessionKey).toBeNull();
|
||||
expect(state.currentConversationId).toBeNull();
|
||||
expect(state.conversations).toHaveLength(1);
|
||||
expect(state.conversations[0]).toMatchObject({
|
||||
sessionKey: 'session_alpha',
|
||||
agentId: 'clone_alpha',
|
||||
title: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the conversation agent when switching back to a saved conversation', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [],
|
||||
conversations: [
|
||||
{
|
||||
id: 'conv_saved',
|
||||
title: 'saved',
|
||||
messages: [
|
||||
{
|
||||
id: 'user_saved',
|
||||
role: 'user',
|
||||
content: 'saved',
|
||||
timestamp: new Date('2026-03-13T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
sessionKey: 'session_saved',
|
||||
agentId: 'clone_alpha',
|
||||
createdAt: new Date('2026-03-13T00:01:00.000Z'),
|
||||
updatedAt: new Date('2026-03-13T00:01:00.000Z'),
|
||||
},
|
||||
],
|
||||
currentConversationId: null,
|
||||
agents: [
|
||||
{
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha',
|
||||
icon: 'A',
|
||||
color: 'bg-orange-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
],
|
||||
currentAgent: {
|
||||
id: 'clone_beta',
|
||||
name: 'Beta',
|
||||
icon: 'B',
|
||||
color: 'bg-blue-500',
|
||||
lastMessage: '',
|
||||
time: '',
|
||||
},
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
});
|
||||
|
||||
useChatStore.getState().switchConversation('conv_saved');
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.currentConversationId).toBe('conv_saved');
|
||||
expect(state.sessionKey).toBe('session_saved');
|
||||
expect(state.currentAgent?.id).toBe('clone_alpha');
|
||||
expect(state.messages).toHaveLength(1);
|
||||
expect(state.messages[0].content).toBe('saved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore stream correlation', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('correlates assistant deltas to the matching runId instead of the last streaming message', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_old',
|
||||
role: 'assistant',
|
||||
content: 'old:',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_old',
|
||||
},
|
||||
{
|
||||
id: 'assistant_new',
|
||||
role: 'assistant',
|
||||
content: 'new:',
|
||||
timestamp: new Date('2026-03-13T00:00:01.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_new',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
expect(agentStreamHandler).toBeTypeOf('function');
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'assistant',
|
||||
runId: 'run_old',
|
||||
delta: 'delta-for-old',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.messages.find((message) => message.id === 'assistant_old')?.content).toBe('old:delta-for-old');
|
||||
expect(state.messages.find((message) => message.id === 'assistant_new')?.content).toBe('new:');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('marks only the matching runId message as completed on lifecycle end', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_old',
|
||||
role: 'assistant',
|
||||
content: 'old',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_old',
|
||||
},
|
||||
{
|
||||
id: 'assistant_new',
|
||||
role: 'assistant',
|
||||
content: 'new',
|
||||
timestamp: new Date('2026-03-13T00:00:01.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_new',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'lifecycle',
|
||||
runId: 'run_old',
|
||||
phase: 'end',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
expect(state.messages.find((message) => message.id === 'assistant_old')?.streaming).toBe(false);
|
||||
expect(state.messages.find((message) => message.id === 'assistant_new')?.streaming).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatStore OpenFang events', () => {
|
||||
beforeEach(() => {
|
||||
chatMock.mockReset();
|
||||
onAgentStreamMock.mockClear();
|
||||
agentStreamHandler = null;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates a hand message when receiving hand stream events', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Processing...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'hand',
|
||||
runId: 'run_1',
|
||||
handName: 'summarize',
|
||||
handStatus: 'completed',
|
||||
handResult: { summary: 'This is a test summary' },
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const handMsg = state.messages.find((m) => m.role === 'hand');
|
||||
expect(handMsg).toBeDefined();
|
||||
expect(handMsg?.handName).toBe('summarize');
|
||||
expect(handMsg?.handStatus).toBe('completed');
|
||||
expect(handMsg?.content).toContain('summary');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('creates a workflow message when receiving workflow stream events', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Running workflow...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'workflow',
|
||||
runId: 'run_1',
|
||||
workflowId: 'wf_analysis',
|
||||
workflowStep: 'step_2',
|
||||
workflowStatus: 'running',
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const workflowMsg = state.messages.find((m) => m.role === 'workflow');
|
||||
expect(workflowMsg).toBeDefined();
|
||||
expect(workflowMsg?.workflowId).toBe('wf_analysis');
|
||||
expect(workflowMsg?.workflowStep).toBe('step_2');
|
||||
expect(workflowMsg?.workflowStatus).toBe('running');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('includes workflow result in the message content when available', async () => {
|
||||
const { useChatStore } = await import('../../desktop/src/store/chatStore');
|
||||
useChatStore.setState({
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant_1',
|
||||
role: 'assistant',
|
||||
content: 'Running workflow...',
|
||||
timestamp: new Date('2026-03-13T00:00:00.000Z'),
|
||||
streaming: true,
|
||||
runId: 'run_1',
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [],
|
||||
currentAgent: null,
|
||||
isStreaming: true,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: 'session_test',
|
||||
});
|
||||
|
||||
const unsubscribe = useChatStore.getState().initStreamListener();
|
||||
|
||||
agentStreamHandler?.({
|
||||
stream: 'workflow',
|
||||
runId: 'run_1',
|
||||
workflowId: 'wf_analysis',
|
||||
workflowStep: 'final',
|
||||
workflowStatus: 'completed',
|
||||
workflowResult: { output: 'Analysis complete', score: 95 },
|
||||
});
|
||||
|
||||
const state = useChatStore.getState();
|
||||
const workflowMsg = state.messages.find((m) => m.role === 'workflow');
|
||||
expect(workflowMsg).toBeDefined();
|
||||
expect(workflowMsg?.content).toContain('output');
|
||||
expect(workflowMsg?.content).toContain('score');
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
620
tests/desktop/gatewayStore.test.ts
Normal file
620
tests/desktop/gatewayStore.test.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
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 },
|
||||
],
|
||||
})),
|
||||
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.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: 'active' },
|
||||
{ name: 'notify', description: 'Notification handler', status: 'active' },
|
||||
],
|
||||
});
|
||||
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' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('gatewayStore desktop flows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
resetClientMocks();
|
||||
});
|
||||
|
||||
it('loads post-connect data and syncs agents after a successful connection', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().connect('ws://127.0.0.1:18789', 'token-123');
|
||||
|
||||
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([
|
||||
{ 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 useGatewayStore.getState().loadChannels();
|
||||
|
||||
expect(useGatewayStore.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');
|
||||
|
||||
useGatewayStore.setState({
|
||||
quickConfig: {
|
||||
agentName: 'Alpha',
|
||||
theme: 'light',
|
||||
gatewayUrl: 'ws://127.0.0.1:18789',
|
||||
gatewayToken: 'old-token',
|
||||
},
|
||||
});
|
||||
|
||||
await useGatewayStore.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(useGatewayStore.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,
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadClones();
|
||||
const updated = await useGatewayStore.getState().updateClone('clone_alpha', {
|
||||
name: 'Alpha Prime',
|
||||
role: '架构助手',
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
id: 'clone_alpha',
|
||||
name: 'Alpha Prime',
|
||||
role: '架构助手',
|
||||
});
|
||||
expect(useGatewayStore.getState().clones).toEqual(refreshedClones);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenFang actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
resetClientMocks();
|
||||
});
|
||||
|
||||
it('loads hands from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadHands();
|
||||
|
||||
expect(mockClient.listHands).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.getState().hands).toEqual([
|
||||
{ name: 'echo', description: 'Echo handler', status: 'active' },
|
||||
{ name: 'notify', description: 'Notification handler', status: 'active' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('triggers a hand and returns the run result', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
const result = await useGatewayStore.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'));
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
const result = await useGatewayStore.getState().triggerHand('nonexistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(useGatewayStore.getState().error).toBe('Hand not found');
|
||||
});
|
||||
|
||||
it('loads workflows from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadWorkflows();
|
||||
|
||||
expect(mockClient.listWorkflows).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.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');
|
||||
|
||||
const result = await useGatewayStore.getState().executeWorkflow('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 () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadTriggers();
|
||||
|
||||
expect(mockClient.listTriggers).toHaveBeenCalledTimes(1);
|
||||
expect(useGatewayStore.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 useGatewayStore.getState().loadAuditLogs({ limit: 50, offset: 0 });
|
||||
|
||||
expect(mockClient.getAuditLogs).toHaveBeenCalledWith({ limit: 50, offset: 0 });
|
||||
expect(useGatewayStore.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 state = useGatewayStore.getState();
|
||||
expect(state.hands).toEqual([]);
|
||||
expect(state.workflows).toEqual([]);
|
||||
expect(state.triggers).toEqual([]);
|
||||
expect(state.auditLogs).toEqual([]);
|
||||
});
|
||||
|
||||
// === Security Tests ===
|
||||
|
||||
it('loads security status from the gateway', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadSecurityStatus();
|
||||
|
||||
expect(mockClient.getSecurityStatus).toHaveBeenCalledTimes(1);
|
||||
const { securityStatus } = useGatewayStore.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 () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
await useGatewayStore.getState().loadSecurityStatus();
|
||||
|
||||
const { securityStatus } = useGatewayStore.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 useGatewayStore.getState().loadSecurityStatus();
|
||||
|
||||
const { securityStatus } = useGatewayStore.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 () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
// Reset store state
|
||||
useGatewayStore.setState({ hands: [], isLoading: false });
|
||||
|
||||
const loadPromise = useGatewayStore.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);
|
||||
});
|
||||
|
||||
it('sets isLoading during loadWorkflows', async () => {
|
||||
const { useGatewayStore } = await import('../../desktop/src/store/gatewayStore');
|
||||
|
||||
// Reset store state
|
||||
useGatewayStore.setState({ workflows: [], isLoading: false });
|
||||
|
||||
await useGatewayStore.getState().loadWorkflows();
|
||||
|
||||
expect(useGatewayStore.getState().isLoading).toBe(false);
|
||||
expect(useGatewayStore.getState().workflows.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
98
tests/desktop/general-settings.test.tsx
Normal file
98
tests/desktop/general-settings.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useGatewayStoreMock = vi.fn();
|
||||
const useChatStoreMock = vi.fn();
|
||||
const getStoredGatewayUrlMock = vi.fn(() => 'ws://127.0.0.1:18789');
|
||||
const getStoredGatewayTokenMock = vi.fn(() => 'stored-token');
|
||||
|
||||
vi.mock('../../desktop/src/store/gatewayStore', () => ({
|
||||
useGatewayStore: () => useGatewayStoreMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../desktop/src/store/chatStore', () => ({
|
||||
useChatStore: () => useChatStoreMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../desktop/src/lib/gateway-client', () => ({
|
||||
getStoredGatewayUrl: () => getStoredGatewayUrlMock(),
|
||||
getStoredGatewayToken: () => getStoredGatewayTokenMock(),
|
||||
}));
|
||||
|
||||
describe('General settings local gateway diagnostics', () => {
|
||||
let refreshLocalGatewayMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
refreshLocalGatewayMock = vi.fn(async () => ({ supported: true }));
|
||||
|
||||
useGatewayStoreMock.mockReturnValue({
|
||||
connectionState: 'connected',
|
||||
gatewayVersion: '2026.3.11',
|
||||
error: null,
|
||||
localGatewayBusy: false,
|
||||
localGateway: {
|
||||
supported: true,
|
||||
cliAvailable: true,
|
||||
serviceLoaded: true,
|
||||
serviceLabel: 'OpenClaw Gateway',
|
||||
serviceStatus: 'running',
|
||||
port: 18789,
|
||||
portStatus: 'busy',
|
||||
probeUrl: 'ws://127.0.0.1:18789',
|
||||
listenerPids: [1234],
|
||||
runtimeSource: 'bundled',
|
||||
runtimePath: 'C:/ZCLAW/resources/openclaw-runtime',
|
||||
error: null,
|
||||
},
|
||||
quickConfig: {
|
||||
gatewayUrl: 'ws://127.0.0.1:18789',
|
||||
gatewayToken: '',
|
||||
theme: 'light',
|
||||
autoStart: false,
|
||||
showToolCalls: false,
|
||||
},
|
||||
connect: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(),
|
||||
saveQuickConfig: vi.fn(async () => {}),
|
||||
refreshLocalGateway: refreshLocalGatewayMock,
|
||||
startLocalGateway: vi.fn(async () => undefined),
|
||||
stopLocalGateway: vi.fn(async () => undefined),
|
||||
restartLocalGateway: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
useChatStoreMock.mockReturnValue({
|
||||
currentModel: 'glm-5',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders bundled runtime diagnostics and refreshes local gateway status on mount', async () => {
|
||||
const reactModule = 'react';
|
||||
const reactDomClientModule = 'react-dom/client';
|
||||
const [{ act, createElement }, { createRoot }, { General }] = await Promise.all([
|
||||
import(reactModule),
|
||||
import(reactDomClientModule),
|
||||
import('../../desktop/src/components/Settings/General'),
|
||||
]);
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(General));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('本地 Gateway');
|
||||
expect(container.textContent).toContain('运行时来源');
|
||||
expect(container.textContent).toContain('内置运行时');
|
||||
expect(container.textContent).toContain('运行时路径');
|
||||
expect(container.textContent).toContain('C:/ZCLAW/resources/openclaw-runtime');
|
||||
expect(refreshLocalGatewayMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
521
tests/desktop/integration/openfang-api.test.ts
Normal file
521
tests/desktop/integration/openfang-api.test.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* OpenFang API Integration Tests
|
||||
*
|
||||
* Uses the mock server to test Hands, Workflows, Security, and Audit APIs
|
||||
* via REST endpoints. Does not require WebSocket handshake.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createOpenFangMockServer, MockServerInstance } from '../../fixtures/openfang-mock-server';
|
||||
|
||||
describe('OpenFang API Integration', () => {
|
||||
let server: MockServerInstance;
|
||||
let baseUrl: string;
|
||||
const testPort = 14200;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createOpenFangMockServer({ port: testPort });
|
||||
await server.start();
|
||||
baseUrl = server.getHttpUrl();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
// Helper function for REST calls
|
||||
async function restGet(path: string): Promise<Response> {
|
||||
return fetch(`${baseUrl}${path}`);
|
||||
}
|
||||
|
||||
async function restPost(path: string, body?: unknown): Promise<Response> {
|
||||
return fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// === Hands API Tests ===
|
||||
|
||||
describe('Hands API', () => {
|
||||
it('GET /api/hands returns hands list', async () => {
|
||||
const response = await restGet('/api/hands');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result).toBeDefined();
|
||||
expect(result.hands).toBeDefined();
|
||||
expect(Array.isArray(result.hands)).toBe(true);
|
||||
expect(result.hands.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify default hands are present
|
||||
const handNames = result.hands.map((h: { name: string }) => h.name);
|
||||
expect(handNames).toContain('clip');
|
||||
expect(handNames).toContain('lead');
|
||||
expect(handNames).toContain('collector');
|
||||
expect(handNames).toContain('predictor');
|
||||
expect(handNames).toContain('researcher');
|
||||
expect(handNames).toContain('twitter');
|
||||
expect(handNames).toContain('browser');
|
||||
});
|
||||
|
||||
it('GET /api/hands/:name returns hand details', async () => {
|
||||
const response = await restGet('/api/hands/clip');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.name).toBe('clip');
|
||||
expect(result.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/trigger triggers a hand', async () => {
|
||||
const response = await restPost('/api/hands/clip/trigger');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.runId).toBeDefined();
|
||||
expect(result.status).toBeDefined();
|
||||
expect(typeof result.runId).toBe('string');
|
||||
expect(result.runId).toMatch(/^run-clip-\d+$/);
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/trigger with params passes parameters', async () => {
|
||||
const params = { target: 'video.mp4', quality: 'high' };
|
||||
const response = await restPost('/api/hands/clip/trigger', params);
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.runId).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/hands/lead/trigger returns needs_approval status', async () => {
|
||||
// 'lead' hand requires approval in mock server
|
||||
const response = await restPost('/api/hands/lead/trigger');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.status).toBe('needs_approval');
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/runs/:runId/approve approves a hand execution', async () => {
|
||||
// First trigger a hand that needs approval
|
||||
const triggerResponse = await restPost('/api/hands/lead/trigger');
|
||||
const triggerResult = await triggerResponse.json();
|
||||
expect(triggerResult.status).toBe('needs_approval');
|
||||
|
||||
// Approve the hand
|
||||
const approveResponse = await restPost(
|
||||
`/api/hands/lead/runs/${triggerResult.runId}/approve`,
|
||||
{ approved: true }
|
||||
);
|
||||
expect(approveResponse.ok).toBe(true);
|
||||
|
||||
const approveResult = await approveResponse.json();
|
||||
expect(approveResult.status).toBe('running');
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/runs/:runId/approve rejects a hand execution', async () => {
|
||||
// First trigger a hand that needs approval
|
||||
const triggerResponse = await restPost('/api/hands/lead/trigger');
|
||||
const triggerResult = await triggerResponse.json();
|
||||
|
||||
// Reject the hand
|
||||
const rejectResponse = await restPost(
|
||||
`/api/hands/lead/runs/${triggerResult.runId}/approve`,
|
||||
{ approved: false, reason: 'Not authorized' }
|
||||
);
|
||||
expect(rejectResponse.ok).toBe(true);
|
||||
|
||||
const rejectResult = await rejectResponse.json();
|
||||
expect(rejectResult.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/runs/:runId/cancel cancels a running hand', async () => {
|
||||
// First trigger a hand
|
||||
const triggerResponse = await restPost('/api/hands/collector/trigger');
|
||||
const triggerResult = await triggerResponse.json();
|
||||
|
||||
// Cancel it
|
||||
const cancelResponse = await restPost(
|
||||
`/api/hands/collector/runs/${triggerResult.runId}/cancel`
|
||||
);
|
||||
expect(cancelResponse.ok).toBe(true);
|
||||
|
||||
const cancelResult = await cancelResponse.json();
|
||||
expect(cancelResult.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('GET /api/hands/:name/runs returns execution history', async () => {
|
||||
// Trigger a few hands first
|
||||
await restPost('/api/hands/clip/trigger');
|
||||
await restPost('/api/hands/collector/trigger');
|
||||
|
||||
const response = await restGet('/api/hands/clip/runs');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.runs).toBeDefined();
|
||||
expect(Array.isArray(result.runs)).toBe(true);
|
||||
expect(result.runs.length).toBeGreaterThan(0);
|
||||
|
||||
const clipRun = result.runs.find((r: { runId: string }) => r.runId.includes('clip'));
|
||||
expect(clipRun).toBeDefined();
|
||||
expect(clipRun.status).toBeDefined();
|
||||
expect(clipRun.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/hands/:name returns 404 for non-existent hand', async () => {
|
||||
const response = await restGet('/api/hands/nonexistent-hand');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/hands/:name/trigger returns 404 for non-existent hand', async () => {
|
||||
const response = await restPost('/api/hands/nonexistent-hand/trigger');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// === Workflows API Tests ===
|
||||
|
||||
describe('Workflows API', () => {
|
||||
it('GET /api/workflows returns workflows list', async () => {
|
||||
const response = await restGet('/api/workflows');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.workflows).toBeDefined();
|
||||
expect(Array.isArray(result.workflows)).toBe(true);
|
||||
expect(result.workflows.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify default workflows are present
|
||||
const workflowIds = result.workflows.map((w: { id: string }) => w.id);
|
||||
expect(workflowIds).toContain('wf-001');
|
||||
expect(workflowIds).toContain('wf-002');
|
||||
expect(workflowIds).toContain('wf-003');
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:id returns workflow details', async () => {
|
||||
const response = await restGet('/api/workflows/wf-001');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.id).toBe('wf-001');
|
||||
expect(result.name).toBeDefined();
|
||||
expect(result.steps).toBeDefined();
|
||||
expect(Array.isArray(result.steps)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/workflows/:id/execute starts a workflow', async () => {
|
||||
const input = { topic: 'Test workflow' };
|
||||
const response = await restPost('/api/workflows/wf-001/execute', input);
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.runId).toBeDefined();
|
||||
expect(result.status).toBeDefined();
|
||||
expect(result.status).toBe('running');
|
||||
expect(typeof result.runId).toBe('string');
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:id/runs/:runId returns execution status', async () => {
|
||||
// First execute a workflow
|
||||
const executeResponse = await restPost('/api/workflows/wf-001/execute', { data: 'test' });
|
||||
const executeResult = await executeResponse.json();
|
||||
|
||||
// Get the run status
|
||||
const statusResponse = await restGet(
|
||||
`/api/workflows/wf-001/runs/${executeResult.runId}`
|
||||
);
|
||||
expect(statusResponse.ok).toBe(true);
|
||||
|
||||
const runStatus = await statusResponse.json();
|
||||
expect(runStatus.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/workflows/:id/runs/:runId/cancel cancels a workflow', async () => {
|
||||
// First execute a workflow
|
||||
const executeResponse = await restPost('/api/workflows/wf-002/execute', {});
|
||||
const executeResult = await executeResponse.json();
|
||||
|
||||
// Cancel it
|
||||
const cancelResponse = await restPost(
|
||||
`/api/workflows/wf-002/runs/${executeResult.runId}/cancel`
|
||||
);
|
||||
expect(cancelResponse.ok).toBe(true);
|
||||
|
||||
const cancelResult = await cancelResponse.json();
|
||||
expect(cancelResult.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('GET /api/workflows/:id returns 404 for non-existent workflow', async () => {
|
||||
const response = await restGet('/api/workflows/nonexistent-wf');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/workflows/:id/execute returns 404 for non-existent workflow', async () => {
|
||||
const response = await restPost('/api/workflows/nonexistent-wf/execute', {});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// === Security API Tests ===
|
||||
|
||||
describe('Security API', () => {
|
||||
it('GET /api/security/status returns security layers', async () => {
|
||||
const response = await restGet('/api/security/status');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.layers).toBeDefined();
|
||||
expect(Array.isArray(result.layers)).toBe(true);
|
||||
expect(result.layers.length).toBe(16);
|
||||
|
||||
// Verify layer structure
|
||||
const layer = result.layers[0];
|
||||
expect(layer.name).toBeDefined();
|
||||
expect(typeof layer.enabled).toBe('boolean');
|
||||
});
|
||||
|
||||
it('securityLevel is calculated correctly for critical level', async () => {
|
||||
const response = await restGet('/api/security/status');
|
||||
const result = await response.json();
|
||||
|
||||
// Default config has 15 enabled layers out of 16
|
||||
// This should be 'critical' level (14/16 = 87.5% >= 87.5%)
|
||||
const enabledCount = result.layers.filter(
|
||||
(l: { enabled: boolean }) => l.enabled
|
||||
).length;
|
||||
expect(enabledCount).toBeGreaterThanOrEqual(14);
|
||||
expect(result.securityLevel).toBe('critical');
|
||||
});
|
||||
|
||||
it('securityLevel is calculated correctly with custom layers', async () => {
|
||||
// Set custom security layers with only 10 enabled
|
||||
server.setSecurityLayers([
|
||||
{ name: 'layer1', enabled: true },
|
||||
{ name: 'layer2', enabled: true },
|
||||
{ name: 'layer3', enabled: true },
|
||||
{ name: 'layer4', enabled: true },
|
||||
{ name: 'layer5', enabled: true },
|
||||
{ name: 'layer6', enabled: true },
|
||||
{ name: 'layer7', enabled: true },
|
||||
{ name: 'layer8', enabled: true },
|
||||
{ name: 'layer9', enabled: true },
|
||||
{ name: 'layer10', enabled: true },
|
||||
{ name: 'layer11', enabled: false },
|
||||
{ name: 'layer12', enabled: false },
|
||||
{ name: 'layer13', enabled: false },
|
||||
{ name: 'layer14', enabled: false },
|
||||
{ name: 'layer15', enabled: false },
|
||||
{ name: 'layer16', enabled: false },
|
||||
]);
|
||||
|
||||
const response = await restGet('/api/security/status');
|
||||
const result = await response.json();
|
||||
|
||||
expect(result.layers).toBeDefined();
|
||||
const enabledCount = result.layers.filter(
|
||||
(l: { enabled: boolean }) => l.enabled
|
||||
).length;
|
||||
expect(enabledCount).toBe(10);
|
||||
expect(result.securityLevel).toBe('high'); // 10/16 = 62.5%
|
||||
});
|
||||
|
||||
it('GET /api/capabilities returns capabilities list', async () => {
|
||||
const response = await restGet('/api/capabilities');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.capabilities).toBeDefined();
|
||||
expect(Array.isArray(result.capabilities)).toBe(true);
|
||||
expect(result.capabilities.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify expected capabilities
|
||||
expect(result.capabilities).toContain('operator.read');
|
||||
expect(result.capabilities).toContain('operator.write');
|
||||
expect(result.capabilities).toContain('operator.admin');
|
||||
expect(result.capabilities).toContain('operator.approvals');
|
||||
expect(result.capabilities).toContain('operator.pairing');
|
||||
});
|
||||
});
|
||||
|
||||
// === Audit Logs API Tests ===
|
||||
|
||||
describe('Audit Logs API', () => {
|
||||
it('GET /api/audit/logs returns paginated logs', async () => {
|
||||
const response = await restGet('/api/audit/logs');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.logs).toBeDefined();
|
||||
expect(Array.isArray(result.logs)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/audit/logs respects limit parameter', async () => {
|
||||
// First add some audit logs
|
||||
server.addAuditLog({ action: 'test.action', actor: 'test-user', result: 'success' });
|
||||
server.addAuditLog({ action: 'test.action2', actor: 'test-user2', result: 'success' });
|
||||
server.addAuditLog({ action: 'test.action3', actor: 'test-user3', result: 'failure' });
|
||||
|
||||
const response = await restGet('/api/audit/logs?limit=2');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.logs.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('GET /api/audit/logs respects offset parameter', async () => {
|
||||
// Reset server state and add multiple logs
|
||||
server.reset();
|
||||
server.addAuditLog({ action: 'action.1', actor: 'user1', result: 'success' });
|
||||
server.addAuditLog({ action: 'action.2', actor: 'user2', result: 'success' });
|
||||
server.addAuditLog({ action: 'action.3', actor: 'user3', result: 'success' });
|
||||
|
||||
const firstPageResponse = await restGet('/api/audit/logs?limit=1&offset=0');
|
||||
const secondPageResponse = await restGet('/api/audit/logs?limit=1&offset=1');
|
||||
|
||||
expect(firstPageResponse.ok).toBe(true);
|
||||
expect(secondPageResponse.ok).toBe(true);
|
||||
|
||||
const firstPage = await firstPageResponse.json();
|
||||
const secondPage = await secondPageResponse.json();
|
||||
|
||||
expect(firstPage.logs.length).toBe(1);
|
||||
expect(secondPage.logs.length).toBe(1);
|
||||
});
|
||||
|
||||
it('audit logs contain required fields', async () => {
|
||||
server.addAuditLog({
|
||||
action: 'hand.trigger',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { handName: 'clip', runId: 'run-123' },
|
||||
});
|
||||
|
||||
const response = await restGet('/api/audit/logs');
|
||||
const result = await response.json();
|
||||
|
||||
const log = result.logs.find(
|
||||
(l: { action: string }) => l.action === 'hand.trigger'
|
||||
);
|
||||
|
||||
expect(log).toBeDefined();
|
||||
expect(log?.id).toBeDefined();
|
||||
expect(log?.timestamp).toBeDefined();
|
||||
expect(log?.action).toBe('hand.trigger');
|
||||
expect(log?.actor).toBe('operator');
|
||||
expect(log?.result).toBe('success');
|
||||
expect(log?.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// === Error Handling Tests ===
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('returns 404 for unknown routes', async () => {
|
||||
const response = await restGet('/api/unknown/endpoint');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// === Agents API Tests ===
|
||||
|
||||
describe('Agents API', () => {
|
||||
it('GET /api/agents returns agents list', async () => {
|
||||
const response = await restGet('/api/agents');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.clones).toBeDefined();
|
||||
expect(Array.isArray(result.clones)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/agents creates a new agent', async () => {
|
||||
const response = await restPost('/api/agents', {
|
||||
name: 'Test Agent',
|
||||
role: 'assistant',
|
||||
model: 'gpt-4',
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.clone).toBeDefined();
|
||||
expect(result.clone.name).toBe('Test Agent');
|
||||
});
|
||||
});
|
||||
|
||||
// === Chat API Tests ===
|
||||
|
||||
describe('Chat API', () => {
|
||||
it('POST /api/chat initiates a chat session', async () => {
|
||||
const response = await restPost('/api/chat', {
|
||||
message: 'Hello, world!',
|
||||
agent_id: 'agent-001',
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.runId).toBeDefined();
|
||||
expect(result.sessionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// === Models API Tests ===
|
||||
|
||||
describe('Models API', () => {
|
||||
it('GET /api/models returns available models', async () => {
|
||||
const response = await restGet('/api/models');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.models).toBeDefined();
|
||||
expect(Array.isArray(result.models)).toBe(true);
|
||||
expect(result.models.length).toBeGreaterThan(0);
|
||||
|
||||
const model = result.models[0];
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.provider).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// === Config API Tests ===
|
||||
|
||||
describe('Config API', () => {
|
||||
it('GET /api/config returns configuration', async () => {
|
||||
const response = await restGet('/api/config');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.server).toBeDefined();
|
||||
expect(result.agent).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/config/quick returns quick config', async () => {
|
||||
const response = await restGet('/api/config/quick');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.quickConfig).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// === Triggers API Tests ===
|
||||
|
||||
describe('Triggers API', () => {
|
||||
it('GET /api/triggers returns triggers list', async () => {
|
||||
const response = await restGet('/api/triggers');
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.triggers).toBeDefined();
|
||||
expect(Array.isArray(result.triggers)).toBe(true);
|
||||
|
||||
const trigger = result.triggers[0];
|
||||
expect(trigger.id).toBeDefined();
|
||||
expect(trigger.type).toBeDefined();
|
||||
expect(typeof trigger.enabled).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
932
tests/fixtures/openfang-mock-server.ts
vendored
Normal file
932
tests/fixtures/openfang-mock-server.ts
vendored
Normal file
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* OpenFang Mock Server for Testing
|
||||
*
|
||||
* Simulates OpenFang Kernel API endpoints and WebSocket events.
|
||||
* Provides a complete test double for the ZCLAW desktop client.
|
||||
*
|
||||
* Usage:
|
||||
* const server = createOpenFangMockServer({ port: 4200 });
|
||||
* await server.start();
|
||||
* // ... run tests ...
|
||||
* await server.stop();
|
||||
*/
|
||||
|
||||
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { WebSocketServer, WebSocket, RawData } from 'ws';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface MockServerConfig {
|
||||
port?: number;
|
||||
host?: string;
|
||||
version?: string;
|
||||
connectionDelay?: number;
|
||||
challengeDelay?: number;
|
||||
}
|
||||
|
||||
export interface MockServerInstance {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
getPort: () => number;
|
||||
getWsUrl: () => string;
|
||||
getHttpUrl: () => string;
|
||||
reset: () => void;
|
||||
setHands: (hands: MockHand[]) => void;
|
||||
setWorkflows: (workflows: MockWorkflow[]) => void;
|
||||
setTriggers: (triggers: MockTrigger[]) => void;
|
||||
setAgents: (agents: MockAgent[]) => void;
|
||||
setSecurityLayers: (layers: MockSecurityLayer[]) => void;
|
||||
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => void;
|
||||
simulateStreamEvent: (event: string, payload: unknown) => void;
|
||||
getConnectedClients: () => number;
|
||||
}
|
||||
|
||||
export interface MockHand {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'idle' | 'running' | 'needs_approval' | 'completed' | 'error';
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MockWorkflow {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MockTrigger {
|
||||
id: string;
|
||||
type: 'webhook' | 'schedule' | 'event';
|
||||
enabled: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MockAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
nickname?: string;
|
||||
scenarios?: string[];
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
workspaceResolvedPath?: string;
|
||||
restrictFiles?: boolean;
|
||||
privacyOptIn?: boolean;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
bootstrapReady?: boolean;
|
||||
}
|
||||
|
||||
export interface MockSecurityLayer {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MockAuditLogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
actor?: string;
|
||||
result?: 'success' | 'failure';
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// === Sample Data ===
|
||||
|
||||
const DEFAULT_HANDS: MockHand[] = [
|
||||
{ name: 'clip', description: 'Video processing and vertical screen generation', status: 'idle' },
|
||||
{ name: 'lead', description: 'Sales lead discovery and qualification', status: 'idle' },
|
||||
{ name: 'collector', description: 'Data collection and aggregation', status: 'idle' },
|
||||
{ name: 'predictor', description: 'Predictive analytics', status: 'idle' },
|
||||
{ name: 'researcher', description: 'Deep research and analysis', status: 'idle' },
|
||||
{ name: 'twitter', description: 'Twitter automation', status: 'idle' },
|
||||
{ name: 'browser', description: 'Browser automation', status: 'idle' },
|
||||
];
|
||||
|
||||
const DEFAULT_WORKFLOWS: MockWorkflow[] = [
|
||||
{ id: 'wf-001', name: 'Daily Report', steps: 3, description: 'Generate daily summary report' },
|
||||
{ id: 'wf-002', name: 'Data Pipeline', steps: 5, description: 'ETL data processing pipeline' },
|
||||
{ id: 'wf-003', name: 'Research Task', steps: 4, description: 'Multi-step research workflow' },
|
||||
];
|
||||
|
||||
const DEFAULT_TRIGGERS: MockTrigger[] = [
|
||||
{ id: 'tr-001', type: 'schedule', enabled: true, config: { cron: '0 9 * * *' } },
|
||||
{ id: 'tr-002', type: 'webhook', enabled: true, config: { path: '/hooks/data' } },
|
||||
{ id: 'tr-003', type: 'event', enabled: false, config: { eventType: 'data.received' } },
|
||||
];
|
||||
|
||||
const DEFAULT_AGENTS: MockAgent[] = [
|
||||
{
|
||||
id: 'agent-001',
|
||||
name: 'Default Agent',
|
||||
role: 'assistant',
|
||||
nickname: 'ZCLAW',
|
||||
scenarios: ['general', 'coding'],
|
||||
model: 'gpt-4',
|
||||
workspaceDir: '~/.openfang/workspaces/default',
|
||||
workspaceResolvedPath: '/home/user/.openfang/workspaces/default',
|
||||
restrictFiles: false,
|
||||
privacyOptIn: false,
|
||||
userName: 'User',
|
||||
userRole: 'developer',
|
||||
createdAt: new Date().toISOString(),
|
||||
bootstrapReady: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_SECURITY_LAYERS: MockSecurityLayer[] = [
|
||||
{ name: 'input_validation', enabled: true, description: 'Input sanitization and validation' },
|
||||
{ name: 'rate_limiting', enabled: true, description: 'Request rate limiting' },
|
||||
{ name: 'authentication', enabled: true, description: 'Ed25519 + JWT authentication' },
|
||||
{ name: 'authorization', enabled: true, description: 'RBAC capability gates' },
|
||||
{ name: 'encryption_at_rest', enabled: true, description: 'Data encryption at rest' },
|
||||
{ name: 'encryption_in_transit', enabled: true, description: 'TLS encryption' },
|
||||
{ name: 'audit_logging', enabled: true, description: 'Merkle hash chain audit' },
|
||||
{ name: 'session_management', enabled: true, description: 'Secure session handling' },
|
||||
{ name: 'device_attestation', enabled: true, description: 'Device identity verification' },
|
||||
{ name: 'content_security', enabled: true, description: 'Content Security Policy' },
|
||||
{ name: 'csp_headers', enabled: true, description: 'HTTP security headers' },
|
||||
{ name: 'cors_policy', enabled: true, description: 'Cross-origin resource policy' },
|
||||
{ name: 'sandbox_isolation', enabled: true, description: 'Process sandboxing' },
|
||||
{ name: 'secret_management', enabled: true, description: 'Secure secret storage' },
|
||||
{ name: 'intrusion_detection', enabled: false, description: 'IDS monitoring' },
|
||||
{ name: 'backup_recovery', enabled: true, description: 'Backup and disaster recovery' },
|
||||
];
|
||||
|
||||
// === Server Implementation ===
|
||||
|
||||
export function createOpenFangMockServer(config: MockServerConfig = {}): MockServerInstance {
|
||||
const {
|
||||
port = 4200,
|
||||
host = '127.0.0.1',
|
||||
version = '2026.3.13',
|
||||
connectionDelay = 0,
|
||||
challengeDelay = 0,
|
||||
} = config;
|
||||
|
||||
let httpServer: Server | null = null;
|
||||
let wsServer: WebSocketServer | null = null;
|
||||
let actualPort = port;
|
||||
|
||||
// Mutable state
|
||||
let hands = [...DEFAULT_HANDS];
|
||||
let workflows = [...DEFAULT_WORKFLOWS];
|
||||
let triggers = [...DEFAULT_TRIGGERS];
|
||||
let agents = [...DEFAULT_AGENTS];
|
||||
let securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
||||
let auditLogs: MockAuditLogEntry[] = [];
|
||||
let handRuns = new Map<string, { status: string; result?: unknown; startedAt: string }>();
|
||||
let workflowRuns = new Map<string, { status: string; step?: string; result?: unknown }>();
|
||||
const connectedClients = new Set<WebSocket>();
|
||||
|
||||
// === REST API Handlers ===
|
||||
|
||||
function handleHealth(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { version, status: 'ok' });
|
||||
}
|
||||
|
||||
function handleGetAgents(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { clones: agents });
|
||||
}
|
||||
|
||||
function handleCreateAgent(req: IncomingMessage, res: ServerResponse): void {
|
||||
parseBody(req, (body) => {
|
||||
const newAgent: MockAgent = {
|
||||
id: `agent-${Date.now()}`,
|
||||
name: body.name || 'New Agent',
|
||||
role: body.role,
|
||||
nickname: body.nickname,
|
||||
scenarios: body.scenarios,
|
||||
model: body.model,
|
||||
workspaceDir: body.workspaceDir,
|
||||
restrictFiles: body.restrictFiles,
|
||||
privacyOptIn: body.privacyOptIn,
|
||||
userName: body.userName,
|
||||
userRole: body.userRole,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
agents.push(newAgent);
|
||||
addAuditLogEntry({
|
||||
action: 'agent.created',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId: newAgent.id, name: newAgent.name },
|
||||
});
|
||||
sendJson(res, { clone: newAgent }, 201);
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateAgent(req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
||||
parseBody(req, (body) => {
|
||||
const index = agents.findIndex((a) => a.id === agentId);
|
||||
if (index === -1) {
|
||||
sendError(res, 'Agent not found', 404);
|
||||
return;
|
||||
}
|
||||
agents[index] = { ...agents[index], ...body, updatedAt: new Date().toISOString() };
|
||||
addAuditLogEntry({
|
||||
action: 'agent.updated',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId, updates: body },
|
||||
});
|
||||
sendJson(res, { clone: agents[index] });
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteAgent(_req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
||||
const index = agents.findIndex((a) => a.id === agentId);
|
||||
if (index === -1) {
|
||||
sendError(res, 'Agent not found', 404);
|
||||
return;
|
||||
}
|
||||
agents.splice(index, 1);
|
||||
addAuditLogEntry({
|
||||
action: 'agent.deleted',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { agentId },
|
||||
});
|
||||
sendJson(res, { ok: true });
|
||||
}
|
||||
|
||||
function handleGetHands(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { hands });
|
||||
}
|
||||
|
||||
function handleGetHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const hand = hands.find((h) => h.name === name);
|
||||
if (!hand) {
|
||||
sendError(res, 'Hand not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, { name: hand.name, description: hand.description, config: hand.config || {} });
|
||||
}
|
||||
|
||||
function handleTriggerHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const hand = hands.find((h) => h.name === name);
|
||||
if (!hand) {
|
||||
sendError(res, 'Hand not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = `run-${name}-${Date.now()}`;
|
||||
const needsApproval = name === 'lead' || name === 'twitter'; // Some hands need approval
|
||||
|
||||
const status = needsApproval ? 'needs_approval' : 'running';
|
||||
handRuns.set(runId, { status, startedAt: new Date().toISOString() });
|
||||
|
||||
// Update hand status
|
||||
const handIndex = hands.findIndex((h) => h.name === name);
|
||||
hands[handIndex] = { ...hand, status: needsApproval ? 'needs_approval' : 'running' };
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'hand.triggered',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { handName: name, runId, needsApproval },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, status });
|
||||
|
||||
// Simulate async events for running hands
|
||||
if (!needsApproval) {
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('hand', {
|
||||
handName: name,
|
||||
runId,
|
||||
status: 'running',
|
||||
phase: 'start',
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleApproveHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
||||
parseBody(_req, (body) => {
|
||||
const run = handRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = body.approved === true;
|
||||
run.status = approved ? 'running' : 'cancelled';
|
||||
handRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: approved ? 'hand.approved' : 'hand.rejected',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { handName: name, runId, reason: body.reason },
|
||||
});
|
||||
|
||||
sendJson(res, { status: run.status });
|
||||
|
||||
if (approved) {
|
||||
simulateStreamEvent('hand', {
|
||||
handName: name,
|
||||
runId,
|
||||
status: 'running',
|
||||
phase: 'start',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancelHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
||||
const run = handRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
run.status = 'cancelled';
|
||||
handRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'hand.cancelled',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { handName: name, runId },
|
||||
});
|
||||
|
||||
sendJson(res, { status: 'cancelled' });
|
||||
}
|
||||
|
||||
function handleGetHandRuns(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
||||
const runs = Array.from(handRuns.entries())
|
||||
.filter(([runId]) => runId.startsWith(`run-${name}-`))
|
||||
.map(([runId, data]) => ({
|
||||
runId,
|
||||
status: data.status,
|
||||
startedAt: data.startedAt,
|
||||
}));
|
||||
sendJson(res, { runs });
|
||||
}
|
||||
|
||||
function handleGetWorkflows(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { workflows });
|
||||
}
|
||||
|
||||
function handleGetWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
||||
const workflow = workflows.find((w) => w.id === id);
|
||||
if (!workflow) {
|
||||
sendError(res, 'Workflow not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
steps: Array.from({ length: workflow.steps }, (_, i) => ({
|
||||
id: `step-${i + 1}`,
|
||||
name: `Step ${i + 1}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function handleExecuteWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
||||
const workflow = workflows.find((w) => w.id === id);
|
||||
if (!workflow) {
|
||||
sendError(res, 'Workflow not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
parseBody(_req, (body) => {
|
||||
const runId = `wf-run-${id}-${Date.now()}`;
|
||||
workflowRuns.set(runId, { status: 'running', step: 'step-1' });
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'workflow.started',
|
||||
actor: 'system',
|
||||
result: 'success',
|
||||
details: { workflowId: id, runId, input: body },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, status: 'running' });
|
||||
|
||||
// Simulate workflow progress
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('workflow', {
|
||||
workflowId: id,
|
||||
runId,
|
||||
status: 'running',
|
||||
step: 'step-1',
|
||||
workflowStatus: 'executing',
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetWorkflowRun(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
||||
const run = workflowRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
sendJson(res, { status: run.status, step: run.step, result: run.result });
|
||||
}
|
||||
|
||||
function handleCancelWorkflow(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
||||
const run = workflowRuns.get(runId);
|
||||
if (!run) {
|
||||
sendError(res, 'Run not found', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
run.status = 'cancelled';
|
||||
workflowRuns.set(runId, run);
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'workflow.cancelled',
|
||||
actor: 'operator',
|
||||
result: 'success',
|
||||
details: { workflowId, runId },
|
||||
});
|
||||
|
||||
sendJson(res, { status: 'cancelled' });
|
||||
}
|
||||
|
||||
function handleGetTriggers(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, { triggers });
|
||||
}
|
||||
|
||||
function handleGetAuditLogs(req: IncomingMessage, res: ServerResponse): void {
|
||||
const url = new URL(req.url || '/', `http://${host}`);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
const paginatedLogs = auditLogs.slice(offset, offset + limit);
|
||||
sendJson(res, { logs: paginatedLogs, total: auditLogs.length });
|
||||
}
|
||||
|
||||
function handleGetSecurityStatus(_req: IncomingMessage, res: ServerResponse): void {
|
||||
const enabledCount = securityLayers.filter((l) => l.enabled).length;
|
||||
sendJson(res, {
|
||||
layers: securityLayers,
|
||||
enabledCount,
|
||||
totalCount: securityLayers.length,
|
||||
securityLevel: calculateSecurityLevel(enabledCount, securityLayers.length),
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetCapabilities(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
capabilities: [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleChat(req: IncomingMessage, res: ServerResponse): void {
|
||||
parseBody(req, (body) => {
|
||||
const runId = `chat-${Date.now()}`;
|
||||
const sessionId = body.session_id || `session-${Date.now()}`;
|
||||
|
||||
addAuditLogEntry({
|
||||
action: 'chat.message',
|
||||
actor: body.agent_id || 'default',
|
||||
result: 'success',
|
||||
details: { runId, sessionId, messageLength: body.message?.length || 0 },
|
||||
});
|
||||
|
||||
sendJson(res, { runId, sessionId });
|
||||
|
||||
// Simulate streaming events
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'assistant',
|
||||
delta: 'Hello',
|
||||
runId,
|
||||
});
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'assistant',
|
||||
delta: '! How can I help you today?',
|
||||
runId,
|
||||
});
|
||||
setTimeout(() => {
|
||||
simulateStreamEvent('agent', {
|
||||
stream: 'lifecycle',
|
||||
phase: 'end',
|
||||
runId,
|
||||
});
|
||||
}, 100);
|
||||
}, 100);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetModels(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
models: [
|
||||
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
|
||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
|
||||
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
||||
{ id: 'deepseek-chat', name: 'DeepSeek Chat', provider: 'deepseek' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetConfig(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
server: { host: '127.0.0.1', port: actualPort },
|
||||
agent: { default_model: 'gpt-4' },
|
||||
llm: {
|
||||
providers: [
|
||||
{ name: 'openai', api_key: '${OPENAI_API_KEY}' },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetQuickConfig(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
quickConfig: {
|
||||
agentName: 'ZCLAW',
|
||||
agentRole: 'assistant',
|
||||
userName: 'User',
|
||||
userRole: 'developer',
|
||||
theme: 'dark',
|
||||
showToolCalls: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetWorkspace(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
path: '~/.openfang/workspaces/default',
|
||||
resolvedPath: '/home/user/.openfang/workspaces/default',
|
||||
exists: true,
|
||||
fileCount: 42,
|
||||
totalSize: 1024 * 1024 * 5, // 5MB
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetSkills(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
skills: [
|
||||
{ id: 'skill-001', name: 'Code Review', path: '/skills/code-review', source: 'builtin' },
|
||||
{ id: 'skill-002', name: 'Translation', path: '/skills/translation', source: 'builtin' },
|
||||
{ id: 'skill-003', name: 'Research', path: '/skills/research', source: 'extra' },
|
||||
],
|
||||
extraDirs: ['/extra/skills'],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetChannels(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
channels: [
|
||||
{ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active', accounts: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetStatsUsage(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
totalSessions: 10,
|
||||
totalMessages: 150,
|
||||
totalTokens: 50000,
|
||||
byModel: {
|
||||
'gpt-4': { messages: 100, inputTokens: 30000, outputTokens: 15000 },
|
||||
'claude-3-sonnet': { messages: 50, inputTokens: 4000, outputTokens: 1000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGetPluginsStatus(_req: IncomingMessage, res: ServerResponse): void {
|
||||
sendJson(res, {
|
||||
plugins: [
|
||||
{ id: 'feishu', name: 'Feishu Integration', status: 'active' },
|
||||
{ id: 'chinese-models', name: 'Chinese Models', status: 'active' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// === HTTP Request Handler ===
|
||||
|
||||
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
// Add CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', `http://${host}`);
|
||||
const path = url.pathname;
|
||||
|
||||
// Route matching
|
||||
const routes: Array<{
|
||||
method: string | string[];
|
||||
pattern: RegExp;
|
||||
handler: (req: IncomingMessage, res: ServerResponse, ...matches: string[]) => void;
|
||||
}> = [
|
||||
{ method: 'GET', pattern: /^\/api\/health$/, handler: handleHealth },
|
||||
{ method: 'GET', pattern: /^\/api\/agents$/, handler: handleGetAgents },
|
||||
{ method: 'POST', pattern: /^\/api\/agents$/, handler: handleCreateAgent },
|
||||
{ method: 'PUT', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleUpdateAgent },
|
||||
{ method: 'DELETE', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleDeleteAgent },
|
||||
{ method: 'GET', pattern: /^\/api\/hands$/, handler: handleGetHands },
|
||||
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)$/, handler: handleGetHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/trigger$/, handler: handleTriggerHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/approve$/, handler: handleApproveHand },
|
||||
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelHand },
|
||||
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)\/runs$/, handler: handleGetHandRuns },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows$/, handler: handleGetWorkflows },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)$/, handler: handleGetWorkflow },
|
||||
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/execute$/, handler: handleExecuteWorkflow },
|
||||
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)$/, handler: handleGetWorkflowRun },
|
||||
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelWorkflow },
|
||||
{ method: 'GET', pattern: /^\/api\/triggers$/, handler: handleGetTriggers },
|
||||
{ method: 'GET', pattern: /^\/api\/audit\/logs$/, handler: handleGetAuditLogs },
|
||||
{ method: 'GET', pattern: /^\/api\/security\/status$/, handler: handleGetSecurityStatus },
|
||||
{ method: 'GET', pattern: /^\/api\/capabilities$/, handler: handleGetCapabilities },
|
||||
{ method: 'POST', pattern: /^\/api\/chat$/, handler: handleChat },
|
||||
{ method: 'GET', pattern: /^\/api\/models$/, handler: handleGetModels },
|
||||
{ method: 'GET', pattern: /^\/api\/config$/, handler: handleGetConfig },
|
||||
{ method: 'GET', pattern: /^\/api\/config\/quick$/, handler: handleGetQuickConfig },
|
||||
{ method: 'GET', pattern: /^\/api\/workspace$/, handler: handleGetWorkspace },
|
||||
{ method: 'GET', pattern: /^\/api\/skills$/, handler: handleGetSkills },
|
||||
{ method: 'GET', pattern: /^\/api\/channels$/, handler: handleGetChannels },
|
||||
{ method: 'GET', pattern: /^\/api\/stats\/usage$/, handler: handleGetStatsUsage },
|
||||
{ method: 'GET', pattern: /^\/api\/plugins\/status$/, handler: handleGetPluginsStatus },
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
const methods = Array.isArray(route.method) ? route.method : [route.method];
|
||||
if (!methods.includes(req.method || 'GET')) continue;
|
||||
|
||||
const match = path.match(route.pattern);
|
||||
if (match) {
|
||||
try {
|
||||
route.handler(req, res, ...match.slice(1));
|
||||
} catch (error) {
|
||||
console.error('[MockServer] Handler error:', error);
|
||||
sendError(res, 'Internal server error', 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No route matched
|
||||
sendError(res, 'Not found', 404);
|
||||
}
|
||||
|
||||
// === WebSocket Handler ===
|
||||
|
||||
function handleWebSocket(ws: WebSocket): void {
|
||||
connectedClients.add(ws);
|
||||
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'req') {
|
||||
handleWsRequest(ws, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MockServer] WebSocket message parse error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[MockServer] WebSocket error:', error);
|
||||
connectedClients.delete(ws);
|
||||
});
|
||||
|
||||
// Send challenge after a short delay
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const nonce = `nonce-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
ws.send(JSON.stringify({
|
||||
type: 'event',
|
||||
event: 'connect.challenge',
|
||||
payload: { nonce },
|
||||
}));
|
||||
}
|
||||
}, challengeDelay);
|
||||
}
|
||||
|
||||
function handleWsRequest(ws: WebSocket, message: { id: string; method: string; params?: unknown }): void {
|
||||
if (message.method === 'connect') {
|
||||
// Handle connect handshake
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
sessionId: `session-${Date.now()}`,
|
||||
protocolVersion: 3,
|
||||
serverVersion: version,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, connectionDelay);
|
||||
} else if (message.method === 'health') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: { version, status: 'ok' },
|
||||
}));
|
||||
} else if (message.method === 'status') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connections: connectedClients.size,
|
||||
version,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// Unknown method
|
||||
ws.send(JSON.stringify({
|
||||
type: 'res',
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: { code: 'METHOD_NOT_FOUND', message: `Unknown method: ${message.method}` },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function sendJson(res: ServerResponse, data: unknown, statusCode = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, message: string, statusCode = 500): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message, code: statusCode } }));
|
||||
}
|
||||
|
||||
function parseBody<T>(req: IncomingMessage, callback: (body: T) => void): void {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const parsed = body ? JSON.parse(body) : {};
|
||||
callback(parsed);
|
||||
} catch {
|
||||
// Send error response if we have access to res
|
||||
// This is a simplified version; in real use, pass res to this function
|
||||
console.error('[MockServer] Failed to parse request body');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addAuditLogEntry(entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>): void {
|
||||
const logEntry: MockAuditLogEntry = {
|
||||
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
auditLogs.unshift(logEntry); // Add to beginning for reverse chronological order
|
||||
// Keep only last 1000 logs
|
||||
if (auditLogs.length > 1000) {
|
||||
auditLogs = auditLogs.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
|
||||
if (totalCount === 0) return 'low';
|
||||
const ratio = enabledCount / totalCount;
|
||||
if (ratio >= 0.875) return 'critical'; // 14-16 layers
|
||||
if (ratio >= 0.625) return 'high'; // 10-13 layers
|
||||
if (ratio >= 0.375) return 'medium'; // 6-9 layers
|
||||
return 'low'; // 0-5 layers
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
return {
|
||||
start: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpServer = createServer(handleRequest);
|
||||
|
||||
httpServer.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(port, host, () => {
|
||||
const address = httpServer?.address();
|
||||
actualPort = typeof address === 'object' && address ? address.port : port;
|
||||
|
||||
// Set up WebSocket server
|
||||
wsServer = new WebSocketServer({ server: httpServer, path: '/ws' });
|
||||
wsServer.on('connection', handleWebSocket);
|
||||
|
||||
console.log(`[MockServer] Started on http://${host}:${actualPort}`);
|
||||
console.log(`[MockServer] WebSocket available at ws://${host}:${actualPort}/ws`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
return new Promise((resolve) => {
|
||||
// Close all WebSocket connections
|
||||
if (wsServer) {
|
||||
wsServer.clients.forEach((ws) => {
|
||||
ws.close(1000, 'Server shutting down');
|
||||
});
|
||||
wsServer.close(() => {
|
||||
wsServer = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (httpServer) {
|
||||
httpServer.close(() => {
|
||||
console.log('[MockServer] Stopped');
|
||||
httpServer = null;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getPort: () => actualPort,
|
||||
getWsUrl: () => `ws://${host}:${actualPort}/ws`,
|
||||
getHttpUrl: () => `http://${host}:${actualPort}`,
|
||||
|
||||
reset: () => {
|
||||
hands = [...DEFAULT_HANDS];
|
||||
workflows = [...DEFAULT_WORKFLOWS];
|
||||
triggers = [...DEFAULT_TRIGGERS];
|
||||
agents = [...DEFAULT_AGENTS];
|
||||
securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
||||
auditLogs = [];
|
||||
handRuns.clear();
|
||||
workflowRuns.clear();
|
||||
},
|
||||
|
||||
setHands: (newHands: MockHand[]) => {
|
||||
hands = newHands;
|
||||
},
|
||||
|
||||
setWorkflows: (newWorkflows: MockWorkflow[]) => {
|
||||
workflows = newWorkflows;
|
||||
},
|
||||
|
||||
setTriggers: (newTriggers: MockTrigger[]) => {
|
||||
triggers = newTriggers;
|
||||
},
|
||||
|
||||
setAgents: (newAgents: MockAgent[]) => {
|
||||
agents = newAgents;
|
||||
},
|
||||
|
||||
setSecurityLayers: (newLayers: MockSecurityLayer[]) => {
|
||||
securityLayers = newLayers;
|
||||
},
|
||||
|
||||
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => {
|
||||
addAuditLogEntry(entry);
|
||||
},
|
||||
|
||||
simulateStreamEvent: (event: string, payload: unknown) => {
|
||||
const message = JSON.stringify({ type: 'event', event, payload });
|
||||
wsServer?.clients.forEach((ws) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getConnectedClients: () => connectedClients.size,
|
||||
};
|
||||
}
|
||||
|
||||
// === Export for CommonJS compatibility ===
|
||||
|
||||
export default createOpenFangMockServer;
|
||||
23
tests/tsconfig.json
Normal file
23
tests/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals", "node"],
|
||||
"baseUrl": "..",
|
||||
"paths": {
|
||||
"@": ["desktop/src"],
|
||||
"@lib": ["desktop/src/lib"],
|
||||
"react": ["node_modules/.pnpm/react@19.2.4/node_modules/react"],
|
||||
"react-dom": ["node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom"],
|
||||
"react/jsx-runtime": ["node_modules/.pnpm/react@19.2.4/node_modules/react/jsx-runtime"],
|
||||
"react/jsx-dev-runtime": ["node_modules/.pnpm/react@19.2.4/node_modules/react/jsx-dev-runtime"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../desktop/src/**/*.ts", "../desktop/src/**/*.tsx"]
|
||||
}
|
||||
Reference in New Issue
Block a user