feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { createTestAgent, startTestServer, httpRequest, type TestServer } from './test-fixtures.js';
|
||||
import { DextoAgent } from '@dexto/core';
|
||||
import { AgentFactory } from '@dexto/agent-management';
|
||||
import type { CreateDextoAppOptions } from '../index.js';
|
||||
|
||||
describe('Hono API Integration Tests - Agent Routes', () => {
|
||||
let testServer: TestServer | undefined;
|
||||
let initialAgent: DextoAgent;
|
||||
let mockAgents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
type: 'builtin' | 'custom';
|
||||
}> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
initialAgent = await createTestAgent();
|
||||
|
||||
// Mock AgentFactory.listAgents to return test agents
|
||||
mockAgents = [
|
||||
{
|
||||
id: 'test-agent-1',
|
||||
name: 'Test Agent 1',
|
||||
description: 'First test agent',
|
||||
author: 'Test Author',
|
||||
tags: ['test'],
|
||||
type: 'builtin' as const,
|
||||
},
|
||||
{
|
||||
id: 'test-agent-2',
|
||||
name: 'Test Agent 2',
|
||||
description: 'Second test agent',
|
||||
author: 'Test Author',
|
||||
tags: ['test'],
|
||||
type: 'builtin' as const,
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(AgentFactory, 'listAgents').mockResolvedValue({
|
||||
installed: mockAgents,
|
||||
available: [],
|
||||
});
|
||||
|
||||
// Create agentsContext with switching functions
|
||||
let activeAgent = initialAgent;
|
||||
let activeAgentId = 'test-agent-1';
|
||||
let isSwitching = false;
|
||||
|
||||
const agentsContext: CreateDextoAppOptions['agentsContext'] = {
|
||||
switchAgentById: async (id: string) => {
|
||||
if (isSwitching) throw new Error('Agent switch in progress');
|
||||
isSwitching = true;
|
||||
try {
|
||||
// Create a new test agent instance (no need to use AgentFactory.createAgent in tests)
|
||||
const newAgent = await createTestAgent();
|
||||
await newAgent.start();
|
||||
if (activeAgent.isStarted()) {
|
||||
await activeAgent.stop();
|
||||
}
|
||||
activeAgent = newAgent;
|
||||
activeAgentId = id;
|
||||
return { id, name: mockAgents.find((a) => a.id === id)?.name ?? id };
|
||||
} finally {
|
||||
isSwitching = false;
|
||||
}
|
||||
},
|
||||
switchAgentByPath: async (filePath: string) => {
|
||||
if (isSwitching) throw new Error('Agent switch in progress');
|
||||
isSwitching = true;
|
||||
try {
|
||||
const newAgent = await createTestAgent();
|
||||
await newAgent.start();
|
||||
if (activeAgent.isStarted()) {
|
||||
await activeAgent.stop();
|
||||
}
|
||||
activeAgent = newAgent;
|
||||
activeAgentId = `agent-from-${filePath}`;
|
||||
return { id: activeAgentId, name: 'Agent from Path' };
|
||||
} finally {
|
||||
isSwitching = false;
|
||||
}
|
||||
},
|
||||
resolveAgentInfo: async (id: string) => {
|
||||
const agent = mockAgents.find((a) => a.id === id);
|
||||
return {
|
||||
id,
|
||||
name: agent?.name ?? id,
|
||||
};
|
||||
},
|
||||
ensureAgentAvailable: () => {
|
||||
if (isSwitching) throw new Error('Agent switch in progress');
|
||||
if (!activeAgent.isStarted()) throw new Error('Agent not started');
|
||||
},
|
||||
getActiveAgentId: () => activeAgentId,
|
||||
};
|
||||
|
||||
testServer = await startTestServer(initialAgent, undefined, agentsContext);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (testServer) {
|
||||
await testServer.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Agent Management Routes', () => {
|
||||
it('GET /api/agents returns list of agents', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agents');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray((res.body as { installed: unknown[] }).installed)).toBe(true);
|
||||
expect(
|
||||
(res.body as { installed: Array<{ id: string }> }).installed.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('GET /api/agents/current returns current agent', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agents/current');
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { id: string }).id).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/agents/switch validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/switch', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('POST /api/agents/switch switches agent by ID', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Note: Agent switching requires updating getAgent() closure which is complex
|
||||
// For now, we test the endpoint accepts valid input
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/switch', {
|
||||
id: 'test-agent-2',
|
||||
});
|
||||
// May return 400 if validation fails or 200 if switch succeeds
|
||||
// The actual switch logic is complex and requires getAgent() to be dynamic
|
||||
expect([200, 400]).toContain(res.status);
|
||||
if (res.status === 200) {
|
||||
const body = res.body as { switched: boolean; id: string; name: string };
|
||||
expect(body.switched).toBe(true);
|
||||
expect(body.id).toBe('test-agent-2');
|
||||
expect(typeof body.name).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/agents/validate-name validates agent name', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/validate-name', {
|
||||
id: 'valid-agent-name-that-does-not-exist',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { valid: boolean; message?: string };
|
||||
expect(body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/agents/validate-name rejects invalid names', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agents/validate-name', {
|
||||
id: 'test-agent-1', // This conflicts with our mock
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { valid: boolean; conflict?: string; message?: string };
|
||||
expect(body.valid).toBe(false);
|
||||
expect(body.conflict).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Config Routes', () => {
|
||||
// Note: Agent path/config routes require agent to have configPath set
|
||||
// These are skipped in test environment as we use in-memory agents
|
||||
it.skip('GET /api/agent/path returns agent path', async () => {
|
||||
// Requires agent with configPath - test agents don't have this
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/path');
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
expect(typeof body.path).toBe('string');
|
||||
expect(typeof body.relativePath).toBe('string');
|
||||
expect(typeof body.name).toBe('string');
|
||||
expect(typeof body.isDefault).toBe('boolean');
|
||||
});
|
||||
|
||||
it.skip('GET /api/agent/config returns agent config', async () => {
|
||||
// Requires agent with configPath - test agents don't have this
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/config');
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { config: unknown; path: string; lastModified?: unknown };
|
||||
expect(body.config).toBeDefined();
|
||||
expect(typeof body.path).toBe('string');
|
||||
});
|
||||
|
||||
it('GET /api/agent/config/export exports config', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/agent/config/export');
|
||||
expect(res.status).toBe(200);
|
||||
// Export returns YAML text, not JSON
|
||||
expect(res.headers['content-type']).toContain('yaml');
|
||||
expect(typeof res.text).toBe('string');
|
||||
expect(res.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('POST /api/agent/validate validates config', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agent/validate', {
|
||||
yaml: 'systemPrompt: "You are a helpful assistant."\ngreeting: Hello\nllm:\n provider: openai\n model: gpt-5\n apiKey: sk-test-key-for-validation',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { valid: boolean; errors?: unknown[]; warnings?: unknown[] };
|
||||
expect(body.valid).toBe(true);
|
||||
// errors may be undefined or empty array
|
||||
expect(
|
||||
body.errors === undefined ||
|
||||
(Array.isArray(body.errors) && body.errors.length === 0)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/agent/validate rejects invalid config', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/agent/validate', {
|
||||
yaml: 'invalid: yaml: content: [',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = res.body as { valid: boolean; errors: unknown[]; warnings: unknown[] };
|
||||
expect(body.valid).toBe(false);
|
||||
expect(Array.isArray(body.errors)).toBe(true);
|
||||
expect(body.errors.length).toBeGreaterThan(0);
|
||||
const firstError = body.errors[0] as { code: string; message: string };
|
||||
expect(typeof firstError.code).toBe('string');
|
||||
expect(typeof firstError.message).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
706
dexto/packages/server/src/hono/__tests__/api.integration.test.ts
Normal file
706
dexto/packages/server/src/hono/__tests__/api.integration.test.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import type { StreamingEvent } from '@dexto/core';
|
||||
import {
|
||||
createTestAgent,
|
||||
startTestServer,
|
||||
httpRequest,
|
||||
type TestServer,
|
||||
expectResponseStructure,
|
||||
validators,
|
||||
} from './test-fixtures.js';
|
||||
|
||||
describe('Hono API Integration Tests', () => {
|
||||
let testServer: TestServer | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const agent = await createTestAgent();
|
||||
testServer = await startTestServer(agent);
|
||||
}, 30000); // 30 second timeout for server startup
|
||||
|
||||
afterAll(async () => {
|
||||
if (testServer) {
|
||||
await testServer.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Health', () => {
|
||||
it('GET /health returns OK', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('OK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LLM Routes', () => {
|
||||
it('GET /api/llm/current returns current LLM config', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/llm/current');
|
||||
expect(res.status).toBe(200);
|
||||
expectResponseStructure(res.body, {
|
||||
config: validators.object,
|
||||
});
|
||||
const config = (
|
||||
res.body as {
|
||||
config: {
|
||||
provider: string;
|
||||
model: string;
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
).config;
|
||||
expect(config.provider).toBe('openai');
|
||||
expect(config.model).toBe('gpt-5-nano');
|
||||
expect(typeof config.displayName === 'string' || config.displayName === undefined).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /api/llm/current with sessionId returns session-specific config', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create a session first
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-llm',
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/llm/current?sessionId=test-session-llm'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { config: unknown }).config).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/llm/catalog returns LLM catalog', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/llm/catalog');
|
||||
expect(res.status).toBe(200);
|
||||
expectResponseStructure(res.body, {
|
||||
providers: validators.object,
|
||||
});
|
||||
const providers = (res.body as { providers: Record<string, unknown> }).providers;
|
||||
expect(Object.keys(providers).length).toBeGreaterThan(0);
|
||||
// Validate provider structure
|
||||
const firstProvider = Object.values(providers)[0] as {
|
||||
models: unknown;
|
||||
};
|
||||
expect(firstProvider).toBeDefined();
|
||||
expect(typeof firstProvider === 'object').toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/llm/switch validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/llm/switch', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('POST /api/llm/switch with model update succeeds', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/llm/switch', {
|
||||
model: 'gpt-5',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sessions Routes', () => {
|
||||
it('GET /api/sessions returns empty list initially', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/sessions');
|
||||
expect(res.status).toBe(200);
|
||||
expectResponseStructure(res.body, {
|
||||
sessions: validators.array,
|
||||
});
|
||||
const sessions = (res.body as { sessions: unknown[] }).sessions;
|
||||
// May have sessions from previous tests in integration suite
|
||||
expect(sessions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('POST /api/sessions creates a new session', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-1',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expectResponseStructure(res.body, {
|
||||
session: validators.object,
|
||||
});
|
||||
const session = (
|
||||
res.body as {
|
||||
session: {
|
||||
id: string;
|
||||
createdAt: number | null;
|
||||
lastActivity: number | null;
|
||||
messageCount: number;
|
||||
title: string | null;
|
||||
};
|
||||
}
|
||||
).session;
|
||||
expect(session.id).toBe('test-session-1');
|
||||
expect(typeof session.messageCount).toBe('number');
|
||||
expect(session.createdAt === null || typeof session.createdAt === 'number').toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/sessions/:id returns session details', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-details',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/sessions/test-session-details'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { session: { id: string } }).session.id).toBe(
|
||||
'test-session-details'
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /api/sessions/:id returns 404 for non-existent session', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/sessions/non-existent-session'
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /api/sessions/:id/load validates and returns session info', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-load',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/sessions/test-session-load/load'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('session');
|
||||
expect((res.body as { session: { id: string } }).session.id).toBe('test-session-load');
|
||||
});
|
||||
|
||||
it('GET /api/sessions/:id/history returns session history', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-history',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/sessions/test-session-history/history'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray((res.body as { history: unknown[] }).history)).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /api/sessions/:id deletes session', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-delete',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'DELETE',
|
||||
'/api/sessions/test-session-delete'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify deletion
|
||||
const getRes = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/sessions/test-session-delete'
|
||||
);
|
||||
expect(getRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Routes', () => {
|
||||
it('GET /api/search/messages requires query parameter', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/messages');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/search/messages with query returns results', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/messages?q=test');
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { results: unknown[] }).results).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/search/sessions requires query parameter', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/sessions');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/search/sessions with query returns results', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/search/sessions?q=test');
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { results: unknown[] }).results).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Routes', () => {
|
||||
it('GET /api/memory returns empty list initially', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/memory');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray((res.body as { memories: unknown[] }).memories)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/memory creates a memory', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
|
||||
content: 'Test memory content',
|
||||
tags: ['test'],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as { memory: { id: string } }).memory.id).toBeDefined();
|
||||
expect((res.body as { memory: { content: string } }).memory.content).toBe(
|
||||
'Test memory content'
|
||||
);
|
||||
});
|
||||
|
||||
it('POST /api/memory validates required fields', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('GET /api/memory/:id returns memory details', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create memory first
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
|
||||
content: 'Memory to retrieve',
|
||||
tags: ['test'],
|
||||
});
|
||||
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
|
||||
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', `/api/memory/${memoryId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { memory: { id: string } }).memory.id).toBe(memoryId);
|
||||
});
|
||||
|
||||
it('PUT /api/memory/:id updates memory', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create memory first
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
|
||||
content: 'Original content',
|
||||
tags: ['test'],
|
||||
});
|
||||
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
|
||||
|
||||
const res = await httpRequest(testServer.baseUrl, 'PUT', `/api/memory/${memoryId}`, {
|
||||
content: 'Updated content',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { memory: { content: string } }).memory.content).toBe(
|
||||
'Updated content'
|
||||
);
|
||||
});
|
||||
|
||||
it('DELETE /api/memory/:id deletes memory', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create memory first
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/memory', {
|
||||
content: 'Memory to delete',
|
||||
tags: ['test'],
|
||||
});
|
||||
const memoryId = (createRes.body as { memory: { id: string } }).memory.id;
|
||||
|
||||
const res = await httpRequest(testServer.baseUrl, 'DELETE', `/api/memory/${memoryId}`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify deletion
|
||||
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/memory/${memoryId}`);
|
||||
expect(getRes.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Routes', () => {
|
||||
it('GET /api/mcp/servers returns server list', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/mcp/servers');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
|
||||
it('POST /api/mcp/servers validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/mcp/servers', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompts Routes', () => {
|
||||
it('GET /api/prompts returns prompt list', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/prompts');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
|
||||
it('GET /api/prompts/:name returns prompt details', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/prompts/non-existent-prompt'
|
||||
);
|
||||
// May return 404 or empty result depending on implementation
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resources Routes', () => {
|
||||
it('GET /api/resources returns resource list', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/resources');
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhooks Routes', () => {
|
||||
it('GET /api/webhooks returns webhook list', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/webhooks');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray((res.body as { webhooks: unknown[] }).webhooks)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/webhooks validates URL', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/webhooks', {
|
||||
url: 'not-a-url',
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('POST /api/webhooks creates webhook', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/webhooks', {
|
||||
url: 'https://example.com/webhook',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Greeting Route', () => {
|
||||
it('GET /api/greeting returns greeting', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/api/greeting');
|
||||
expect(res.status).toBe(200);
|
||||
// greeting might be undefined if not set in config, which is valid
|
||||
expect(res.body).toBeDefined();
|
||||
expect(
|
||||
typeof (res.body as { greeting?: unknown }).greeting === 'string' ||
|
||||
(res.body as { greeting?: unknown }).greeting === undefined
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/greeting with sessionId returns session-specific greeting', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-greeting',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/greeting?sessionId=test-session-greeting'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// greeting might be undefined if not set in config, which is valid
|
||||
expect(res.body).toBeDefined();
|
||||
expect(
|
||||
typeof (res.body as { greeting?: unknown }).greeting === 'string' ||
|
||||
(res.body as { greeting?: unknown }).greeting === undefined
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2A Routes', () => {
|
||||
it('GET /.well-known/agent-card.json returns agent card', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/.well-known/agent-card.json'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { name: unknown }).name).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Routes', () => {
|
||||
it('POST /api/message validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/message', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('POST /api/message-sync validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/message-sync', {});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('POST /api/reset resets conversation', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-session-reset',
|
||||
});
|
||||
const res = await httpRequest(testServer.baseUrl, 'POST', '/api/reset', {
|
||||
sessionId: 'test-session-reset',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('POST /api/message-stream returns SSE stream directly', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
|
||||
const sessionId = 'stream-session';
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', { sessionId });
|
||||
|
||||
const agent = testServer.agent;
|
||||
const originalStream = agent.stream;
|
||||
const fakeEvents: StreamingEvent[] = [
|
||||
{
|
||||
name: 'llm:thinking',
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
name: 'llm:chunk',
|
||||
content: 'hello',
|
||||
chunkType: 'text',
|
||||
isComplete: false,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
name: 'llm:response',
|
||||
content: 'hello',
|
||||
tokenUsage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
sessionId,
|
||||
provider: 'openai',
|
||||
model: 'test-model',
|
||||
},
|
||||
];
|
||||
|
||||
agent.stream = async function (
|
||||
_message: string,
|
||||
_options
|
||||
): Promise<AsyncIterableIterator<StreamingEvent>> {
|
||||
async function* generator() {
|
||||
for (const event of fakeEvents) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
return generator();
|
||||
} as typeof agent.stream;
|
||||
|
||||
try {
|
||||
// POST to /api/message-stream - response IS the SSE stream
|
||||
const response = await fetch(`${testServer.baseUrl}/api/message-stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
content: 'Say hello',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Response does not contain a readable body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let received = '';
|
||||
let chunks = 0;
|
||||
while (chunks < 50) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
chunks++;
|
||||
received += decoder.decode(value, { stream: true });
|
||||
if (received.includes('event: llm:response')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await reader.cancel();
|
||||
|
||||
expect(received).toContain('event: llm:thinking');
|
||||
expect(received).toContain('event: llm:response');
|
||||
} finally {
|
||||
agent.stream = originalStream;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queue Routes', () => {
|
||||
it('GET /api/queue/:sessionId returns empty queue initially', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-queue-session',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/queue/test-queue-session'
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { messages: unknown[]; count: number }).messages).toEqual([]);
|
||||
expect((res.body as { count: number }).count).toBe(0);
|
||||
});
|
||||
|
||||
it('GET /api/queue/:sessionId returns 404 for non-existent session', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/queue/non-existent-queue-session'
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/queue/:sessionId queues a message', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-queue-post-session',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'POST',
|
||||
'/api/queue/test-queue-post-session',
|
||||
{ content: 'Hello from queue' }
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as { queued: boolean }).queued).toBe(true);
|
||||
expect((res.body as { id: string }).id).toBeDefined();
|
||||
expect((res.body as { position: number }).position).toBe(1);
|
||||
|
||||
// Verify message is in queue
|
||||
const getRes = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'GET',
|
||||
'/api/queue/test-queue-post-session'
|
||||
);
|
||||
expect((getRes.body as { count: number }).count).toBe(1);
|
||||
});
|
||||
|
||||
it('POST /api/queue/:sessionId validates input', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
// Create session first
|
||||
await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId: 'test-queue-validate-session',
|
||||
});
|
||||
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'POST',
|
||||
'/api/queue/test-queue-validate-session',
|
||||
{} // Empty body should fail validation
|
||||
);
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
it('DELETE /api/queue/:sessionId/:messageId removes a queued message', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const sessionId = `queue-delete-msg-${Date.now()}`;
|
||||
|
||||
// Create session and queue a message
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId,
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const queueRes = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'POST',
|
||||
`/api/queue/${sessionId}`,
|
||||
{ content: 'Message to delete' }
|
||||
);
|
||||
expect(queueRes.status).toBe(201);
|
||||
const messageId = (queueRes.body as { id: string }).id;
|
||||
|
||||
// Delete the message
|
||||
const res = await httpRequest(
|
||||
testServer.baseUrl,
|
||||
'DELETE',
|
||||
`/api/queue/${sessionId}/${messageId}`
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { removed: boolean }).removed).toBe(true);
|
||||
|
||||
// Verify queue is empty
|
||||
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/queue/${sessionId}`);
|
||||
expect((getRes.body as { count: number }).count).toBe(0);
|
||||
});
|
||||
|
||||
it('DELETE /api/queue/:sessionId clears all queued messages', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const sessionId = `queue-clear-${Date.now()}`;
|
||||
|
||||
// Create session and queue multiple messages
|
||||
const createRes = await httpRequest(testServer.baseUrl, 'POST', '/api/sessions', {
|
||||
sessionId,
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const q1 = await httpRequest(testServer.baseUrl, 'POST', `/api/queue/${sessionId}`, {
|
||||
content: 'Message 1',
|
||||
});
|
||||
expect(q1.status).toBe(201);
|
||||
const q2 = await httpRequest(testServer.baseUrl, 'POST', `/api/queue/${sessionId}`, {
|
||||
content: 'Message 2',
|
||||
});
|
||||
expect(q2.status).toBe(201);
|
||||
|
||||
// Clear the queue
|
||||
const res = await httpRequest(testServer.baseUrl, 'DELETE', `/api/queue/${sessionId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { cleared: boolean }).cleared).toBe(true);
|
||||
expect((res.body as { count: number }).count).toBe(2);
|
||||
|
||||
// Verify queue is empty
|
||||
const getRes = await httpRequest(testServer.baseUrl, 'GET', `/api/queue/${sessionId}`);
|
||||
expect((getRes.body as { count: number }).count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAPI Schema', () => {
|
||||
it('GET /openapi.json returns OpenAPI schema', async () => {
|
||||
if (!testServer) throw new Error('Test server not initialized');
|
||||
const res = await httpRequest(testServer.baseUrl, 'GET', '/openapi.json');
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { openapi: string }).openapi).toBe('3.0.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
294
dexto/packages/server/src/hono/__tests__/test-fixtures.ts
Normal file
294
dexto/packages/server/src/hono/__tests__/test-fixtures.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { DextoAgent, createAgentCard } from '@dexto/core';
|
||||
import type { AgentConfig, AgentCard } from '@dexto/core';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import type { Context } from 'hono';
|
||||
import { createDextoApp } from '../index.js';
|
||||
import type { DextoApp } from '../types.js';
|
||||
import { createNodeServer, type NodeBridgeResult } from '../node/index.js';
|
||||
import type { CreateDextoAppOptions } from '../index.js';
|
||||
|
||||
/**
|
||||
* Test configuration for integration tests
|
||||
* Uses in-memory storage to avoid side effects
|
||||
*/
|
||||
export function createTestAgentConfig(): AgentConfig {
|
||||
return {
|
||||
systemPrompt: 'You are a test assistant.',
|
||||
llm: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-5-nano',
|
||||
apiKey: 'test-key-123', // Mock key for testing
|
||||
maxIterations: 10,
|
||||
},
|
||||
mcpServers: {},
|
||||
storage: {
|
||||
cache: { type: 'in-memory' },
|
||||
database: { type: 'in-memory' },
|
||||
blob: { type: 'local', storePath: '/tmp/test-blobs' },
|
||||
},
|
||||
sessions: {
|
||||
maxSessions: 50, // Increased to accommodate all integration tests
|
||||
sessionTTL: 3600,
|
||||
},
|
||||
toolConfirmation: {
|
||||
mode: 'auto-approve',
|
||||
timeout: 120000,
|
||||
},
|
||||
elicitation: {
|
||||
enabled: false,
|
||||
timeout: 120000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a real DextoAgent instance with in-memory storage
|
||||
* No mocks - uses real implementations
|
||||
*/
|
||||
export async function createTestAgent(config?: AgentConfig): Promise<DextoAgent> {
|
||||
const agentConfig = config ?? createTestAgentConfig();
|
||||
const agent = new DextoAgent(agentConfig);
|
||||
await agent.start();
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test server setup result
|
||||
*/
|
||||
export interface TestServer {
|
||||
server: HttpServer;
|
||||
app: DextoApp;
|
||||
bridge: NodeBridgeResult;
|
||||
agent: DextoAgent;
|
||||
agentCard: AgentCard;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a real HTTP server for testing
|
||||
* Uses createDextoApp and createNodeServer directly
|
||||
* @param agent - The agent instance to use
|
||||
* @param port - Optional port (auto-selected if not provided)
|
||||
* @param agentsContext - Optional agent switching context (enables /api/agents routes)
|
||||
*/
|
||||
export async function startTestServer(
|
||||
agent: DextoAgent,
|
||||
port?: number,
|
||||
agentsContext?: CreateDextoAppOptions['agentsContext']
|
||||
): Promise<TestServer> {
|
||||
// Use provided port or find an available port
|
||||
const serverPort = port ?? (await findAvailablePort());
|
||||
|
||||
// Create agent card
|
||||
const agentCard = createAgentCard({
|
||||
defaultName: 'test-agent',
|
||||
defaultVersion: '1.0.0',
|
||||
defaultBaseUrl: `http://localhost:${serverPort}`,
|
||||
});
|
||||
|
||||
// Create getter functions
|
||||
// Note: For agent switching tests, getAgent needs to reference activeAgent from agentsContext
|
||||
// This is handled by the agentsContext implementation itself
|
||||
const getAgent = (_ctx: Context) => agent;
|
||||
const getAgentCard = () => agentCard;
|
||||
|
||||
// Create event subscribers and approval coordinator for test
|
||||
const { WebhookEventSubscriber } = await import('../../events/webhook-subscriber.js');
|
||||
const { A2ASseEventSubscriber } = await import('../../events/a2a-sse-subscriber.js');
|
||||
const { ApprovalCoordinator } = await import('../../approval/approval-coordinator.js');
|
||||
|
||||
const webhookSubscriber = new WebhookEventSubscriber();
|
||||
const sseSubscriber = new A2ASseEventSubscriber();
|
||||
const approvalCoordinator = new ApprovalCoordinator();
|
||||
|
||||
// Subscribe to agent's event bus
|
||||
webhookSubscriber.subscribe(agent.agentEventBus);
|
||||
sseSubscriber.subscribe(agent.agentEventBus);
|
||||
|
||||
// Create Hono app
|
||||
const app = createDextoApp({
|
||||
getAgent,
|
||||
getAgentCard,
|
||||
approvalCoordinator,
|
||||
webhookSubscriber,
|
||||
sseSubscriber,
|
||||
...(agentsContext ? { agentsContext } : {}), // Include agentsContext only if provided
|
||||
});
|
||||
|
||||
// Create Node server bridge
|
||||
const bridge = createNodeServer(app, {
|
||||
getAgent: () => agent,
|
||||
port: serverPort,
|
||||
});
|
||||
|
||||
// Agent card (no updates needed after bridge creation in SSE migration)
|
||||
const updatedAgentCard = createAgentCard({
|
||||
defaultName: 'test-agent',
|
||||
defaultVersion: '1.0.0',
|
||||
defaultBaseUrl: `http://localhost:${serverPort}`,
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
bridge.server.listen(serverPort, '0.0.0.0', () => {
|
||||
resolve();
|
||||
});
|
||||
bridge.server.on('error', reject);
|
||||
});
|
||||
|
||||
const baseUrl = `http://localhost:${serverPort}`;
|
||||
|
||||
return {
|
||||
server: bridge.server,
|
||||
app,
|
||||
bridge,
|
||||
agent,
|
||||
agentCard: updatedAgentCard,
|
||||
baseUrl,
|
||||
port: serverPort,
|
||||
cleanup: async () => {
|
||||
// Cleanup subscribers to prevent memory leaks
|
||||
webhookSubscriber.cleanup();
|
||||
sseSubscriber.cleanup();
|
||||
approvalCoordinator.removeAllListeners();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
bridge.server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
if (agent.isStarted()) {
|
||||
await agent.stop();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an available port starting from a random port in the ephemeral range
|
||||
* Uses ports 49152-65535 (IANA ephemeral port range)
|
||||
*/
|
||||
async function findAvailablePort(): Promise<number> {
|
||||
const { createServer } = await import('node:http');
|
||||
// Start from a random port in the ephemeral range to avoid conflicts
|
||||
const startPort = 49152 + Math.floor(Math.random() * 1000);
|
||||
|
||||
for (let port = startPort; port < 65535; port++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is in use`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
return port;
|
||||
} catch {
|
||||
// Port is in use, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find an available port starting from ${startPort}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make HTTP requests to the test server
|
||||
*/
|
||||
export async function httpRequest(
|
||||
baseUrl: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
headers?: Record<string, string>
|
||||
): Promise<{
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
text: string;
|
||||
}> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let parsedBody: unknown;
|
||||
try {
|
||||
parsedBody = JSON.parse(text);
|
||||
} catch {
|
||||
parsedBody = text;
|
||||
}
|
||||
|
||||
// Convert Headers to plain object for serialization
|
||||
const headersObject: Record<string, string> = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headersObject[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: headersObject,
|
||||
body: parsedBody,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a response has the expected structure
|
||||
*/
|
||||
export function expectResponseStructure(
|
||||
body: unknown,
|
||||
schema: Record<string, (value: unknown) => boolean>
|
||||
): void {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
throw new Error(`Expected object response, got ${typeof body}`);
|
||||
}
|
||||
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
for (const [key, validator] of Object.entries(schema)) {
|
||||
if (!(key in bodyObj)) {
|
||||
throw new Error(`Missing required field: ${key}`);
|
||||
}
|
||||
if (!validator(bodyObj[key])) {
|
||||
throw new Error(
|
||||
`Invalid type for field '${key}': expected validator to return true, got false`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common response validators
|
||||
*/
|
||||
export const validators = {
|
||||
string: (value: unknown): boolean => typeof value === 'string',
|
||||
number: (value: unknown): boolean => typeof value === 'number',
|
||||
boolean: (value: unknown): boolean => typeof value === 'boolean',
|
||||
array: (value: unknown): boolean => Array.isArray(value),
|
||||
object: (value: unknown): boolean =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value),
|
||||
optionalString: (value: unknown): boolean => value === undefined || typeof value === 'string',
|
||||
optionalNumber: (value: unknown): boolean => value === undefined || typeof value === 'number',
|
||||
optionalArray: (value: unknown): boolean => value === undefined || Array.isArray(value),
|
||||
optionalObject: (value: unknown): boolean =>
|
||||
value === undefined ||
|
||||
(typeof value === 'object' && value !== null && !Array.isArray(value)),
|
||||
};
|
||||
Reference in New Issue
Block a user