/**
* Ask-One-Question Tool E2E Test
*
* Isolated test for the ask-one-question tool functionality.
* Tests the complete workflow from MCP tool call to human response simulation.
*/
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
import fetch from 'node-fetch';
describe('Ask-One-Question Tool E2E', () => {
let serverProcess: ChildProcess | null = null;
const serverPath = join(__dirname, '../../../dist/askme-server/main.js');
const testPort = 3000;
let requestId: string | null = null;
beforeEach(async () => {
// Start server with exit-after-command for isolated testing
serverProcess = spawn('node', [serverPath, '--port', testPort.toString(), '--exit-after-command'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
ASK_ME_MCP_DEBUG: '1'
}
});
// Wait for server to be ready
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Server failed to start within 10 seconds'));
}, 10000);
serverProcess!.stderr!.on('data', (data) => {
const message = data.toString();
if (message.includes('Server ready for connections')) {
clearTimeout(timeout);
resolve();
} else if (message.includes('EADDRINUSE')) {
clearTimeout(timeout);
reject(new Error(`Port ${testPort} already in use`));
}
});
serverProcess!.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
});
afterEach(async () => {
if (serverProcess) {
if (!serverProcess.killed) {
serverProcess.kill('SIGTERM');
}
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGKILL');
}
resolve();
}, 3000);
serverProcess!.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
serverProcess = null;
}
});
test('should execute ask-one-question tool successfully', async () => {
// Step 1: Initialize MCP connection
const initRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {
roots: { listChanged: true },
sampling: {}
},
clientInfo: {
name: 'test-client',
version: '1.0.0'
}
}
};
const initPromise = new Promise((resolve, reject) => {
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 1) {
resolve(response);
return;
}
} catch (e) {
// Ignore non-JSON lines
}
}
});
setTimeout(() => reject(new Error('Initialize timeout')), 5000);
});
serverProcess!.stdin!.write(JSON.stringify(initRequest) + '\n');
const initResponse = await initPromise;
expect(initResponse).toHaveProperty('result');
expect((initResponse as any).result).toHaveProperty('capabilities');
// Step 2: Call ask-one-question tool
const toolRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ask-one-question',
arguments: {
question: '# Test Question\n\nWhat is your favorite color? Please choose from:\n\n- Red\n- Blue\n- Green\n- Yellow\n\nExplain your choice briefly.',
context: {
testMode: true,
timestamp: new Date().toISOString()
},
options: ['Red', 'Blue', 'Green', 'Yellow']
}
}
};
// Set up response handler and browser mock simultaneously
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Tool call timeout')), 30000);
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 2) {
clearTimeout(timeout);
resolve(response);
return;
}
} catch (e) {
// Ignore non-JSON lines
}
}
});
});
// Step 3: Set up browser mock to simulate human response
const browserMockPromise = (async () => {
// Wait a bit for the server to process the request
await new Promise(resolve => setTimeout(resolve, 1000));
try {
// Connect to browser bridge SSE endpoint
const response = await fetch(`http://localhost:${testPort}/mcp/browser-events`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
// Read SSE events to get the request
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
if (eventData.type === 'new_request' && eventData.data.type === 'ask-one-question') {
requestId = eventData.data.id;
// Send mock human response
const humanResponse = {
answer: 'Blue is my favorite color because it reminds me of the ocean and sky. It has a calming effect and represents both depth and infinity.',
timestamp: new Date().toISOString()
};
await fetch(`http://localhost:${testPort}/mcp/response`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: requestId,
sessionId: 'demo',
response: humanResponse
})
});
reader.releaseLock();
return;
}
} catch (e) {
// Ignore malformed JSON
}
}
}
}
} catch (error) {
console.error('Browser mock error:', error);
}
})();
// Execute tool call and browser simulation
serverProcess!.stdin!.write(JSON.stringify(toolRequest) + '\n');
// Wait for both to complete
await Promise.all([browserMockPromise, new Promise(resolve => setTimeout(resolve, 2000))]);
const toolResponse = await responsePromise;
// Step 4: Validate response
expect(toolResponse).toHaveProperty('result');
const result = (toolResponse as any).result;
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
expect(result.content.length).toBeGreaterThan(0);
const content = result.content[0];
expect(content).toHaveProperty('type', 'text');
expect(content).toHaveProperty('text');
expect(typeof content.text).toBe('string');
// Should contain the human response
expect(content.text).toContain('Blue is my favorite color');
expect(content.text).toContain('ocean and sky');
// Validate question context was preserved
expect(content.text).toContain('Test Question');
}, 35000);
test('should handle ask-one-question with minimal arguments', async () => {
// Initialize connection first
const initRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: { roots: { listChanged: true }, sampling: {} },
clientInfo: { name: 'test-client', version: '1.0.0' }
}
};
const initPromise = new Promise((resolve) => {
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 1) resolve(response);
} catch (e) { /* ignore */ }
}
});
});
serverProcess!.stdin!.write(JSON.stringify(initRequest) + '\n');
await initPromise;
// Call tool with minimal arguments
const toolRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ask-one-question',
arguments: {
question: 'What is 2 + 2?'
}
}
};
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 20000);
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 2) {
clearTimeout(timeout);
resolve(response);
}
} catch (e) { /* ignore */ }
}
});
});
// Mock browser response
setTimeout(async () => {
try {
// Wait for request to be processed
await new Promise(resolve => setTimeout(resolve, 1000));
// Get SSE stream to find request ID
const response = await fetch(`http://localhost:${testPort}/mcp/browser-events`);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const readChunk = async (): Promise<void> => {
const { done, value } = await reader.read();
if (done) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const eventData = JSON.parse(line.slice(6));
if (eventData.type === 'new_request') {
// Send response
await fetch(`http://localhost:${testPort}/mcp/response`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
requestId: eventData.data.id,
sessionId: 'demo',
response: { answer: '4' }
})
});
reader.releaseLock();
return;
}
} catch (e) { /* ignore */ }
}
}
await readChunk();
};
await readChunk();
} catch (error) {
console.error('Mock error:', error);
}
}, 500);
serverProcess!.stdin!.write(JSON.stringify(toolRequest) + '\n');
const toolResponse = await responsePromise;
expect(toolResponse).toHaveProperty('result');
const result = (toolResponse as any).result;
expect(result.content[0].text).toContain('4');
}, 25000);
test('should reject ask-one-question with invalid arguments', async () => {
// Initialize first
const initRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: { roots: { listChanged: true }, sampling: {} },
clientInfo: { name: 'test-client', version: '1.0.0' }
}
};
const initPromise = new Promise((resolve) => {
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 1) resolve(response);
} catch (e) { /* ignore */ }
}
});
});
serverProcess!.stdin!.write(JSON.stringify(initRequest) + '\n');
await initPromise;
// Call tool with invalid arguments (missing question)
const toolRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ask-one-question',
arguments: {
context: { test: true }
// Missing required 'question' field
}
}
};
const responsePromise = new Promise((resolve) => {
serverProcess!.stdout!.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const response = JSON.parse(line);
if (response.id === 2) resolve(response);
} catch (e) { /* ignore */ }
}
});
});
serverProcess!.stdin!.write(JSON.stringify(toolRequest) + '\n');
const toolResponse = await responsePromise;
// Should return an error
expect(toolResponse).toHaveProperty('error');
const error = (toolResponse as any).error;
expect(error.code).toBe(-32602); // Invalid params
expect(error.message).toContain('question');
}, 15000);
});