Skip to main content
Glama
reporting-client.ts28.7 kB
import axios, { AxiosInstance, AxiosError } from "axios"; import { ZebrunnerReportingConfig, AuthTokenResponse, AuthTokenResponseSchema, LaunchResponse, LaunchResponseSchema, ProjectResponse, ProjectResponseSchema, TestSessionsResponse, TestSessionsResponseSchema, TestRunsResponse, TestRunsResponseSchema, TestExecutionHistoryResponse, TestExecutionHistoryResponseSchema, MilestonesResponse, MilestonesResponseSchema, AvailableProjectsResponse, AvailableProjectsResponseSchema, ProjectsLimitResponse, ProjectsLimitResponseSchema, LaunchesResponse, LaunchesResponseSchema, LogsAndScreenshotsResponse, LogsAndScreenshotsResponseSchema, JiraIntegrationsResponse, JiraIntegrationsResponseSchema, ZebrunnerReportingError, ZebrunnerReportingAuthError, ZebrunnerReportingNotFoundError } from "../types/reporting.js"; import { maskToken, maskAuthHeader, validateFileUrl } from "../utils/security.js"; /** * Zebrunner Reporting API Client * * Uses access token authentication with bearer token exchange * Separate from the TCM Public API client */ export class ZebrunnerReportingClient { private http: AxiosInstance; private config: ZebrunnerReportingConfig; private bearerToken: string | null = null; private tokenExpiresAt: Date | null = null; private projectCache: Map<string, { project: ProjectResponse, timestamp: number }> = new Map(); private jiraBaseUrlCache: string | null = null; // Cached JIRA base URL for session constructor(config: ZebrunnerReportingConfig) { this.config = { timeout: 30_000, debug: false, ...config }; // Initialize HTTP client for base URL without /api prefix const baseURL = this.config.baseUrl.replace(/\/+$/, ""); this.http = axios.create({ baseURL, timeout: this.config.timeout, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); this.setupInterceptors(); } private setupInterceptors(): void { // Request interceptor for logging this.http.interceptors.request.use( (config) => { if (this.config.debug) { console.log(`[ZebrunnerReportingClient] ${config.method?.toUpperCase()} ${config.url}`); // Mask Authorization header if present if (config.headers?.Authorization) { const maskedHeader = maskAuthHeader(config.headers.Authorization as string); console.log('[ZebrunnerReportingClient] Authorization:', maskedHeader); } if (config.data) { console.log('[ZebrunnerReportingClient] Request data:', config.data); } } return config; }, (error) => { if (this.config.debug) { console.error('[ZebrunnerReportingClient] Request error:', error); } return Promise.reject(error); } ); // Response interceptor for error handling this.http.interceptors.response.use( (response) => { if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Response ${response.status}:`, response.data); } return response; }, (error: AxiosError) => { const enhancedError = this.enhanceError(error); if (this.config.debug) { console.error('[ZebrunnerReportingClient] Response error:', enhancedError); } return Promise.reject(enhancedError); } ); } private enhanceError(error: AxiosError): ZebrunnerReportingError { const status = error.response?.status; const responseData = error.response?.data as any; const message = responseData?.message || error.message; if (status === 401) { return new ZebrunnerReportingAuthError( `Authentication failed: ${message}`, status ); } if (status === 404) { return new ZebrunnerReportingNotFoundError( `Resource not found: ${message}`, status ); } return new ZebrunnerReportingError( `API error: ${message}`, status, error.response?.data ); } /** * Exchange access token for short-living bearer token */ async authenticate(): Promise<string> { try { const response = await this.http.post('/api/iam/v1/auth/refresh', { refreshToken: this.config.accessToken }); const authData = AuthTokenResponseSchema.parse(response.data); this.bearerToken = authData.authToken; // Set expiration time (default to 1 hour if not provided) const expiresInMs = (authData.expiresIn || 3600) * 1000; this.tokenExpiresAt = new Date(Date.now() + expiresInMs); if (this.config.debug) { const maskedToken = maskToken(this.bearerToken); console.log(`[ZebrunnerReportingClient] Authentication successful, token: ${maskedToken}, expires at:`, this.tokenExpiresAt); } return this.bearerToken; } catch (error) { if (this.config.debug) { console.error('[ZebrunnerReportingClient] Authentication failed:', error); } throw error; } } /** * Get valid bearer token, refreshing if necessary */ private async getBearerToken(): Promise<string> { // Check if we have a valid token if (this.bearerToken && this.tokenExpiresAt && this.tokenExpiresAt > new Date()) { return this.bearerToken; } // Token is missing or expired, authenticate return await this.authenticate(); } /** * Make authenticated request to reporting API */ private async makeAuthenticatedRequest<T>( method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, data?: any ): Promise<T> { const bearerToken = await this.getBearerToken(); const config = { method, url, headers: { 'Authorization': `Bearer ${bearerToken}` }, ...(data && { data }) }; const response = await this.http.request(config); return response.data; } /** * Get launch details by ID */ async getLaunch(launchId: number, projectId: number): Promise<LaunchResponse> { const url = `/api/reporting/v1/launches/${launchId}?projectId=${projectId}`; const response = await this.makeAuthenticatedRequest<any>('GET', url); // Extract the actual launch data from the nested response const launchData = response.data || response; return LaunchResponseSchema.parse(launchData); } /** * Test connection to the reporting API */ async testConnection(): Promise<{ success: boolean; message: string; details?: any }> { try { const bearerToken = await this.authenticate(); return { success: true, message: 'Connection successful to Zebrunner Reporting API', details: { baseUrl: this.config.baseUrl, tokenLength: bearerToken.length, expiresAt: this.tokenExpiresAt } }; } catch (error) { return { success: false, message: `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, details: { error } }; } } /** * Get project by key */ async getProject(projectKey: string): Promise<ProjectResponse> { // Check cache first (cache for 5 minutes) const cached = this.projectCache.get(projectKey); if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) { return cached.project; } const url = `/api/projects/v1/projects/${projectKey}`; const response = await this.makeAuthenticatedRequest<any>('GET', url); // Extract the actual project data from the nested response const projectData = response.data || response; // Debug: Log the actual data being parsed if (process.env.DEBUG === 'true') { console.error('Project data being parsed:', JSON.stringify(projectData, null, 2)); } let project; try { project = ProjectResponseSchema.parse(projectData); } catch (error) { if (process.env.DEBUG === 'true') { console.error('ProjectResponseSchema validation failed:', error); console.error('Raw projectData:', projectData); } throw new ZebrunnerReportingError(`Failed to parse project data for ${projectKey}: ${error instanceof Error ? error.message : error}`); } // Cache the result this.projectCache.set(projectKey, { project, timestamp: Date.now() }); return project; } /** * Get project ID by key (convenience method) */ async getProjectId(projectKey: string): Promise<number> { const project = await this.getProject(projectKey); return project.id; } /** * Get project key by ID */ async getProjectKey(projectId: number): Promise<string> { // Check if we have it in cache (reverse lookup) for (const [key, cached] of this.projectCache.entries()) { if (cached.project.id === projectId && Date.now() - cached.timestamp < 5 * 60 * 1000) { return cached.project.key; } } // If not in cache, fetch all available projects and find the one const projects = await this.getAvailableProjects(); const project = projects.items.find(p => p.id === projectId); if (!project) { throw new ZebrunnerReportingError(`Project with ID ${projectId} not found`); } // Cache it for future use const fullProject = await this.getProject(project.key); return fullProject.key; } /** * Get test sessions for a launch */ async getTestSessions(launchId: number, projectId: number): Promise<TestSessionsResponse> { const url = `/api/reporting/v1/launches/${launchId}/test-sessions?projectId=${projectId}`; const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const sessionsData = response.data || response; return TestSessionsResponseSchema.parse(sessionsData); } /** * Get test runs (test executions) for a launch */ async getTestRuns( launchId: number, projectId: number, options: { page?: number; pageSize?: number; } = {} ): Promise<TestRunsResponse> { const { page = 1, pageSize = 50 } = options; const url = `/api/reporting/v1/launches/${launchId}/tests?projectId=${projectId}&page=${page}&pageSize=${pageSize}`; const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const runsData = response.data || response; return TestRunsResponseSchema.parse(runsData); } /** * Get ALL test runs for a launch (auto-paginate through all pages) */ async getAllTestRuns( launchId: number, projectId: number, pageSize: number = 100 ): Promise<TestRunsResponse> { const allItems: any[] = []; let currentPage = 1; let hasMorePages = true; while (hasMorePages) { const response = await this.getTestRuns(launchId, projectId, { page: currentPage, pageSize }); allItems.push(...response.items); // Check if there are more pages const totalElements = response.totalElements || 0; const fetchedSoFar = currentPage * pageSize; hasMorePages = fetchedSoFar < totalElements; if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Fetched page ${currentPage}: ${response.items.length} items (total: ${allItems.length}/${totalElements})`); } currentPage++; } return { items: allItems, totalElements: allItems.length, totalPages: Math.ceil(allItems.length / pageSize), page: 1, size: allItems.length }; } /** * Get test execution history for a specific test * Returns history of test executions across multiple launches * * @param launchId - Launch ID containing the test * @param testId - Test ID to get history for * @param projectId - Project ID * @param limit - Number of history items to return (default: 10) * @returns Test execution history with status, duration, timestamps */ async getTestExecutionHistory( launchId: number, testId: number, projectId: number, limit: number = 10 ): Promise<TestExecutionHistoryResponse> { const url = `/api/reporting/v1/launches/${launchId}/tests/${testId}/history?projectId=${projectId}&limit=${limit}`; if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Fetching test execution history for test ${testId} (limit: ${limit})`); } const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const historyData = response.data || response; return TestExecutionHistoryResponseSchema.parse(historyData); } /** * Get test logs and screenshots for a specific test run * Uses the test-execution-logs API endpoint */ async getTestLogsAndScreenshots( testRunId: number, testId: number, options: { maxPageSize?: number; } = {} ): Promise<LogsAndScreenshotsResponse> { const { maxPageSize = 1000 } = options; const url = `/api/test-execution-logs/v1/test-runs/${testRunId}/tests/${testId}/logs-and-screenshots?maxPageSize=${maxPageSize}`; try { const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const logsData = response.data || response; try { return LogsAndScreenshotsResponseSchema.parse(logsData); } catch (parseError) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to parse logs/screenshots, returning empty: ${parseError}`); } // Return empty but valid response return { items: [] }; } } catch (error) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to fetch logs/screenshots: ${error}`); } // Return empty but valid response instead of throwing return { items: [] }; } } /** * Get test sessions for a specific test to retrieve artifacts (video, logs) */ async getTestSessionsForTest( launchId: number, testId: number, projectId: number ): Promise<TestSessionsResponse> { const url = `/api/reporting/v1/launches/${launchId}/test-sessions?testId=${testId}&projectId=${projectId}`; try { const response = await this.makeAuthenticatedRequest<any>('GET', url); const sessionsData = response.data || response; if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Test sessions response for test ${testId}:`, JSON.stringify(sessionsData, null, 2)); } try { return TestSessionsResponseSchema.parse(sessionsData); } catch (parseError) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to parse test sessions: ${parseError}`); } // Return empty but valid response return { items: [] }; } } catch (error) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to fetch test sessions: ${error}`); } // Return empty but valid response instead of throwing return { items: [] }; } } /** * Download screenshot file with authentication * @param fileUrl - Relative or absolute URL to screenshot file (e.g., "/files/abc123" or "https://your-workspace.zebrunner.com/files/abc123") * @returns Buffer containing the image data */ async downloadScreenshot(fileUrl: string): Promise<Buffer> { try { // Get URL validation config from environment or defaults const strictMode = process.env.STRICT_URL_VALIDATION !== 'false'; // Default true const skipOnError = process.env.SKIP_URL_VALIDATION_ON_ERROR === 'true'; // Default false // Validate URL before processing const validatedUrl = validateFileUrl(fileUrl, { strictMode, skipOnError }); const bearerToken = await this.getBearerToken(); // Construct full URL if relative path provided let fullUrl = validatedUrl; if (validatedUrl.startsWith('/files/')) { fullUrl = `${this.config.baseUrl}${validatedUrl}`; } if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Downloading screenshot: ${fullUrl}`); } const response = await this.http.get(fullUrl, { headers: { 'Authorization': `Bearer ${bearerToken}` }, responseType: 'arraybuffer' }); if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Screenshot downloaded successfully, size: ${response.data.byteLength} bytes`); } return Buffer.from(response.data); } catch (error) { if (this.config.debug) { console.error('[ZebrunnerReportingClient] Screenshot download failed:', error); } throw new ZebrunnerReportingError( `Failed to download screenshot from ${fileUrl}: ${error instanceof Error ? error.message : error}` ); } } /** * Get milestones for a project */ async getMilestones( projectId: number, options: { page?: number; pageSize?: number; completed?: boolean | 'all'; } = {} ): Promise<MilestonesResponse> { const { page = 1, pageSize = 10, completed = false } = options; let url = `/api/reporting/v1/milestones?projectId=${projectId}&page=${page}&pageSize=${pageSize}`; // Add completed filter if not 'all' if (completed !== 'all') { url += `&completed=${completed}`; } const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const milestonesData = response.data || response; try { return MilestonesResponseSchema.parse(milestonesData); } catch (error) { throw new ZebrunnerReportingError(`Failed to parse milestones data: ${error instanceof Error ? error.message : error}`); } } /** * Get available projects with optional filtering */ async getAvailableProjects( options: { starred?: boolean; publiclyAccessible?: boolean; extraFields?: string[]; } = {} ): Promise<AvailableProjectsResponse> { const { starred, publiclyAccessible, extraFields = ['starred'] } = options; let url = `/api/projects/v1/projects`; const params = new URLSearchParams(); // Add extraFields parameter if (extraFields.length > 0) { params.append('extraFields', extraFields.join(',')); } if (params.toString()) { url += `?${params.toString()}`; } const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const projectsData = response.data || response; try { const parsedData = AvailableProjectsResponseSchema.parse(projectsData); // Apply client-side filtering let filteredItems = parsedData.items.filter(project => !project.deleted); // Always exclude deleted if (starred !== undefined) { filteredItems = filteredItems.filter(project => project.starred === starred); } if (publiclyAccessible !== undefined) { filteredItems = filteredItems.filter(project => project.publiclyAccessible === publiclyAccessible); } return { items: filteredItems }; } catch (error) { throw new ZebrunnerReportingError(`Failed to parse projects data: ${error instanceof Error ? error.message : error}`); } } /** * Get projects pagination info */ async getProjectsLimit(): Promise<ProjectsLimitResponse> { const url = `/api/projects/v1/projects-limit`; const response = await this.makeAuthenticatedRequest<any>('GET', url); const limitData = response.data || response; try { return ProjectsLimitResponseSchema.parse(limitData); } catch (error) { throw new ZebrunnerReportingError(`Failed to parse projects limit data: ${error instanceof Error ? error.message : error}`); } } /** * Clear project cache */ clearProjectCache(): void { this.projectCache.clear(); } /** * Get launches for a project */ async getLaunches( projectId: number, options: { page?: number; pageSize?: number; milestone?: string; query?: string; } = {} ): Promise<LaunchesResponse> { const { page = 1, pageSize = 20, milestone, query } = options; let url = `/api/reporting/v1/launches?projectId=${projectId}&page=${page}&pageSize=${pageSize}`; // Add milestone filter if provided if (milestone) { url += `&milestone=${encodeURIComponent(milestone)}`; } // Add query filter if provided if (query) { url += `&query=${encodeURIComponent(query)}`; } const response = await this.makeAuthenticatedRequest<any>('GET', url); // Handle different response structures const launchesData = response.data || response; try { return LaunchesResponseSchema.parse(launchesData); } catch (error) { throw new ZebrunnerReportingError(`Failed to parse launches data: ${error instanceof Error ? error.message : error}`); } } /** * Get automation states for a project */ async getAutomationStates(projectId: number): Promise<{ id: number; name: string }[]> { const url = `/api/tcm/v1/test-case-settings/system-fields/automation-states?projectId=${projectId}`; try { const response = await this.makeAuthenticatedRequest<any>('GET', url); const data = response.data || response; // Expected format: array of { id: number, name: string } objects if (Array.isArray(data)) { return data.map((item: any) => ({ id: item.id, name: item.name })); } throw new ZebrunnerReportingError('Unexpected response format for automation states'); } catch (error) { // If the API call fails, return default mapping console.warn('Failed to fetch automation states from API, using default mapping:', error); return [ { id: 10, name: "Not Automated" }, { id: 11, name: "To Be Automated" }, { id: 12, name: "Automated" } ]; } } /** * Get priorities for a project */ async getPriorities(projectId: number): Promise<{ id: number; name: string }[]> { const url = `/api/tcm/v1/test-case-settings/system-fields/priorities?projectId=${projectId}`; try { if (this.config.debug) { console.error(`🔍 Fetching priorities from: ${url}`); } const response = await this.makeAuthenticatedRequest<any>('GET', url); const data = response.data || response; if (this.config.debug) { console.error(`🔍 Priorities API response:`, JSON.stringify(data, null, 2)); } // Handle response format: {"items": [...]} or direct array let prioritiesArray: any[] = []; if (data && Array.isArray(data.items)) { prioritiesArray = data.items; } else if (Array.isArray(data)) { prioritiesArray = data; } else { throw new ZebrunnerReportingError('Unexpected response format for priorities - no items array found'); } // Map to expected format const priorities = prioritiesArray.map((item: any) => ({ id: item.id, name: item.name })); if (this.config.debug) { console.error(`🔍 Parsed ${priorities.length} priorities:`, priorities); } return priorities; } catch (error) { // Enhanced error logging const errorMessage = error instanceof Error ? error.message : String(error); console.warn(`❌ Failed to fetch priorities from API (${url}):`, errorMessage); if (this.config.debug && error instanceof Error) { console.error('Full error details:', error); } // Return fallback priorities based on your actual system console.warn('Using fallback priority mapping based on actual system values'); return [ { id: 15, name: "High" }, { id: 16, name: "Medium" }, { id: 17, name: "Low" }, { id: 18, name: "Trivial" }, { id: 35, name: "Critical" } ]; } } /** * Get current authentication status */ getAuthStatus(): { authenticated: boolean; expiresAt: Date | null; timeToExpiry?: number } { const authenticated = !!(this.bearerToken && this.tokenExpiresAt && this.tokenExpiresAt > new Date()); const timeToExpiry = this.tokenExpiresAt ? this.tokenExpiresAt.getTime() - Date.now() : undefined; return { authenticated, expiresAt: this.tokenExpiresAt, timeToExpiry }; } /** * Fetch JIRA integrations from Zebrunner */ async getJiraIntegrations(): Promise<JiraIntegrationsResponse> { try { const response = await this.makeAuthenticatedRequest<any>( 'GET', '/api/integrations/v2/integrations/tool:jira' ); const integrationsData = response.data || response; return JiraIntegrationsResponseSchema.parse(integrationsData); } catch (error) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to fetch JIRA integrations: ${error}`); } // Return empty response on error return { items: [] }; } } /** * Resolve JIRA base URL with caching * Priority: * 1. Cached value (session-level cache) * 2. Zebrunner integrations API (match by projectId, fallback to any enabled) * 3. Environment variable (JIRA_BASE_URL) * 4. Placeholder (https://jira.com) */ async resolveJiraBaseUrl(projectId?: number): Promise<string> { // Return cached value if available if (this.jiraBaseUrlCache) { return this.jiraBaseUrlCache; } try { // Try to fetch from Zebrunner integrations API const integrations = await this.getJiraIntegrations(); if (integrations.items.length > 0) { // Filter to enabled JIRA integrations only const enabledIntegrations = integrations.items.filter( (integration) => integration.enabled && integration.tool === 'JIRA' ); if (enabledIntegrations.length > 0) { let selectedIntegration = enabledIntegrations[0]; // Default to first // If projectId provided, try to find matching integration if (projectId) { const projectMatch = enabledIntegrations.find((integration) => integration.projectsMapping.enabledForZebrunnerProjectIds.includes(projectId) ); if (projectMatch) { selectedIntegration = projectMatch; } } const jiraUrl = selectedIntegration.config.url; if (jiraUrl) { // Cache and return this.jiraBaseUrlCache = jiraUrl.replace(/\/+$/, ''); // Remove trailing slash if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Resolved JIRA URL from integrations: ${this.jiraBaseUrlCache}`); } return this.jiraBaseUrlCache; } } } } catch (error) { if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] Failed to resolve JIRA URL from integrations: ${error}`); } } // Fallback to environment variable const envJiraUrl = process.env.JIRA_BASE_URL; if (envJiraUrl) { this.jiraBaseUrlCache = envJiraUrl.replace(/\/+$/, ''); if (this.config.debug) { console.log(`[ZebrunnerReportingClient] Resolved JIRA URL from env var: ${this.jiraBaseUrlCache}`); } return this.jiraBaseUrlCache; } // Final fallback to placeholder this.jiraBaseUrlCache = 'https://jira.com'; if (this.config.debug) { console.warn(`[ZebrunnerReportingClient] No JIRA URL found, using placeholder: ${this.jiraBaseUrlCache}`); } return this.jiraBaseUrlCache; } /** * Build a JIRA issue URL * @param issueKey - JIRA issue key (e.g., "QAS-22939", "APPS-2771") * @param projectId - Optional project ID for project-specific JIRA integration * @returns Full JIRA URL (e.g., "https://your-workspace.atlassian.net/browse/QAS-22939") */ async buildJiraUrl(issueKey: string, projectId?: number): Promise<string> { const jiraBaseUrl = await this.resolveJiraBaseUrl(projectId); return `${jiraBaseUrl}/browse/${issueKey}`; } }

Implementation Reference

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/maksimsarychau/mcp-zebrunner'

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