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
54 changed files with 630 additions and 524 deletions

View file

@ -51,7 +51,7 @@ Examples:
- [`simpleOAuthClient.ts`](../src/examples/client/simpleOAuthClient.ts)
- [`simpleOAuthClientProvider.ts`](../src/examples/client/simpleOAuthClientProvider.ts)
- [`simpleClientCredentials.ts`](../src/examples/client/simpleClientCredentials.ts)
- Server-side auth demo: [`demoInMemoryOAuthProvider.ts`](../src/examples/server/demoInMemoryOAuthProvider.ts)
- Server-side auth demo: [`demoInMemoryOAuthProvider.ts`](../src/examples/server/demoInMemoryOAuthProvider.ts) (tests live under `test/examples/server/demoInMemoryOAuthProvider.test.ts`)
These examples show how to:

10
package-lock.json generated
View file

@ -1319,6 +1319,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@ -1750,6 +1751,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2292,6 +2294,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -2610,6 +2613,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@ -4067,6 +4071,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4147,6 +4152,7 @@
"integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@ -4192,6 +4198,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4386,6 +4393,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -4399,6 +4407,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -4551,6 +4560,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -0,0 +1,331 @@
import { describe, it, expect } from 'vitest';
import { auth } from '../../src/client/auth.js';
import {
ClientCredentialsProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider,
createPrivateKeyJwtAuth
} from '../../src/client/auth-extensions.js';
import { createMockOAuthFetch } from '../helpers/oauth.js';
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
const AUTH_SERVER_URL = 'https://auth.example.com';
describe('auth-extensions providers (end-to-end with auth())', () => {
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
const provider = new ClientCredentialsProvider({
clientId: 'my-client',
clientSecret: 'my-secret',
clientName: 'test-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBeNull();
const headers = new Headers(init?.headers);
const authHeader = headers.get('Authorization');
expect(authHeader).toBeTruthy();
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'HS256',
clientName: 'private-key-jwt-client'
});
let assertionFromRequest: string | null = null;
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
assertionFromRequest = params.get('client_assertion');
expect(assertionFromRequest).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const parts = assertionFromRequest!.split('.');
expect(parts).toHaveLength(3);
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
expect(assertionFromRequest).toBeTruthy();
});
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
const provider = new PrivateKeyJwtProvider({
clientId: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
algorithm: 'none',
clientName: 'private-key-jwt-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL
});
await expect(
auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
})
).rejects.toThrow('Unsupported algorithm none');
});
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
const staticAssertion = 'header.payload.signature';
const provider = new StaticPrivateKeyJwtProvider({
clientId: 'static-client',
jwtBearerAssertion: staticAssertion,
clientName: 'static-private-key-jwt-client'
});
const fetchMock = createMockOAuthFetch({
resourceServerUrl: RESOURCE_SERVER_URL,
authServerUrl: AUTH_SERVER_URL,
onTokenRequest: async (_url, init) => {
const params = init?.body as URLSearchParams;
expect(params).toBeInstanceOf(URLSearchParams);
expect(params.get('grant_type')).toBe('client_credentials');
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
expect(params.get('client_assertion')).toBe(staticAssertion);
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
const headers = new Headers(init?.headers);
expect(headers.get('Authorization')).toBeNull();
}
});
const result = await auth(provider, {
serverUrl: RESOURCE_SERVER_URL,
fetchFn: fetchMock
});
expect(result).toBe('AUTHORIZED');
const tokens = provider.tokens();
expect(tokens).toBeTruthy();
expect(tokens?.access_token).toBe('test-access-token');
});
});
describe('createPrivateKeyJwtAuth', () => {
const baseOptions = {
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
};
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const headers = new Headers();
const params = new URLSearchParams();
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
expect(params.get('client_assertion')).toBeTruthy();
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
// Verify JWT structure (three dot-separated segments)
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('throws when globalThis.crypto is not available', async () => {
// Temporarily remove globalThis.crypto to simulate older Node.js runtimes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalAny = globalThis as any;
const originalCrypto = globalAny.crypto;
// Use delete so that typeof globalThis.crypto === 'undefined'
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete globalAny.crypto;
try {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions'
);
} finally {
// Restore original crypto to avoid affecting other tests
globalAny.crypto = originalCrypto;
}
});
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: secret,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using a symmetric JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
// "a-string-secret-at-least-256-bits-long" base64url-encoded
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
alg: 'HS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('creates a signed JWT when using an RSA PEM private key', async () => {
// Generate an RSA key pair on the fly
const jose = await import('jose');
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
const pem = await jose.exportPKCS8(privateKey);
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: pem,
alg: 'RS256'
});
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
const assertion = params.get('client_assertion')!;
const parts = assertion.split('.');
expect(parts).toHaveLength(3);
});
it('uses metadata.issuer as audience when available', async () => {
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
const params = new URLSearchParams();
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
issuer: 'https://issuer.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code']
});
const assertion = params.get('client_assertion')!;
// Decode the payload to verify audience
const [, payloadB64] = assertion.split('.');
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
expect(payload.aud).toBe('https://issuer.example.com');
});
it('throws when using an unsupported algorithm', async () => {
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'none'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
'Unsupported algorithm none'
);
});
it('throws when jose cannot import an invalid RSA PEM key', async () => {
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: badPem,
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Invalid character/
);
});
it('throws when jose cannot import a mismatched JWK key', async () => {
const jwk: Record<string, unknown> = {
kty: 'oct',
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
alg: 'HS256'
};
const addClientAuth = createPrivateKeyJwtAuth({
issuer: 'client-id',
subject: 'client-id',
privateKey: jwk,
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
alg: 'RS256'
});
const params = new URLSearchParams();
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
);
});
});

3247
test/client/auth.test.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,153 @@
import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/stdio.js';
import spawn from 'cross-spawn';
import { JSONRPCMessage } from '../../src/types.js';
import { ChildProcess } from 'node:child_process';
import { Mock, MockedFunction } from 'vitest';
// mock cross-spawn
vi.mock('cross-spawn');
const mockSpawn = spawn as unknown as MockedFunction<typeof spawn>;
describe('StdioClientTransport using cross-spawn', () => {
beforeEach(() => {
// mock cross-spawn's return value
mockSpawn.mockImplementation(() => {
const mockProcess: {
on: Mock;
stdin?: { on: Mock; write: Mock };
stdout?: { on: Mock };
stderr?: null;
} = {
on: vi.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return mockProcess;
}),
stdin: {
on: vi.fn(),
write: vi.fn().mockReturnValue(true)
},
stdout: {
on: vi.fn()
},
stderr: null
};
return mockProcess as unknown as ChildProcess;
});
});
afterEach(() => {
vi.clearAllMocks();
});
test('should call cross-spawn correctly', async () => {
const transport = new StdioClientTransport({
command: 'test-command',
args: ['arg1', 'arg2']
});
await transport.start();
// verify spawn is called correctly
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
['arg1', 'arg2'],
expect.objectContaining({
shell: false
})
);
});
test('should pass environment variables correctly', async () => {
const customEnv = { TEST_VAR: 'test-value' };
const transport = new StdioClientTransport({
command: 'test-command',
env: customEnv
});
await transport.start();
// verify environment variables are merged correctly
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
[],
expect.objectContaining({
env: {
...getDefaultEnvironment(),
...customEnv
}
})
);
});
test('should use default environment when env is undefined', async () => {
const transport = new StdioClientTransport({
command: 'test-command',
env: undefined
});
await transport.start();
// verify default environment is used
expect(mockSpawn).toHaveBeenCalledWith(
'test-command',
[],
expect.objectContaining({
env: getDefaultEnvironment()
})
);
});
test('should send messages correctly', async () => {
const transport = new StdioClientTransport({
command: 'test-command'
});
// get the mock process object
const mockProcess: {
on: Mock;
stdin: {
on: Mock;
write: Mock;
once: Mock;
};
stdout: {
on: Mock;
};
stderr: null;
} = {
on: vi.fn((event: string, callback: () => void) => {
if (event === 'spawn') {
callback();
}
return mockProcess;
}),
stdin: {
on: vi.fn(),
write: vi.fn().mockReturnValue(true),
once: vi.fn()
},
stdout: {
on: vi.fn()
},
stderr: null
};
mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess);
await transport.start();
// 关键修复:确保 jsonrpc 是字面量 "2.0"
const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: 'test-id',
method: 'test-method'
};
await transport.send(message);
// verify message is sent correctly
expect(mockProcess.stdin.write).toHaveBeenCalled();
});
});

4139
test/client/index.test.ts Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1506
test/client/sse.test.ts Normal file

File diff suppressed because it is too large Load diff

77
test/client/stdio.test.ts Normal file
View file

@ -0,0 +1,77 @@
import { JSONRPCMessage } from '../../src/types.js';
import { StdioClientTransport, StdioServerParameters } from '../../src/client/stdio.js';
// Configure default server parameters based on OS
// Uses 'more' command for Windows and 'tee' command for Unix/Linux
const getDefaultServerParameters = (): StdioServerParameters => {
if (process.platform === 'win32') {
return { command: 'more' };
}
return { command: '/usr/bin/tee' };
};
const serverParameters = getDefaultServerParameters();
test('should start then close cleanly', async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = error => {
throw error;
};
let didClose = false;
client.onclose = () => {
didClose = true;
};
await client.start();
expect(didClose).toBeFalsy();
await client.close();
expect(didClose).toBeTruthy();
});
test('should read messages', async () => {
const client = new StdioClientTransport(serverParameters);
client.onerror = error => {
throw error;
};
const messages: JSONRPCMessage[] = [
{
jsonrpc: '2.0',
id: 1,
method: 'ping'
},
{
jsonrpc: '2.0',
method: 'notifications/initialized'
}
];
const readMessages: JSONRPCMessage[] = [];
const finished = new Promise<void>(resolve => {
client.onmessage = message => {
readMessages.push(message);
if (JSON.stringify(message) === JSON.stringify(messages[1])) {
resolve();
}
};
});
await client.start();
await client.send(messages[0]);
await client.send(messages[1]);
await finished;
expect(readMessages).toEqual(messages);
await client.close();
});
test('should return child process pid', async () => {
const client = new StdioClientTransport(serverParameters);
await client.start();
expect(client.pid).not.toBeNull();
await client.close();
expect(client.pid).toBeNull();
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,263 @@
import { Response } from 'express';
import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../../src/examples/server/demoInMemoryOAuthProvider.js';
import { AuthorizationParams } from '../../../src/server/auth/provider.js';
import { OAuthClientInformationFull } from '../../../src/shared/auth.js';
import { InvalidRequestError } from '../../../src/server/auth/errors.js';
import { createExpressResponseMock } from '../../helpers/http.js';
describe('DemoInMemoryAuthProvider', () => {
let provider: DemoInMemoryAuthProvider;
let mockResponse: Response & { getRedirectUrl: () => string };
beforeEach(() => {
provider = new DemoInMemoryAuthProvider();
mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & {
getRedirectUrl: () => string;
};
});
describe('authorize', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'],
scope: 'test-scope'
};
it('should redirect to the requested redirect_uri when valid', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
expect(mockResponse.redirect).toHaveBeenCalled();
expect(mockResponse.getRedirectUrl()).toBeDefined();
const url = new URL(mockResponse.getRedirectUrl());
expect(url.origin + url.pathname).toBe('https://example.com/callback');
expect(url.searchParams.get('state')).toBe('test-state');
expect(url.searchParams.has('code')).toBe(true);
});
it('should throw InvalidRequestError for unregistered redirect_uri', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://evil.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError);
await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri');
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('should generate unique authorization codes for multiple requests', async () => {
const params1: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'state-1',
codeChallenge: 'challenge-1',
scopes: ['test-scope']
};
const params2: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'state-2',
codeChallenge: 'challenge-2',
scopes: ['test-scope']
};
await provider.authorize(validClient, params1, mockResponse);
const firstRedirectUrl = mockResponse.getRedirectUrl();
const firstCode = new URL(firstRedirectUrl).searchParams.get('code');
// Reset the mock for the second call
mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & {
getRedirectUrl: () => string;
};
await provider.authorize(validClient, params2, mockResponse);
const secondRedirectUrl = mockResponse.getRedirectUrl();
const secondCode = new URL(secondRedirectUrl).searchParams.get('code');
expect(firstCode).toBeDefined();
expect(secondCode).toBeDefined();
expect(firstCode).not.toBe(secondCode);
});
it('should handle params without state', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
expect(mockResponse.redirect).toHaveBeenCalled();
expect(mockResponse.getRedirectUrl()).toBeDefined();
const url = new URL(mockResponse.getRedirectUrl());
expect(url.searchParams.has('state')).toBe(false);
expect(url.searchParams.has('code')).toBe(true);
});
});
describe('challengeForAuthorizationCode', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
it('should return the code challenge for a valid authorization code', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge-value',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const challenge = await provider.challengeForAuthorizationCode(validClient, code);
expect(challenge).toBe('test-challenge-value');
});
it('should throw error for invalid authorization code', async () => {
await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
});
});
describe('exchangeAuthorizationCode', () => {
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
it('should exchange valid authorization code for tokens', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope', 'other-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const tokens = await provider.exchangeAuthorizationCode(validClient, code);
expect(tokens).toEqual({
access_token: expect.any(String),
token_type: 'bearer',
expires_in: 3600,
scope: 'test-scope other-scope'
});
});
it('should throw error for invalid authorization code', async () => {
await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code');
});
it('should throw error when client_id does not match', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
const differentClient: OAuthClientInformationFull = {
client_id: 'different-client',
client_secret: 'different-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow(
'Authorization code was not issued to this client'
);
});
it('should delete authorization code after successful exchange', async () => {
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope']
};
await provider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
// First exchange should succeed
await provider.exchangeAuthorizationCode(validClient, code);
// Second exchange should fail
await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code');
});
it('should validate resource when validateResource is provided', async () => {
const validateResource = vi.fn().mockReturnValue(false);
const strictProvider = new DemoInMemoryAuthProvider(validateResource);
const params: AuthorizationParams = {
redirectUri: 'https://example.com/callback',
state: 'test-state',
codeChallenge: 'test-challenge',
scopes: ['test-scope'],
resource: new URL('https://invalid-resource.com')
};
await strictProvider.authorize(validClient, params, mockResponse);
const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!;
await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow(
'Invalid resource: https://invalid-resource.com/'
);
expect(validateResource).toHaveBeenCalledWith(params.resource);
});
});
describe('DemoInMemoryClientsStore', () => {
let store: DemoInMemoryClientsStore;
beforeEach(() => {
store = new DemoInMemoryClientsStore();
});
it('should register and retrieve client', async () => {
const client: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback'],
scope: 'test-scope'
};
await store.registerClient(client);
const retrieved = await store.getClient('test-client');
expect(retrieved).toEqual(client);
});
it('should return undefined for non-existent client', async () => {
const retrieved = await store.getClient('non-existent');
expect(retrieved).toBeUndefined();
});
});
});

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

96
test/helpers/http.ts Normal file
View file

@ -0,0 +1,96 @@
import type http from 'node:http';
import { type Server } from 'node:http';
import type { Response } from 'express';
import { AddressInfo } from 'node:net';
import { vi } from 'vitest';
/**
* Attach a listener to an existing server on a random localhost port and return its base URL.
*/
export async function listenOnRandomPort(server: Server, host: string = '127.0.0.1'): Promise<URL> {
return new Promise<URL>(resolve => {
server.listen(0, host, () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://${host}:${addr.port}`));
});
});
}
// =========================
// HTTP/Express mock helpers
// =========================
/**
* Create a minimal Express-like Response mock for tests.
*
* The mock supports:
* - redirect()
* - status().json().send() chaining
* - set()/header()
* - optional getRedirectUrl() helper used in some tests
*/
export function createExpressResponseMock(options: { trackRedirectUrl?: boolean } = {}): Response & {
getRedirectUrl?: () => string;
} {
let capturedRedirectUrl: string | undefined;
const res: Partial<Response> & { getRedirectUrl?: () => string } = {
redirect: vi.fn((urlOrStatus: string | number, maybeUrl?: string | number) => {
if (options.trackRedirectUrl) {
if (typeof urlOrStatus === 'string') {
capturedRedirectUrl = urlOrStatus;
} else if (typeof maybeUrl === 'string') {
capturedRedirectUrl = maybeUrl;
}
}
return res as Response;
}) as unknown as Response['redirect'],
status: vi.fn<Response['status']>().mockImplementation((_code: number) => {
// status code is ignored for now; tests assert it via jest/vitest spies
return res as Response;
}),
json: vi.fn<Response['json']>().mockImplementation((_body: unknown) => {
// body is ignored; tests usually assert via spy
return res as Response;
}),
send: vi.fn<Response['send']>().mockImplementation((_body?: unknown) => {
// body is ignored; tests usually assert via spy
return res as Response;
}),
set: vi.fn<Response['set']>().mockImplementation((_field: string, _value?: string | string[]) => {
// header value is ignored in the generic mock; tests spy on set()
return res as Response;
}),
header: vi.fn<Response['header']>().mockImplementation((_field: string, _value?: string | string[]) => {
return res as Response;
})
};
if (options.trackRedirectUrl) {
res.getRedirectUrl = () => {
if (capturedRedirectUrl === undefined) {
throw new Error('No redirect URL was captured. Ensure redirect() was called first.');
}
return capturedRedirectUrl;
};
}
return res as Response & { getRedirectUrl?: () => string };
}
/**
* Create a Node http.ServerResponse mock used for low-level transport tests.
*
* All core methods are jest/vitest fns returning `this` so that
* tests can assert on writeHead/write/on/end calls.
*/
export function createNodeServerResponseMock(): http.ServerResponse {
const res = {
writeHead: vi.fn<http.ServerResponse['writeHead']>().mockReturnThis(),
write: vi.fn<http.ServerResponse['write']>().mockReturnThis(),
on: vi.fn<http.ServerResponse['on']>().mockReturnThis(),
end: vi.fn<http.ServerResponse['end']>().mockReturnThis()
};
return res as unknown as http.ServerResponse;
}

71
test/helpers/mcp.ts Normal file
View file

@ -0,0 +1,71 @@
import { InMemoryTransport } from '../../src/inMemory.js';
import { Client } from '../../src/client/index.js';
import { Server } from '../../src/server/index.js';
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js';
import type { ClientCapabilities, ServerCapabilities } from '../../src/types.js';
export interface InMemoryTaskEnvironment {
client: Client;
server: Server;
taskStore: InMemoryTaskStore;
clientTransport: InMemoryTransport;
serverTransport: InMemoryTransport;
}
export async function createInMemoryTaskEnvironment(options?: {
clientCapabilities?: ClientCapabilities;
serverCapabilities?: ServerCapabilities;
}): Promise<InMemoryTaskEnvironment> {
const taskStore = new InMemoryTaskStore();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client(
{
name: 'test-client',
version: '1.0.0'
},
{
capabilities: options?.clientCapabilities ?? {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
}
}
);
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: options?.serverCapabilities ?? {
tasks: {
list: {},
requests: {
tools: {
call: {}
}
}
}
},
taskStore,
taskMessageQueue: new InMemoryTaskMessageQueue()
}
);
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
return {
client,
server,
taskStore,
clientTransport,
serverTransport
};
}

87
test/helpers/oauth.ts Normal file
View file

@ -0,0 +1,87 @@
import type { FetchLike } from '../../src/shared/transport.js';
export interface MockOAuthFetchOptions {
resourceServerUrl: string;
authServerUrl: string;
/**
* Optional hook to inspect or override the token request.
*/
onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>;
}
/**
* Shared mock fetch implementation for OAuth flows used in client tests.
*
* It handles:
* - OAuth Protected Resource Metadata discovery
* - Authorization Server Metadata discovery
* - Token endpoint responses
*/
export function createMockOAuthFetch(options: MockOAuthFetchOptions): FetchLike {
const { resourceServerUrl, authServerUrl, onTokenRequest } = options;
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof URL ? input : new URL(input);
// Protected resource metadata discovery
if (url.origin === resourceServerUrl.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
return new Response(
JSON.stringify({
resource: resourceServerUrl,
authorization_servers: [authServerUrl]
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Authorization server metadata discovery
if (url.origin === authServerUrl && url.pathname === '/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: authServerUrl,
authorization_endpoint: `${authServerUrl}/authorize`,
token_endpoint: `${authServerUrl}/token`,
response_types_supported: ['code'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Token endpoint
if (url.origin === authServerUrl && url.pathname === '/token') {
if (onTokenRequest) {
await onTokenRequest(url, init);
}
return new Response(
JSON.stringify({
access_token: 'test-access-token',
token_type: 'Bearer'
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}
throw new Error(`Unexpected URL in mock OAuth fetch: ${url.toString()}`);
};
}
/**
* Helper to install a vi.fn-based global.fetch mock for tests that rely on global fetch.
*/
export function mockGlobalFetch() {
const mockFetch = vi.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = mockFetch;
return mockFetch;
}

33
test/helpers/tasks.ts Normal file
View file

@ -0,0 +1,33 @@
import type { Task } from '../../src/types.js';
/**
* Polls the provided getTask function until the task reaches the desired status or times out.
*/
export async function waitForTaskStatus(
getTask: (taskId: string) => Promise<Task | null | undefined>,
taskId: string,
desiredStatus: Task['status'],
{
intervalMs = 100,
timeoutMs = 10_000
}: {
intervalMs?: number;
timeoutMs?: number;
} = {}
): Promise<Task> {
const start = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
const task = await getTask(taskId);
if (task && task.status === desiredStatus) {
return task;
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for task ${taskId} to reach status ${desiredStatus}`);
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}

119
test/inMemory.test.ts Normal file
View file

@ -0,0 +1,119 @@
import { InMemoryTransport } from '../src/inMemory.js';
import { JSONRPCMessage } from '../src/types.js';
import { AuthInfo } from '../src/server/auth/types.js';
describe('InMemoryTransport', () => {
let clientTransport: InMemoryTransport;
let serverTransport: InMemoryTransport;
beforeEach(() => {
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
});
test('should create linked pair', () => {
expect(clientTransport).toBeDefined();
expect(serverTransport).toBeDefined();
});
test('should start without error', async () => {
await expect(clientTransport.start()).resolves.not.toThrow();
await expect(serverTransport.start()).resolves.not.toThrow();
});
test('should send message from client to server', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
serverTransport.onmessage = msg => {
receivedMessage = msg;
};
await clientTransport.send(message);
expect(receivedMessage).toEqual(message);
});
test('should send message with auth info from client to server', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
const authInfo: AuthInfo = {
token: 'test-token',
clientId: 'test-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
let receivedMessage: JSONRPCMessage | undefined;
let receivedAuthInfo: AuthInfo | undefined;
serverTransport.onmessage = (msg, extra) => {
receivedMessage = msg;
receivedAuthInfo = extra?.authInfo;
};
await clientTransport.send(message, { authInfo });
expect(receivedMessage).toEqual(message);
expect(receivedAuthInfo).toEqual(authInfo);
});
test('should send message from server to client', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
clientTransport.onmessage = msg => {
receivedMessage = msg;
};
await serverTransport.send(message);
expect(receivedMessage).toEqual(message);
});
test('should handle close', async () => {
let clientClosed = false;
let serverClosed = false;
clientTransport.onclose = () => {
clientClosed = true;
};
serverTransport.onclose = () => {
serverClosed = true;
};
await clientTransport.close();
expect(clientClosed).toBe(true);
expect(serverClosed).toBe(true);
});
test('should throw error when sending after close', async () => {
await clientTransport.close();
await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected');
});
test('should queue messages sent before start', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1
};
let receivedMessage: JSONRPCMessage | undefined;
serverTransport.onmessage = msg => {
receivedMessage = msg;
};
await clientTransport.send(message);
await serverTransport.start();
expect(receivedMessage).toEqual(message);
});
});

View file

@ -0,0 +1,113 @@
import path from 'node:path';
import { Readable, Writable } from 'node:stream';
import { Client } from '../../src/client/index.js';
import { StdioClientTransport } from '../../src/client/stdio.js';
import { Server } from '../../src/server/index.js';
import { StdioServerTransport } from '../../src/server/stdio.js';
import { LoggingMessageNotificationSchema } from '../../src/types.js';
const FIXTURES_DIR = path.resolve(__dirname, '../../src/__fixtures__');
describe('Process cleanup', () => {
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
it('server should exit cleanly after closing transport', async () => {
const server = new Server(
{
name: 'test-server',
version: '1.0.0'
},
{
capabilities: {}
}
);
const mockReadable = new Readable({
read() {
this.push(null); // signal EOF
}
}),
mockWritable = new Writable({
write(chunk, encoding, callback) {
callback();
}
});
// Attach mock streams to process for the server transport
const transport = new StdioServerTransport(mockReadable, mockWritable);
await server.connect(transport);
// Close the transport
await transport.close();
// ensure a proper disposal mock streams
mockReadable.destroy();
mockWritable.destroy();
// If we reach here without hanging, the test passes
// The test runner will fail if the process hangs
expect(true).toBe(true);
});
it('onclose should be called exactly once', async () => {
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StdioClientTransport({
command: 'node',
args: ['--import', 'tsx', 'testServer.ts'],
cwd: FIXTURES_DIR
});
await client.connect(transport);
let onCloseWasCalled = 0;
client.onclose = () => {
onCloseWasCalled++;
};
await client.close();
// A short delay to allow the close event to propagate
await new Promise(resolve => setTimeout(resolve, 50));
expect(onCloseWasCalled).toBe(1);
});
it('should exit cleanly for a server that hangs', async () => {
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StdioClientTransport({
command: 'node',
args: ['--import', 'tsx', 'serverThatHangs.ts'],
cwd: FIXTURES_DIR
});
await client.connect(transport);
await client.setLoggingLevel('debug');
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
console.debug('server log: ' + notification.params.data);
});
const serverPid = transport.pid!;
await client.close();
// A short delay to allow the close event to propagate
await new Promise(resolve => setTimeout(resolve, 50));
try {
process.kill(serverPid, 9);
throw new Error('Expected server to be dead but it is alive');
} catch (err: unknown) {
// 'ESRCH' the process doesn't exist
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
// success
} else throw err;
}
});
});

Some files were not shown because too many files have changed in this diff Show more