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>
522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|
|
});
|