diff --git a/tests/__snapshots__/stdioTransport.test.ts.snap b/tests/__snapshots__/stdioTransport.test.ts.snap index cbfe25f..14f5208 100644 --- a/tests/__snapshots__/stdioTransport.test.ts.snap +++ b/tests/__snapshots__/stdioTransport.test.ts.snap @@ -1,13 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`External URLs should fetch a document 1`] = ` -[ - "# Example Fixture", - "This is a local offline fixture used by the MCP external URLs test.", - "It includes the token PatternFly to satisfy test assertions.", -] -`; - exports[`Hosted mode, --docs-host should read llms-files and includes expected tokens 1`] = ` [ "# @patternfly/react-core 6.0.0", @@ -289,19 +281,6 @@ exports[`Logging should allow setting logging options, stderr 1`] = ` ] `; -exports[`Logging should allow setting logging options, verbose 1`] = ` -[ - "[INFO]: Registered tool: usePatternFlyDocs -", - "[INFO]: Registered tool: fetchDocs -", - "[INFO]: Registered tool: componentSchemas -", - "[INFO]: PatternFly MCP server running on stdio -", -] -`; - exports[`Logging should allow setting logging options, with log level filtering 1`] = `[]`; exports[`Logging should allow setting logging options, with mcp protocol 1`] = ` @@ -425,6 +404,30 @@ You can find documentation on PatternFly's components at [PatternFly All compone " `; +exports[`PatternFly MCP, STDIO should concatenate headers and separator with two remote files 1`] = ` +"# Documentation from http://127.0.0.1:5010/notARealPath/README.md + +# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration + +--- + +# Documentation from http://127.0.0.1:5010/notARealPath/AboutModal.md + +# Test Document + +This is a test document for mocking remote HTTP requests." +`; + exports[`PatternFly MCP, STDIO should expose expected tools and stable shape 1`] = ` { "toolNames": [ diff --git a/tests/stdioTransport.test.ts b/tests/stdioTransport.test.ts index 23ca211..e06c977 100644 --- a/tests/stdioTransport.test.ts +++ b/tests/stdioTransport.test.ts @@ -1,21 +1,80 @@ /** * Requires: npm run build prior to running Jest. */ -import { startServer, type StdioTransportClient } from './utils/stdioTransportClient'; -import { loadFixture } from './utils/fixtures'; +import { + startServer, + type StdioTransportClient, + type RpcRequest +} from './utils/stdioTransportClient'; import { setupFetchMock } from './utils/fetchMock'; describe('PatternFly MCP, STDIO', () => { - let client: StdioTransportClient; + let FETCH_MOCK: Awaited> | undefined; + let CLIENT: StdioTransportClient; + // We're unable to mock fetch for stdio since it runs in a separate process, so we run a server and use that path for mocking external URLs. + let URL_MOCK: string; - beforeEach(async () => { - client = await startServer(); + beforeAll(async () => { + FETCH_MOCK = await setupFetchMock({ + port: 5010, + routes: [ + { + url: /\/README\.md$/, + // url: '/notARealPath/README.md', + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: `# PatternFly Development Rules + This is a generated offline fixture used by the MCP external URLs test. + + Essential rules and guidelines working with PatternFly applications. + + ## Quick Navigation + + ### 🚀 Setup & Environment + - **Setup Rules** - Project initialization requirements + - **Quick Start** - Essential setup steps + - **Environment Rules** - Development configuration` + }, + { + url: /.*\.md$/, + // url: '/notARealPath/AboutModal.md', + status: 200, + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + body: '# Test Document\n\nThis is a test document for mocking remote HTTP requests.' + } + ] + }); + + URL_MOCK = `${FETCH_MOCK?.fixture?.baseUrl}/`; + CLIENT = await startServer(); + }); + + afterAll(async () => { + if (CLIENT) { + // You may still receive jest warnings about a running process, but clean up case we forget at the test level. + await CLIENT.close(); + } + + if (FETCH_MOCK) { + await FETCH_MOCK.cleanup(); + } }); - afterEach(async () => client.stop()); + it('should expose expected tools and stable shape', async () => { + const response = await CLIENT.send({ + method: 'tools/list', + params: {} + }); + const tools = response?.result?.tools || []; + const toolNames = tools.map((tool: any) => tool.name).sort(); + + expect({ toolNames }).toMatchSnapshot(); + }); it('should concatenate headers and separator with two local files', async () => { const req = { + jsonrpc: '2.0', + id: 1, method: 'tools/call', params: { name: 'usePatternFlyDocs', @@ -26,33 +85,48 @@ describe('PatternFly MCP, STDIO', () => { ] } } - }; + } as RpcRequest; - const response = await client.send(req); + const response = await CLIENT?.send(req); const text = response?.result?.content?.[0]?.text || ''; expect(text.startsWith('# Documentation from')).toBe(true); expect(text).toMatchSnapshot(); }); - it('should expose expected tools and stable shape', async () => { - const response = await client.send({ method: 'tools/list' }); - const tools = response?.result?.tools || []; - const toolNames = tools.map((tool: any) => tool.name).sort(); + it('should concatenate headers and separator with two remote files', async () => { + const req = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'fetchDocs', + arguments: { + urlList: [ + // URL_MOCK + `${URL_MOCK}notARealPath/README.md`, + `${URL_MOCK}notARealPath/AboutModal.md` + ] + } + } + } as RpcRequest; - expect(toolNames).toEqual(expect.arrayContaining(['usePatternFlyDocs', 'fetchDocs'])); - expect({ toolNames }).toMatchSnapshot(); + const response = await CLIENT.send(req, { timeoutMs: 10000 }); + const text = response?.result?.content?.[0]?.text || ''; + + // expect(text.startsWith('# Documentation from')).toBe(true); + expect(text).toMatchSnapshot(); }); }); describe('Hosted mode, --docs-host', () => { - let client: StdioTransportClient; + let CLIENT: StdioTransportClient; beforeEach(async () => { - client = await startServer({ args: ['--docs-host'] }); + CLIENT = await startServer({ args: ['--docs-host'] }); }); - afterEach(async () => client.stop()); + afterEach(async () => CLIENT.stop()); it('should read llms-files and includes expected tokens', async () => { const req = { @@ -62,7 +136,7 @@ describe('Hosted mode, --docs-host', () => { arguments: { urlList: ['react-core/6.0.0/llms.txt'] } } }; - const resp = await client.send(req); + const resp = await CLIENT.send(req); const text = resp?.result?.content?.[0]?.text || ''; expect(text.startsWith('# Documentation from')).toBe(true); @@ -81,66 +155,20 @@ describe('Logging', () => { description: 'stderr', args: ['--log-stderr'] }, - { - description: 'verbose', - args: ['--log-stderr', '--verbose'] - }, { description: 'with log level filtering', args: ['--log-level', 'warn'] }, { description: 'with mcp protocol', - args: ['--verbose', '--log-protocol'] + args: ['--log-protocol'] } ])('should allow setting logging options, $description', async ({ args }) => { const serverArgs = [...args]; - const client = await startServer({ args: serverArgs }); - - expect(client.logs()).toMatchSnapshot(); - - await client.stop(); - }); -}); - -describe('External URLs', () => { - let fetchMock: Awaited> | undefined; - let url: string; - let client: StdioTransportClient; - - beforeEach(async () => { - client = await startServer(); - }); + const CLIENT = await startServer({ args: serverArgs }); - afterEach(async () => client.stop()); + expect(CLIENT.logs()).toMatchSnapshot(); - beforeAll(async () => { - // Note: The helper creates index-based paths based on routing (/0, /1, etc.), so we use /0 for the first route - fetchMock = await setupFetchMock({ - routes: [ - { - url: /\/readme$/, - status: 200, - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - body: loadFixture('README.md') - } - ] - }); - url = `${fetchMock.fixture.baseUrl}/0`; - }); - - afterAll(async () => fetchMock?.cleanup()); - - it('should fetch a document', async () => { - const req = { - method: 'tools/call', - params: { name: 'fetchDocs', arguments: { urlList: [url] } } - }; - const resp = await client.send(req, { timeoutMs: 10000 }); - const text = resp?.result?.content?.[0]?.text || ''; - - expect(text.startsWith('# Documentation from')).toBe(true); - expect(/patternfly/i.test(text)).toBe(true); - expect(text.split(/\n/g).filter(Boolean).splice(1)).toMatchSnapshot(); + await CLIENT.stop(); }); }); diff --git a/tests/utils/fetchMock.ts b/tests/utils/fetchMock.ts index 3367995..4ba1480 100644 --- a/tests/utils/fetchMock.ts +++ b/tests/utils/fetchMock.ts @@ -21,6 +21,7 @@ type RoutesMap = Record; interface StartHttpFixtureOptions { routes?: RoutesMap; address?: string; + port?: number; } /** @@ -31,15 +32,59 @@ interface StartHttpFixtureOptions { * @param options - HTTP fixture options * @param options.routes - Map of URL paths to route handlers * @param options.address - Server address to listen on (default: '127.0.0.1') + * @param options.port - Server port to listen on (default: 0, which means a random port) + * @param regexRoutes * @returns Promise that resolves with server baseUrl and close method */ const startHttpFixture = ( - { routes = {}, address = '127.0.0.1' }: StartHttpFixtureOptions = {} -): Promise<{ baseUrl: string; close: () => Promise }> => + { routes = {}, address = '127.0.0.1', port = 0 }: StartHttpFixtureOptions = {}, + regexRoutes: FetchRoute[] = [] +): Promise<{ baseUrl: string; close: () => Promise; addRoute?: (path: string, route: Route) => void }> => new Promise((resolve, reject) => { + const dynamicRoutes: Record = { ...routes }; + const server = http.createServer((req, res) => { const url = req.url || ''; - const route = routes[url]; + let route = dynamicRoutes[url]; + + // If route not found and we have regex routes, try to match them + if (!route && regexRoutes.length > 0) { + const pathname = url; + + // Try to match against regex routes + for (const regexRoute of regexRoutes) { + if (regexRoute.url instanceof RegExp) { + // Test regex against pathname + if (regexRoute.url.test(pathname) || regexRoute.url.test(`http://${address}${pathname}`)) { + // Register this route dynamically + route = { + status: regexRoute.status || 200, + headers: regexRoute.headers || { 'Content-Type': 'text/plain; charset=utf-8' }, + body: typeof regexRoute.body === 'string' || regexRoute.body instanceof Buffer + ? regexRoute.body + : '# Mocked Response' + }; + dynamicRoutes[pathname] = route; + break; + } + } else if (typeof regexRoute.url === 'string' && !regexRoute.url.startsWith('/')) { + // String pattern with wildcards + const pattern = regexRoute.url.replace(/\*/g, '.*'); + const regex = new RegExp(`^${pattern}$`); + if (regex.test(pathname) || regex.test(`http://${address}${pathname}`)) { + route = { + status: regexRoute.status || 200, + headers: regexRoute.headers || { 'Content-Type': 'text/plain; charset=utf-8' }, + body: typeof regexRoute.body === 'string' || regexRoute.body instanceof Buffer + ? regexRoute.body + : '# Mocked Response' + }; + dynamicRoutes[pathname] = route; + break; + } + } + } + } if (!route) { res.statusCode = 404; @@ -63,31 +108,45 @@ const startHttpFixture = ( return res.end(body as string | Buffer | Uint8Array | undefined); }); - server.listen(0, address, () => { + server.listen(port, address, () => { const addr = server.address(); if (addr && typeof addr !== 'string') { const host = addr.address === '::' ? address : addr.address; const baseUrl = `http://${host}:${addr.port}`; - resolve({ baseUrl, close: () => new Promise(res => server.close(() => res())) }); + resolve({ + baseUrl, + close: () => new Promise(res => server.close(() => res())), + addRoute: (path: string, route: Route) => { + dynamicRoutes[path] = route; + } + }); } else { // Fallback if the address isn't available as AddressInfo - resolve({ baseUrl: `http://${address}`, close: () => new Promise(res => server.close(() => res())) }); + resolve({ + baseUrl: `http://${address}`, + close: () => new Promise(res => server.close(() => res())), + addRoute: (path: string, route: Route) => { + dynamicRoutes[path] = route; + } + }); } }); server.on('error', reject); }); -type StartHttpFixtureResult = Awaited>; +type StartHttpFixtureResult = Awaited> & { + addRoute?: (path: string, route: Route) => void; +}; /** * Route configuration for fetch mocking */ export interface FetchRoute { - /** URL pattern to match (supports wildcards with *) */ + /** URL pattern to match (RegExp, string pattern with wildcards, or direct path starting with '/') */ url: string | RegExp; /** HTTP status code */ @@ -130,6 +189,9 @@ export interface FetchMockSetup { /** Fixture server address (default: '127.0.0.1') */ address?: string; + + /** Fixture server port (default: 0, which means a random port) */ + port?: number; } export interface FetchMockResult { @@ -156,39 +218,73 @@ export const setupFetchMock = async (options: FetchMockSetup = {}): Promise; body?: string | Buffer }> = {}; + const routeMap = new Map(); // Map routes to their index for reference + const regexRoutes: FetchRoute[] = []; // Track regex routes for dynamic registration routes.forEach((route, index) => { - // Use index-based path for fixture server, we'll match by URL pattern in the mock - const path = `/${index}`; - - fixtureRoutes[path] = { - status: route.status || 200, - headers: route.headers || { 'Content-Type': 'text/plain; charset=utf-8' }, - body: typeof route.body === 'string' || route.body instanceof Buffer - ? route.body - : '# Mocked Response' - }; + routeMap.set(route, index); + + // If url is a string starting with '/', use it directly as the fixture server path + if (typeof route.url === 'string' && route.url.startsWith('/')) { + const normalizedPath = route.url.startsWith('/') ? route.url : `/${route.url}`; + + fixtureRoutes[normalizedPath] = { + status: route.status || 200, + headers: route.headers || { 'Content-Type': 'text/plain; charset=utf-8' }, + body: typeof route.body === 'string' || route.body instanceof Buffer + ? route.body + : '# Mocked Response' + }; + } else { + // For regex/pattern routes, track them for dynamic registration + regexRoutes.push(route); + } }); - // Start fixture server - const fixture = await startHttpFixture({ routes: fixtureRoutes, address }); + // Start fixture server with regex routes for dynamic matching + const fixtureOptions: StartHttpFixtureOptions = { routes: fixtureRoutes, address }; + if (port) { + fixtureOptions.port = port; + } + const fixture = await startHttpFixture(fixtureOptions, regexRoutes); // Create URL pattern matcher - const matchRoute = (url: string): FetchRoute | undefined => routes.find(route => { - if (route.url instanceof RegExp) { - return route.url.test(url); + const matchRoute = (url: string): FetchRoute | undefined => { + // Extract pathname from URL for matching + let pathname: string; + try { + const urlObj = new URL(url); + pathname = urlObj.pathname; + } catch { + // If URL parsing fails, try to extract pathname manually + const match = url.match(/^https?:\/\/[^/]+(\/.*)$/); + pathname = match && match[1] ? match[1] : url; } - // Support wildcards - const pattern = route.url.replace(/\*/g, '.*'); - const regex = new RegExp(`^${pattern}$`); - return regex.test(url); - }); + return routes.find(route => { + if (route.url instanceof RegExp) { + // Test regex against both full URL and pathname for flexibility + return route.url.test(url) || route.url.test(pathname); + } + // If url is a direct path (starts with '/'), compare pathnames + if (route.url.startsWith('/')) { + return pathname === route.url; + } + // Support wildcards for pattern matching (test against full URL) + const pattern = route.url.replace(/\*/g, '.*'); + const regex = new RegExp(`^${pattern}$`); + + return regex.test(url); + }); + }; // Set up fetch mock const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { @@ -202,10 +298,51 @@ export const setupFetchMock = async (options: FetchMockSetup = {}): Promise