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:
iven
2026-03-14 23:16:32 +08:00
parent 67e1da635d
commit 07079293f4
126 changed files with 36229 additions and 1035 deletions

View 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();
});
});

View 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);
});
});

View 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();
});
});

View 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');
});
});
});