Relocate tests under /test (#1220)

This commit is contained in:
Konstantin Konstantinov 2025-12-06 01:56:12 +02:00 • committed by GitHub
commit 5a9de1c638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 630 additions and 524 deletions

View file

@ -0,0 +1,936 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../../../src/experimental/tasks/stores/in-memory.js';
import { TaskCreationParams, Request } from '../../../../src/types.js';
import { QueuedMessage } from '../../../../src/experimental/tasks/interfaces.js';
describe('InMemoryTaskStore', () => {
let store: InMemoryTaskStore;
beforeEach(() => {
store = new InMemoryTaskStore();
});
afterEach(() => {
store.cleanup();
});
describe('createTask', () => {
it('should create a new task with working status', async () => {
const taskParams: TaskCreationParams = {
ttl: 60000
};
const request: Request = {
method: 'tools/call',
params: { name: 'test-tool' }
};
const task = await store.createTask(taskParams, 123, request);
expect(task).toBeDefined();
expect(task.taskId).toBeDefined();
expect(typeof task.taskId).toBe('string');
expect(task.taskId.length).toBeGreaterThan(0);
expect(task.status).toBe('working');
expect(task.ttl).toBe(60000);
expect(task.pollInterval).toBeDefined();
expect(task.createdAt).toBeDefined();
expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0);
});
it('should create task without ttl', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task = await store.createTask(taskParams, 456, request);
expect(task).toBeDefined();
expect(task.ttl).toBeNull();
});
it('should generate unique taskIds', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const task1 = await store.createTask(taskParams, 789, request);
const task2 = await store.createTask(taskParams, 790, request);
expect(task1.taskId).not.toBe(task2.taskId);
});
});
describe('getTask', () => {
it('should return null for non-existent task', async () => {
const task = await store.getTask('non-existent');
expect(task).toBeNull();
});
it('should return task state', async () => {
const taskParams: TaskCreationParams = {};
const request: Request = {
method: 'tools/call',
params: {}
};
const createdTask = await store.createTask(taskParams, 111, request);
await store.updateTaskStatus(createdTask.taskId, 'working');
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.status).toBe('working');
});
});
describe('updateTaskStatus', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 222, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should keep task status as working', async () => {
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should update task status to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should update task status to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should update task status to failed with error', async () => {
await store.updateTaskStatus(taskId, 'failed', 'Something went wrong');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
expect(task?.statusMessage).toBe('Something went wrong');
});
it('should update task status to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should throw if task not found', async () => {
await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found');
});
describe('status lifecycle validation', () => {
it('should allow transition from working to input_required', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const task = await store.getTask(taskId);
expect(task?.status).toBe('input_required');
});
it('should allow transition from working to completed', async () => {
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from working to failed', async () => {
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from working to cancelled', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should allow transition from input_required to working', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'working');
const task = await store.getTask(taskId);
expect(task?.status).toBe('working');
});
it('should allow transition from input_required to completed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'completed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
it('should allow transition from input_required to failed', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'failed');
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
});
it('should allow transition from input_required to cancelled', async () => {
await store.updateTaskStatus(taskId, 'input_required');
await store.updateTaskStatus(taskId, 'cancelled');
const task = await store.getTask(taskId);
expect(task?.status).toBe('cancelled');
});
it('should reject transition from completed to any other status', async () => {
await store.updateTaskStatus(taskId, 'completed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from failed to any other status', async () => {
await store.updateTaskStatus(taskId, 'failed');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task');
});
it('should reject transition from cancelled to any other status', async () => {
await store.updateTaskStatus(taskId, 'cancelled');
await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task');
await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task');
});
});
});
describe('storeTaskResult', () => {
let taskId: string;
beforeEach(async () => {
const taskParams: TaskCreationParams = {
ttl: 60000
};
const createdTask = await store.createTask(taskParams, 333, {
method: 'tools/call',
params: {}
});
taskId = createdTask.taskId;
});
it('should store task result and set status to completed', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toEqual(result);
});
it('should throw if task not found', async () => {
await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found');
});
it('should reject storing result for task already in completed status', async () => {
// First complete the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First result' }]
};
await store.storeTaskResult(taskId, 'completed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should store result with failed status', async () => {
const result = {
content: [{ type: 'text' as const, text: 'Error details' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('failed');
const storedResult = await store.getTaskResult(taskId);
expect(storedResult).toEqual(result);
});
it('should reject storing result for task already in failed status', async () => {
// First fail the task
const firstResult = {
content: [{ type: 'text' as const, text: 'First error' }],
isError: true
};
await store.storeTaskResult(taskId, 'failed', firstResult);
// Try to store result again (should fail)
const secondResult = {
content: [{ type: 'text' as const, text: 'Second error' }],
isError: true
};
await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task');
});
it('should reject storing result for cancelled task', async () => {
// Mark task as cancelled
await store.updateTaskStatus(taskId, 'cancelled');
// Try to store result (should fail)
const result = {
content: [{ type: 'text' as const, text: 'Cancellation result' }]
};
await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task');
});
it('should allow storing result from input_required status', async () => {
await store.updateTaskStatus(taskId, 'input_required');
const result = {
content: [{ type: 'text' as const, text: 'Success!' }]
};
await store.storeTaskResult(taskId, 'completed', result);
const task = await store.getTask(taskId);
expect(task?.status).toBe('completed');
});
});
describe('getTaskResult', () => {
it('should throw if task not found', async () => {
await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found');
});
it('should throw if task has no result stored', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 444, {
method: 'tools/call',
params: {}
});
await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`);
});
it('should return stored result', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 555, {
method: 'tools/call',
params: {}
});
const result = {
content: [{ type: 'text' as const, text: 'Result data' }]
};
await store.storeTaskResult(createdTask.taskId, 'completed', result);
const retrieved = await store.getTaskResult(createdTask.taskId);
expect(retrieved).toEqual(result);
});
});
describe('ttl cleanup', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should cleanup task after ttl duration', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 666, {
method: 'tools/call',
params: {}
});
// Task should exist initially
let task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward past ttl
vi.advanceTimersByTime(1001);
// Task should be cleaned up
task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should reset cleanup timer when result is stored', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 777, {
method: 'tools/call',
params: {}
});
// Fast-forward 500ms
vi.advanceTimersByTime(500);
// Store result (should reset timer)
await store.storeTaskResult(createdTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
// Fast-forward another 500ms (total 1000ms since creation, but timer was reset)
vi.advanceTimersByTime(500);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
// Fast-forward remaining time
vi.advanceTimersByTime(501);
// Now task should be cleaned up
const cleanedTask = await store.getTask(createdTask.taskId);
expect(cleanedTask).toBeNull();
});
it('should not cleanup tasks without ttl', async () => {
const taskParams: TaskCreationParams = {};
const createdTask = await store.createTask(taskParams, 888, {
method: 'tools/call',
params: {}
});
// Fast-forward a long time
vi.advanceTimersByTime(100000);
// Task should still exist
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
});
it('should start cleanup timer when task reaches terminal state', async () => {
const taskParams: TaskCreationParams = {
ttl: 1000
};
const createdTask = await store.createTask(taskParams, 999, {
method: 'tools/call',
params: {}
});
// Task in non-terminal state, fast-forward
vi.advanceTimersByTime(1001);
// Task should be cleaned up
let task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
// Create another task
const taskParams2: TaskCreationParams = {
ttl: 2000
};
const createdTask2 = await store.createTask(taskParams2, 1000, {
method: 'tools/call',
params: {}
});
// Update to terminal state
await store.updateTaskStatus(createdTask2.taskId, 'completed');
// Fast-forward past original ttl
vi.advanceTimersByTime(2001);
// Task should be cleaned up
task = await store.getTask(createdTask2.taskId);
expect(task).toBeNull();
});
it('should return actual TTL in task response', async () => {
// Test that the TaskStore returns the actual TTL it will use
// This implementation uses the requested TTL as-is, but implementations
// MAY override it (e.g., enforce maximum TTL limits)
const requestedTtl = 5000;
const taskParams: TaskCreationParams = {
ttl: requestedTtl
};
const createdTask = await store.createTask(taskParams, 1111, {
method: 'tools/call',
params: {}
});
// The returned task should include the actual TTL that will be used
expect(createdTask.ttl).toBe(requestedTtl);
// Verify the task is cleaned up after the actual TTL
vi.advanceTimersByTime(requestedTtl + 1);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeNull();
});
it('should support null TTL for unlimited lifetime', async () => {
// Test that null TTL means unlimited lifetime
const taskParams: TaskCreationParams = {
ttl: null
};
const createdTask = await store.createTask(taskParams, 2222, {
method: 'tools/call',
params: {}
});
// The returned task should have null TTL
expect(createdTask.ttl).toBeNull();
// Task should not be cleaned up even after a long time
vi.advanceTimersByTime(100000);
const task = await store.getTask(createdTask.taskId);
expect(task).toBeDefined();
expect(task?.taskId).toBe(createdTask.taskId);
});
it('should cleanup tasks regardless of status', async () => {
// Test that TTL cleanup happens regardless of task status
const taskParams: TaskCreationParams = {
ttl: 1000
};
// Create tasks in different statuses
const workingTask = await store.createTask(taskParams, 3333, {
method: 'tools/call',
params: {}
});
const completedTask = await store.createTask(taskParams, 4444, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(completedTask.taskId, 'completed', {
content: [{ type: 'text' as const, text: 'Done' }]
});
const failedTask = await store.createTask(taskParams, 5555, {
method: 'tools/call',
params: {}
});
await store.storeTaskResult(failedTask.taskId, 'failed', {
content: [{ type: 'text' as const, text: 'Error' }]
});
// Fast-forward past TTL
vi.advanceTimersByTime(1001);
// All tasks should be cleaned up regardless of status
expect(await store.getTask(workingTask.taskId)).toBeNull();
expect(await store.getTask(completedTask.taskId)).toBeNull();
expect(await store.getTask(failedTask.taskId)).toBeNull();
});
});
describe('getAllTasks', () => {
it('should return all tasks', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const tasks = store.getAllTasks();
expect(tasks).toHaveLength(3);
// Verify all tasks have unique IDs
const taskIds = tasks.map(t => t.taskId);
expect(new Set(taskIds).size).toBe(3);
});
it('should return empty array when no tasks', () => {
const tasks = store.getAllTasks();
expect(tasks).toEqual([]);
});
});
describe('listTasks', () => {
it('should return empty list when no tasks', async () => {
const result = await store.listTasks();
expect(result.tasks).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 2, {
method: 'tools/call',
params: {}
});
await store.createTask({}, 3, {
method: 'tools/call',
params: {}
});
const result = await store.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size', async () => {
// Create 15 tasks (page size is 10)
for (let i = 1; i <= 15; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first page
const page1 = await store.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await store.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should throw error for invalid cursor', async () => {
await store.createTask({}, 1, {
method: 'tools/call',
params: {}
});
await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor');
});
it('should continue from cursor correctly', async () => {
// Create 5 tasks
for (let i = 1; i <= 5; i++) {
await store.createTask({}, i, {
method: 'tools/call',
params: {}
});
}
// Get first 3 tasks
const allTaskIds = Array.from(store.getAllTasks().map(t => t.taskId));
const result = await store.listTasks(allTaskIds[2]);
// Should get tasks after the third task
expect(result.tasks).toHaveLength(2);
});
});
describe('cleanup', () => {
it('should clear all timers and tasks', async () => {
await store.createTask({ ttl: 1000 }, 1, {
method: 'tools/call',
params: {}
});
await store.createTask({ ttl: 2000 }, 2, {
method: 'tools/call',
params: {}
});
expect(store.getAllTasks()).toHaveLength(2);
store.cleanup();
expect(store.getAllTasks()).toHaveLength(0);
});
});
});
describe('InMemoryTaskMessageQueue', () => {
let queue: InMemoryTaskMessageQueue;
beforeEach(() => {
queue = new InMemoryTaskMessageQueue();
});
describe('enqueue and dequeue', () => {
it('should enqueue and dequeue request messages', async () => {
const requestMessage: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'test-tool', arguments: {} }
},
timestamp: Date.now()
};
await queue.enqueue('task-1', requestMessage);
const dequeued = await queue.dequeue('task-1');
expect(dequeued).toEqual(requestMessage);
});
it('should enqueue and dequeue notification messages', async () => {
const notificationMessage: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progress: 50, total: 100 }
},
timestamp: Date.now()
};
await queue.enqueue('task-2', notificationMessage);
const dequeued = await queue.dequeue('task-2');
expect(dequeued).toEqual(notificationMessage);
});
it('should enqueue and dequeue response messages', async () => {
const responseMessage: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 42,
result: { content: [{ type: 'text', text: 'Success' }] }
},
timestamp: Date.now()
};
await queue.enqueue('task-3', responseMessage);
const dequeued = await queue.dequeue('task-3');
expect(dequeued).toEqual(responseMessage);
});
it('should return undefined when dequeuing from empty queue', async () => {
const dequeued = await queue.dequeue('task-empty');
expect(dequeued).toBeUndefined();
});
it('should maintain FIFO order for mixed message types', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 2000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 3000
};
await queue.enqueue('task-fifo', request);
await queue.enqueue('task-fifo', notification);
await queue.enqueue('task-fifo', response);
expect(await queue.dequeue('task-fifo')).toEqual(request);
expect(await queue.dequeue('task-fifo')).toEqual(notification);
expect(await queue.dequeue('task-fifo')).toEqual(response);
expect(await queue.dequeue('task-fifo')).toBeUndefined();
});
});
describe('dequeueAll', () => {
it('should dequeue all messages including responses', async () => {
const request: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {}
},
timestamp: 1000
};
const response: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: 2000
};
const notification: QueuedMessage = {
type: 'notification',
message: {
jsonrpc: '2.0',
method: 'notifications/progress',
params: {}
},
timestamp: 3000
};
await queue.enqueue('task-all', request);
await queue.enqueue('task-all', response);
await queue.enqueue('task-all', notification);
const all = await queue.dequeueAll('task-all');
expect(all).toHaveLength(3);
expect(all[0]).toEqual(request);
expect(all[1]).toEqual(response);
expect(all[2]).toEqual(notification);
});
it('should return empty array for non-existent task', async () => {
const all = await queue.dequeueAll('non-existent');
expect(all).toEqual([]);
});
it('should clear the queue after dequeueAll', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-clear', message);
await queue.dequeueAll('task-clear');
const dequeued = await queue.dequeue('task-clear');
expect(dequeued).toBeUndefined();
});
});
describe('queue size limits', () => {
it('should throw when maxSize is exceeded', async () => {
const message: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test',
params: {}
},
timestamp: Date.now()
};
await queue.enqueue('task-limit', message, undefined, 2);
await queue.enqueue('task-limit', message, undefined, 2);
await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow');
});
it('should allow enqueue when under maxSize', async () => {
const message: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 1,
result: {}
},
timestamp: Date.now()
};
await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined();
});
});
describe('task isolation', () => {
it('should isolate messages between different tasks', async () => {
const message1: QueuedMessage = {
type: 'request',
message: {
jsonrpc: '2.0',
id: 1,
method: 'test1',
params: {}
},
timestamp: 1000
};
const message2: QueuedMessage = {
type: 'response',
message: {
jsonrpc: '2.0',
id: 2,
result: {}
},
timestamp: 2000
};
await queue.enqueue('task-a', message1);
await queue.enqueue('task-b', message2);
expect(await queue.dequeue('task-a')).toEqual(message1);
expect(await queue.dequeue('task-b')).toEqual(message2);
expect(await queue.dequeue('task-a')).toBeUndefined();
expect(await queue.dequeue('task-b')).toBeUndefined();
});
});
describe('response message error handling', () => {
it('should handle response messages with errors', async () => {
const errorResponse: QueuedMessage = {
type: 'error',
message: {
jsonrpc: '2.0',
id: 1,
error: {
code: -32600,
message: 'Invalid Request'
}
},
timestamp: Date.now()
};
await queue.enqueue('task-error', errorResponse);
const dequeued = await queue.dequeue('task-error');
expect(dequeued).toEqual(errorResponse);
expect(dequeued?.type).toBe('error');
});
});
});

View file

@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ErrorCode, McpError } from '../../../src/types.js';
import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js';
describe('Task Listing with Pagination', () => {
let client: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['client'];
let server: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['server'];
let taskStore: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['taskStore'];
beforeEach(async () => {
const env = await createInMemoryTaskEnvironment();
client = env.client;
server = env.server;
taskStore = env.taskStore;
});
afterEach(async () => {
taskStore.cleanup();
await client.close();
await server.close();
});
it('should return empty list when no tasks exist', async () => {
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toEqual([]);
expect(result.nextCursor).toBeUndefined();
});
it('should return all tasks when less than page size', async () => {
// Create 3 tasks
for (let i = 0; i < 3; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
const result = await client.experimental.tasks.listTasks();
expect(result.tasks).toHaveLength(3);
expect(result.nextCursor).toBeUndefined();
});
it('should paginate when more than page size exists', async () => {
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
for (let i = 0; i < 15; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get first page
const page1 = await client.experimental.tasks.listTasks();
expect(page1.tasks).toHaveLength(10);
expect(page1.nextCursor).toBeDefined();
// Get second page using cursor
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
expect(page2.tasks).toHaveLength(5);
expect(page2.nextCursor).toBeUndefined();
});
it('should treat cursor as opaque token', async () => {
// Create 5 tasks
for (let i = 0; i < 5; i++) {
await taskStore.createTask({}, i, {
method: 'tools/call',
params: { name: 'test-tool' }
});
}
// Get all tasks to get a valid cursor
const allTasks = taskStore.getAllTasks();
const validCursor = allTasks[2].taskId;
// Use the cursor - should work even though we don't know its internal structure
const result = await client.experimental.tasks.listTasks(validCursor);
expect(result.tasks).toHaveLength(2);
});
it('should return error code -32602 for invalid cursor', async () => {
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: McpError) => {
expect(error).toBeInstanceOf(McpError);
expect(error.code).toBe(ErrorCode.InvalidParams);
expect(error.message).toContain('Invalid cursor');
return true;
});
});
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
// Create a task
const task = await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
// Verify it's accessible via tasks/get
const getResult = await client.experimental.tasks.getTask(task.taskId);
expect(getResult.taskId).toBe(task.taskId);
// Verify it's also accessible via tasks/list
const listResult = await client.experimental.tasks.listTasks();
expect(listResult.tasks).toHaveLength(1);
expect(listResult.tasks[0].taskId).toBe(task.taskId);
});
it('should not include related-task metadata in list response', async () => {
// Create a task
await taskStore.createTask({}, 1, {
method: 'tools/call',
params: { name: 'test-tool' }
});
const result = await client.experimental.tasks.listTasks();
// The response should have _meta but not include related-task metadata
expect(result._meta).toBeDefined();
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
});
});

View file

@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { isTerminal } from '../../../src/experimental/tasks/interfaces.js';
import type { Task } from '../../../src/types.js';
describe('Task utility functions', () => {
describe('isTerminal', () => {
it('should return true for completed status', () => {
expect(isTerminal('completed')).toBe(true);
});
it('should return true for failed status', () => {
expect(isTerminal('failed')).toBe(true);
});
it('should return true for cancelled status', () => {
expect(isTerminal('cancelled')).toBe(true);
});
it('should return false for working status', () => {
expect(isTerminal('working')).toBe(false);
});
it('should return false for input_required status', () => {
expect(isTerminal('input_required')).toBe(false);
});
});
});
describe('Task Schema Validation', () => {
it('should validate task with ttl field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-123',
status: 'working',
ttl: 60000,
createdAt,
lastUpdatedAt: createdAt,
pollInterval: 1000
};
expect(task.ttl).toBe(60000);
expect(task.createdAt).toBeDefined();
expect(typeof task.createdAt).toBe('string');
});
it('should validate task with null ttl', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-456',
status: 'completed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.ttl).toBeNull();
});
it('should validate task with statusMessage field', () => {
const createdAt = new Date().toISOString();
const task: Task = {
taskId: 'test-789',
status: 'failed',
ttl: null,
createdAt,
lastUpdatedAt: createdAt,
statusMessage: 'Operation failed due to timeout'
};
expect(task.statusMessage).toBe('Operation failed due to timeout');
});
it('should validate task with createdAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
});
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
const now = new Date();
const createdAt = now.toISOString();
const task: Task = {
taskId: 'test-iso',
status: 'working',
ttl: 30000,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
it('should validate all task statuses', () => {
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
const createdAt = new Date().toISOString();
statuses.forEach(status => {
const task: Task = {
taskId: `test-${status}`,
status,
ttl: null,
createdAt,
lastUpdatedAt: createdAt
};
expect(task.status).toBe(status);
});
});
});