Skip to main content
Glama
download-artifact.test.ts26.1 kB
import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; // Mock config to keep tools in dev mode without reading env jest.mock('@/config', () => ({ getTeamCityUrl: () => 'https://example.test', getTeamCityToken: () => 'token', getMCPMode: () => 'dev', })); jest.mock('@/utils/logger/index', () => { const debug = jest.fn(); const info = jest.fn(); const warn = jest.fn(); const error = jest.fn(); const logToolExecution = jest.fn(); const logTeamCityRequest = jest.fn(); const logLifecycle = jest.fn(); const child = jest.fn(); const mockLoggerInstance = { debug, info, warn, error, logToolExecution, logTeamCityRequest, logLifecycle, child, generateRequestId: () => 'test-request', }; child.mockReturnValue(mockLoggerInstance); return { getLogger: () => mockLoggerInstance, logger: mockLoggerInstance, debug, info, warn, error, }; }); type ToolHandler = (args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean; }>; describe('tools: download_build_artifact', () => { afterEach(() => { jest.resetModules(); jest.clearAllMocks(); }); it('returns base64 content when requested encoding is base64', async () => { const downloadArtifact = jest.fn().mockResolvedValue({ name: 'artifact.bin', path: 'artifact.bin', size: 12, content: Buffer.from('hello world!').toString('base64'), mimeType: 'application/octet-stream', }); const ArtifactManager = jest.fn().mockImplementation(() => ({ downloadArtifact })); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue({}); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: | ((args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean }>) | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifact').handler; }); if (!handler) { throw new Error('download_build_artifact handler not found'); } const response = await handler({ buildId: '123', artifactPath: 'artifact.bin', encoding: 'base64', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.encoding).toBe('base64'); expect(payload.content).toBe(Buffer.from('hello world!').toString('base64')); expect(downloadArtifact).toHaveBeenCalledWith('123', 'artifact.bin', { encoding: 'base64', maxSize: undefined, }); }); it('streams artifact content to the requested output path', async () => { const chunks = ['hello', ' ', 'mcp']; const stream = Readable.from(chunks); const downloadArtifact = jest.fn().mockResolvedValue({ name: 'logs/build.log', path: 'logs/build.log', size: 11, content: stream, mimeType: 'text/plain', }); const ArtifactManager = jest.fn().mockImplementation(() => ({ downloadArtifact })); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue({}); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); const targetPath = join(tmpdir(), `artifact-stream-${Date.now()}.log`); let handler: | ((args: unknown) => Promise<{ content?: Array<{ text?: string }>; success?: boolean }>) | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifact').handler; }); if (!handler) { throw new Error('download_build_artifact handler not found'); } const response = await handler({ buildId: '456', artifactPath: 'logs/build.log', encoding: 'stream', outputPath: targetPath, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.encoding).toBe('stream'); expect(payload.outputPath).toBe(targetPath); expect(downloadArtifact).toHaveBeenCalledWith('456', 'logs/build.log', { encoding: 'stream', maxSize: undefined, }); const written = await fs.readFile(targetPath, 'utf8'); expect(written).toBe(chunks.join('')); await fs.rm(targetPath, { force: true }); }); }); describe('tools: downloadArtifactByUrl branch coverage via download_build_artifacts', () => { afterEach(() => { jest.resetModules(); jest.clearAllMocks(); }); describe('text encoding', () => { it('returns text content when axios returns string data', async () => { const mockGet = jest.fn().mockResolvedValue({ data: 'hello world text', headers: { 'content-type': 'text/plain', 'content-length': '16', }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'file.txt', buildId: '123', downloadUrl: 'https://tc.example/artifact.txt' }, ], encoding: 'text', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[0].encoding).toBe('text'); expect(payload.artifacts[0].content).toBe('hello world text'); expect(mockGet).toHaveBeenCalledWith('https://tc.example/artifact.txt', { responseType: 'text', }); }); it('throws when text encoding receives non-string data', async () => { const mockGet = jest .fn() .mockResolvedValueOnce({ data: 'good content', headers: { 'content-type': 'text/plain', 'content-length': '12' }, }) .mockResolvedValueOnce({ data: Buffer.from('not a string'), headers: { 'content-type': 'application/octet-stream' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'good.txt', buildId: '123', downloadUrl: 'https://tc.example/good.txt' }, { path: 'binary.bin', buildId: '123', downloadUrl: 'https://tc.example/binary.bin' }, ], encoding: 'text', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('non-text payload'); }); }); describe('base64 encoding with different payload types', () => { it('handles Buffer payload', async () => { const mockGet = jest.fn().mockResolvedValue({ data: Buffer.from('buffer data'), headers: { 'content-type': 'application/octet-stream', 'content-length': '11' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'file.bin', buildId: '123', downloadUrl: 'https://tc.example/artifact.bin' }, ], encoding: 'base64', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[0].encoding).toBe('base64'); expect(payload.artifacts[0].content).toBe(Buffer.from('buffer data').toString('base64')); }); it('handles ArrayBuffer payload', async () => { const arrayBuffer = new ArrayBuffer(8); const view = new Uint8Array(arrayBuffer); view.set([1, 2, 3, 4, 5, 6, 7, 8]); const mockGet = jest.fn().mockResolvedValue({ data: arrayBuffer, headers: { 'content-type': 'application/octet-stream' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'file.bin', buildId: '123', downloadUrl: 'https://tc.example/artifact.bin' }, ], encoding: 'base64', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[0].encoding).toBe('base64'); expect(Buffer.from(payload.artifacts[0].content, 'base64')).toEqual( Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]) ); }); it('handles ArrayBufferView (Uint8Array) payload', async () => { const uint8 = new Uint8Array([10, 20, 30, 40, 50]); const mockGet = jest.fn().mockResolvedValue({ data: uint8, headers: { 'content-type': 'application/octet-stream' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'file.bin', buildId: '123', downloadUrl: 'https://tc.example/artifact.bin' }, ], encoding: 'base64', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[0].encoding).toBe('base64'); }); it('throws on unexpected binary payload type', async () => { const mockGet = jest .fn() .mockResolvedValueOnce({ data: Buffer.from('good'), headers: { 'content-type': 'application/octet-stream' }, }) .mockResolvedValueOnce({ data: { not: 'a buffer' }, headers: { 'content-type': 'application/octet-stream' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'good.bin', buildId: '123', downloadUrl: 'https://tc.example/good.bin' }, { path: 'bad.bin', buildId: '123', downloadUrl: 'https://tc.example/bad.bin' }, ], encoding: 'base64', }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('unexpected binary payload'); }); }); describe('maxSize validation', () => { it('throws when content-length exceeds maxSize', async () => { const mockGet = jest .fn() .mockResolvedValueOnce({ data: 'ok', headers: { 'content-type': 'text/plain', 'content-length': '2' }, }) .mockResolvedValueOnce({ data: 'this will be rejected', headers: { 'content-type': 'text/plain', 'content-length': '1000' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'small.txt', buildId: '123', downloadUrl: 'https://tc.example/small.txt' }, { path: 'large.txt', buildId: '123', downloadUrl: 'https://tc.example/large.txt' }, ], encoding: 'text', maxSize: 100, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('exceeds maximum allowed size'); expect(payload.artifacts[1].error).toContain('1000'); }); it('throws when text size exceeds maxSize', async () => { const largeText = 'x'.repeat(200); const mockGet = jest .fn() .mockResolvedValueOnce({ data: 'ok', headers: { 'content-type': 'text/plain' }, }) .mockResolvedValueOnce({ data: largeText, headers: { 'content-type': 'text/plain' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'small.txt', buildId: '123', downloadUrl: 'https://tc.example/small.txt' }, { path: 'large.txt', buildId: '123', downloadUrl: 'https://tc.example/large.txt' }, ], encoding: 'text', maxSize: 100, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('exceeds maximum allowed size'); }); it('throws when buffer size exceeds maxSize', async () => { const largeBuffer = Buffer.alloc(200); const mockGet = jest .fn() .mockResolvedValueOnce({ data: Buffer.from('ok'), headers: { 'content-type': 'application/octet-stream' }, }) .mockResolvedValueOnce({ data: largeBuffer, headers: { 'content-type': 'application/octet-stream' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const response = await handler({ buildId: '123', artifactPaths: [ { path: 'small.bin', buildId: '123', downloadUrl: 'https://tc.example/small.bin' }, { path: 'large.bin', buildId: '123', downloadUrl: 'https://tc.example/large.bin' }, ], encoding: 'base64', maxSize: 100, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('exceeds maximum allowed size'); }); }); describe('stream encoding', () => { it('throws when stream encoding receives non-stream data', async () => { const mockGet = jest .fn() .mockResolvedValueOnce({ data: Readable.from(['stream', 'data']), headers: { 'content-type': 'application/octet-stream', 'content-length': '10' }, }) .mockResolvedValueOnce({ data: 'not a stream', headers: { 'content-type': 'text/plain' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const targetDir = join(tmpdir(), `artifact-stream-test-${Date.now()}`); await fs.mkdir(targetDir, { recursive: true }); try { const response = await handler({ buildId: '123', artifactPaths: [ { path: 'good-stream.bin', buildId: '123', downloadUrl: 'https://tc.example/good-stream.bin', }, { path: 'not-stream.txt', buildId: '123', downloadUrl: 'https://tc.example/not-stream.txt', }, ], encoding: 'stream', outputDir: targetDir, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[1].success).toBe(false); expect(payload.artifacts[1].error).toContain('readable stream'); } finally { await fs.rm(targetDir, { recursive: true, force: true }); } }); it('streams artifact to disk via downloadUrl', async () => { const streamData = Readable.from(['hello', ' ', 'from', ' ', 'url']); const mockGet = jest.fn().mockResolvedValue({ data: streamData, headers: { 'content-type': 'application/octet-stream', 'content-length': '15' }, }); const mockAdapter = { getAxios: () => ({ get: mockGet }) }; const ArtifactManager = jest.fn().mockImplementation(() => ({})); const createAdapterFromTeamCityAPI = jest.fn().mockReturnValue(mockAdapter); const getInstance = jest.fn().mockReturnValue({}); jest.doMock('@/teamcity/artifact-manager', () => ({ ArtifactManager })); jest.doMock('@/teamcity/client-adapter', () => ({ createAdapterFromTeamCityAPI })); jest.doMock('@/api-client', () => ({ TeamCityAPI: { getInstance } })); let handler: ToolHandler | undefined; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getRequiredTool } = require('@/tools'); handler = getRequiredTool('download_build_artifacts').handler; }); if (!handler) throw new Error('handler not found'); const targetDir = join(tmpdir(), `artifact-stream-url-${Date.now()}`); await fs.mkdir(targetDir, { recursive: true }); try { const response = await handler({ buildId: '123', artifactPaths: [ { path: 'streamed.bin', buildId: '123', downloadUrl: 'https://tc.example/streamed.bin', }, ], encoding: 'stream', outputDir: targetDir, }); const payload = JSON.parse(response.content?.[0]?.text ?? '{}'); expect(payload.artifacts[0].success).toBe(true); expect(payload.artifacts[0].encoding).toBe('stream'); expect(payload.artifacts[0].outputPath).toBeDefined(); const written = await fs.readFile(payload.artifacts[0].outputPath, 'utf8'); expect(written).toBe('hello from url'); } finally { await fs.rm(targetDir, { recursive: true, force: true }); } }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Daghis/teamcity-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server