Skip to main content
Glama
download-artifact-streaming.test.ts15.3 kB
import { promises as fs } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from '@jest/globals'; import type { ActionResult, BuildRef, TriggerBuildResult } from '../types/tool-results'; import { callTool, callToolsBatch } from './lib/mcp-runner'; const SERIAL_WORKER = process.env['JEST_WORKER_ID'] === '1' || process.env['SERIAL_BUILD_TESTS'] === 'true'; const serialDescribe = SERIAL_WORKER ? describe : describe.skip; const hasTeamCityEnv = Boolean( (process.env['TEAMCITY_URL'] ?? process.env['TEAMCITY_SERVER_URL']) && (process.env['TEAMCITY_TOKEN'] ?? process.env['TEAMCITY_API_TOKEN']) ); const ts = Date.now(); const PROJECT_ID = `E2E_ARTIFACT_${ts}`; const PROJECT_NAME = `E2E Artifact ${ts}`; const BT_ID = `E2E_ARTIFACT_BT_${ts}`; const BT_NAME = `E2E Artifact BuildType ${ts}`; let buildId: string | undefined; let multiBuildId: string | undefined; let multiArtifactRequests: Array<{ path: string; downloadUrl?: string }> | undefined; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); interface DownloadArtifactResponse { name?: string; path?: string; size?: number; mimeType?: string; encoding?: string; content?: string; outputPath?: string; bytesWritten?: number; success?: boolean; error?: { message?: string } | string; } interface BatchArtifactItem { name?: string; path?: string; size?: number; mimeType?: string; encoding?: string; content?: string; outputPath?: string; bytesWritten?: number; success?: boolean; error?: { message?: string } | string; } interface DownloadArtifactsResponse { artifacts?: BatchArtifactItem[]; success?: boolean; error?: { message?: string } | string; } async function waitForBuildCompletion(id: string, timeoutMs = 60_000): Promise<void> { const deadline = Date.now() + timeoutMs; let promoted = false; while (true) { if (Date.now() > deadline) { throw new Error(`Timed out waiting for build ${id} to finish`); } let status: { state?: string; status?: string } | null = null; try { // eslint-disable-next-line no-await-in-loop status = await callTool<{ state?: string; status?: string }>('dev', 'get_build_status', { buildId: id, includeProblems: true, includeTests: false, }); } catch (error) { // Allow transient errors (e.g., build yet to be registered) before timing out. console.warn(`Polling build status failed: ${error}`); } const state = String(status?.state ?? ''); if (state === 'finished') { const outcome = String(status?.status ?? 'UNKNOWN'); if (outcome !== 'SUCCESS') { throw new Error(`Build ${id} finished with status ${outcome}`); } return; } if (state === 'queued' && !promoted) { try { // Queue promotion must remain sequential to respect TeamCity API semantics // eslint-disable-next-line no-await-in-loop await callTool('full', 'move_queued_build_to_top', { buildId: id }); } catch (error) { // Non-fatal: servers may restrict queue operations or build may have started already. console.warn(`move_queued_build_to_top failed (non-fatal): ${error}`); } finally { promoted = true; } } // eslint-disable-next-line no-await-in-loop await wait(2_000); } } serialDescribe('download_build_artifact tool (integration)', () => { afterAll(async () => { if (!hasTeamCityEnv) return; try { await callTool('full', 'delete_project', { projectId: PROJECT_ID }); } catch (_err) { /* swallow cleanup errors */ } }); it('creates project and build configuration (full)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); const artifactStepScript = [ 'echo artifact-content > artifact.txt', 'echo artifact-extra > artifact-extra.txt', 'echo "##teamcity[publishArtifacts \'artifact.txt\']"', 'echo "##teamcity[publishArtifacts \'artifact-extra.txt\']"', ].join('\n'); const batch = await callToolsBatch('full', [ { tool: 'create_project', args: { id: PROJECT_ID, name: PROJECT_NAME, }, }, { tool: 'create_build_config', args: { projectId: PROJECT_ID, id: BT_ID, name: BT_NAME, description: 'Integration scenario for artifact downloads', }, }, { tool: 'manage_build_steps', args: { buildTypeId: BT_ID, action: 'add', name: 'create-artifact', type: 'simpleRunner', properties: { 'script.content': artifactStepScript, 'use.custom.script': 'true', }, }, }, ]); expect(batch.completed).toBe(true); expect(batch.results).toHaveLength(3); const [projectStep, buildConfigStep, stepStep] = batch.results; const project = projectStep?.result as ActionResult | undefined; const buildConfig = buildConfigStep?.result as ActionResult | undefined; const step = stepStep?.result as ActionResult | undefined; expect(projectStep?.ok).toBe(true); expect(project).toMatchObject({ success: true, action: 'create_project' }); expect(buildConfigStep?.ok).toBe(true); expect(buildConfig).toMatchObject({ success: true, action: 'create_build_config' }); expect(stepStep?.ok).toBe(true); expect(step).toMatchObject({ success: true, action: 'add_build_step' }); try { const update = await callTool<ActionResult>('full', 'update_build_config', { buildTypeId: BT_ID, artifactRules: '*.txt', }); expect(update).toMatchObject({ success: true }); } catch (_err) { // Some TeamCity servers restrict artifactRules updates; proceed if it fails. expect(true).toBe(true); } }, 60_000); it('runs build and waits for completion', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); const trigger = await callTool<TriggerBuildResult>('dev', 'trigger_build', { buildTypeId: BT_ID, comment: 'integration-download-artifact', }); expect(trigger).toMatchObject({ success: true, action: 'trigger_build' }); buildId = trigger.buildId; expect(typeof buildId).toBe('string'); if (!buildId) return expect(true).toBe(true); try { await callTool('full', 'move_queued_build_to_top', { buildId }); } catch (error) { // Non-fatal: queue manipulation may not be permitted or build already running. console.warn(`Initial move_queued_build_to_top failed (non-fatal): ${error}`); } // Ensure build number is fetched (also waits briefly before polling status) const buildRef = await callTool<BuildRef>('dev', 'get_build', { buildId }); expect(buildRef.id ?? buildRef.number ?? buildId).toBeDefined(); await waitForBuildCompletion(buildId, 60_000); }, 120_000); it('downloads artifact as base64 payload (dev)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); if (!buildId) return expect(true).toBe(true); const result = await callTool<DownloadArtifactResponse>('dev', 'download_build_artifact', { buildId, artifactPath: 'artifact.txt', encoding: 'base64', }); if (result.success === false) { const message = typeof result.error === 'object' && result.error?.message ? String(result.error.message) : String(result.error ?? ''); if (message.includes('Artifact not found') || message.includes('Failed to fetch artifacts')) { expect(true).toBe(true); return; } throw new Error(`download_build_artifact (base64) failed: ${message}`); } expect(result.encoding).toBe('base64'); expect(result.path).toBe('artifact.txt'); const content = String(result.content ?? ''); const decoded = Buffer.from(content, 'base64').toString('utf8').trim(); expect(decoded).toBe('artifact-content'); console.log('single artifact path', result.path); }, 60_000); it('downloads secondary artifact as base64 payload (dev)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); if (!buildId) return expect(true).toBe(true); const result = await callTool<DownloadArtifactResponse>('dev', 'download_build_artifact', { buildId, artifactPath: 'artifact-extra.txt', encoding: 'base64', }); if (result.success === false) { const message = typeof result.error === 'object' && result.error?.message ? String(result.error.message) : String(result.error ?? ''); if (message.includes('Artifact not found') || message.includes('Failed to fetch artifacts')) { expect(true).toBe(true); return; } throw new Error(`download_build_artifact (base64 extra) failed: ${message}`); } expect(result.encoding).toBe('base64'); const content = String(result.content ?? ''); const decoded = Buffer.from(content, 'base64').toString('utf8').trim(); expect(decoded).toBe('artifact-extra'); }, 60_000); it('downloads multiple artifacts as base64 payloads (dev)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); if (!buildId) return expect(true).toBe(true); multiBuildId = buildId; multiArtifactRequests = undefined; await wait(3000); const result = await callTool<DownloadArtifactsResponse>('dev', 'download_build_artifacts', { buildId, artifactPaths: ['artifact.txt', 'artifact-extra.txt'], encoding: 'base64', }); if (result.success === false) { const message = typeof result.error === 'object' && result.error?.message ? String(result.error.message) : String(result.error ?? ''); if (message.includes('Artifact not found') || message.includes('Failed to fetch artifacts')) { expect(true).toBe(true); return; } throw new Error(`download_build_artifacts (base64) failed: ${message}`); } const artifacts = result.artifacts ?? []; expect(artifacts.length).toBeGreaterThanOrEqual(2); const first = artifacts.find((entry) => entry?.path === 'artifact.txt'); const second = artifacts.find((entry) => entry?.path === 'artifact-extra.txt'); if (!first || !second || first.success === false || second.success === false) { const summary = (result.artifacts ?? []).map((entry) => ({ path: entry?.path, success: entry?.success, error: entry?.error, })); throw new Error(`download_build_artifacts (base64) entries: ${JSON.stringify(summary)}`); } expect(first?.encoding).toBe('base64'); expect( Buffer.from(String(first?.content ?? ''), 'base64') .toString('utf8') .trim() ).toBe('artifact-content'); expect(second?.encoding).toBe('base64'); expect( Buffer.from(String(second?.content ?? ''), 'base64') .toString('utf8') .trim() ).toBe('artifact-extra'); }, 60_000); it('streams artifact to disk (dev)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); if (!buildId) return expect(true).toBe(true); const outputPath = join(tmpdir(), `artifact-download-${Date.now()}.txt`); const result = await callTool<DownloadArtifactResponse>('dev', 'download_build_artifact', { buildId, artifactPath: 'artifact.txt', encoding: 'stream', outputPath, }); if (result.success === false) { const message = typeof result.error === 'object' && result.error?.message ? String(result.error.message) : String(result.error ?? ''); if (message.includes('Artifact not found') || message.includes('Failed to fetch artifacts')) { await fs.rm(outputPath, { force: true }); expect(true).toBe(true); return; } await fs.rm(outputPath, { force: true }); throw new Error(`download_build_artifact (stream) failed: ${message}`); } expect(result.encoding).toBe('stream'); expect(result.outputPath).toBe(outputPath); const written = await fs.readFile(outputPath, 'utf8'); expect(written.trim()).toBe('artifact-content'); await fs.rm(outputPath, { force: true }); }, 60_000); it('streams multiple artifacts to disk (dev)', async () => { if (!hasTeamCityEnv) return expect(true).toBe(true); const targetBuildId = multiBuildId ?? buildId; if (!targetBuildId) return expect(true).toBe(true); const requests = multiArtifactRequests ?? ['artifact.txt', 'artifact-extra.txt']; const outputDir = join(tmpdir(), `artifact-batch-${Date.now()}`); await fs.mkdir(outputDir, { recursive: true }); let result: DownloadArtifactsResponse; try { await wait(3000); result = await callTool<DownloadArtifactsResponse>('dev', 'download_build_artifacts', { buildId: targetBuildId, artifactPaths: requests, encoding: 'stream', outputDir, }); } catch (error) { await fs.rm(outputDir, { recursive: true, force: true }); throw error; } if (result.success === false) { const message = typeof result.error === 'object' && result.error?.message ? String(result.error.message) : String(result.error ?? ''); if (message.includes('Artifact not found') || message.includes('Failed to fetch artifacts')) { await fs.rm(outputDir, { recursive: true, force: true }); expect(true).toBe(true); return; } await fs.rm(outputDir, { recursive: true, force: true }); throw new Error(`download_build_artifacts (stream) failed: ${message}`); } const artifacts = result.artifacts ?? []; expect(artifacts.length).toBeGreaterThanOrEqual(2); const first = artifacts.find((entry) => entry?.path === 'artifact.txt'); const second = artifacts.find((entry) => entry?.path === 'artifact-extra.txt'); if (!first || !second || first.success === false || second.success === false) { const summary = (result.artifacts ?? []).map((entry) => ({ path: entry?.path, success: entry?.success, error: entry?.error, })); await fs.rm(outputDir, { recursive: true, force: true }); throw new Error(`download_build_artifacts (stream) entries: ${JSON.stringify(summary)}`); } if (!first.outputPath || !second.outputPath) { await fs.rm(outputDir, { recursive: true, force: true }); throw new Error('Expected streamed artifacts to include output paths'); } expect(first.encoding).toBe('stream'); expect(second.encoding).toBe('stream'); expect(first.outputPath.startsWith(outputDir)).toBe(true); expect(second.outputPath.startsWith(outputDir)).toBe(true); const firstContent = await fs.readFile(first.outputPath, 'utf8'); const secondContent = await fs.readFile(second.outputPath, 'utf8'); expect(firstContent.trim()).toBe('artifact-content'); expect(secondContent.trim()).toBe('artifact-extra'); await fs.rm(outputDir, { recursive: true, force: true }); }, 60_000); });

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