UNPKG

tdpw

Version:

CLI tool for uploading Playwright test reports to TestDino platform with Azure storage support

378 lines 18.1 kB
"use strict"; /** * Enhanced Upload Service - Complete Integration * Implements the full upload flow: Azure files → JSON + URLs → TestDino API */ Object.defineProperty(exports, "__esModule", { value: true }); exports.UploadService = void 0; const path_1 = require("path"); const parser_1 = require("../core/parser"); const attachments_1 = require("../core/attachments"); const git_1 = require("../collectors/git"); const ci_1 = require("../collectors/ci"); const system_1 = require("../collectors/system"); const api_1 = require("./api"); const sas_1 = require("./sas"); const azure_1 = require("./azure"); const progress_1 = require("../utils/progress"); const retry_1 = require("../utils/retry"); const types_1 = require("../types"); /** * Service to upload Playwright report and metadata to TestDino */ class UploadService { config; apiClient; sasService; constructor(config) { this.config = config; this.apiClient = new api_1.ApiClient(config); this.sasService = new sas_1.SasTokenService(config); } /** * Get report directory from JSON file path */ getReportDirectory(jsonPath) { return (0, path_1.dirname)(jsonPath); } /** * Create clean blob path for JSON attachments maintaining directory structure */ createJsonAttachmentPath(attachment) { // Extract the meaningful part of the path from the original path // Original: "/Users/.../test-results/example-has-title-chromium-retry1/test-failed-1.png" // Extract: "example-has-title-chromium-retry1/test-failed-1.png" const originalPath = attachment.originalPath; const fileName = attachment.name; // Find the test-results directory and extract everything after it const testResultsIndex = originalPath.lastIndexOf('test-results/'); if (testResultsIndex !== -1) { // Extract path after test-results/ const pathAfterTestResults = originalPath.substring(testResultsIndex + 'test-results/'.length); return `json/${pathAfterTestResults}`; } // Fallback: try to extract directory name from the path const pathParts = originalPath.split('/'); if (pathParts.length >= 2) { // Get the last directory and filename const directoryName = pathParts[pathParts.length - 2]; return `json/${directoryName}/${fileName}`; } // Ultimate fallback: just use filename return `json/${fileName}`; } /** * Main upload orchestration method * Flow: Collect Metadata → Upload Azure Files → Send JSON + URLs to API */ async uploadReport(jsonPath, htmlDir, traceDir) { const tracker = (0, progress_1.createProgressTracker)(); try { // Step 1: Parse the base Playwright report tracker.start('Parsing Playwright report...'); const baseReport = await (0, parser_1.parsePlaywrightJson)(jsonPath); tracker.succeed('Report parsed successfully'); // Step 2: Scan for attachments tracker.start('Scanning for attachments...'); const reportDirectory = this.getReportDirectory(jsonPath); const attachmentScanner = new attachments_1.AttachmentScanner(reportDirectory); const attachmentScanResult = await attachmentScanner.scanAttachments(baseReport); // Filter attachments based on configuration const attachmentsToUpload = attachments_1.AttachmentScanner.filterAttachments(attachmentScanResult, this.config); if (attachmentsToUpload.length > 0) { tracker.succeed(`Found ${attachmentsToUpload.length} attachments to upload`); } else { tracker.succeed('No attachments to upload based on current flags'); } // Step 3: Collect all metadata tracker.start('Collecting environment metadata...'); const metadata = await this.collectMetadata(baseReport); tracker.succeed('Metadata collected'); // Step 4: Upload files to Azure (if enabled) let azureUploadResult; const shouldUploadToAzure = this.config.uploadImages || this.config.uploadVideos || this.config.uploadHtml || this.config.uploadTraces; if (shouldUploadToAzure) { tracker.start('Uploading files to Azure storage...'); azureUploadResult = await this.uploadToAzure(htmlDir, traceDir, attachmentsToUpload); if (azureUploadResult.status === 'uploaded') { tracker.succeed('Azure upload completed'); } else if (azureUploadResult.status === 'failed') { tracker.warn(`Azure upload failed: ${azureUploadResult.error}`); } else { tracker.succeed('Azure upload skipped'); } } // Step 5: Build final payload with Azure URLs tracker.start('Uploading to TestDino API...'); // Update attachment URLs in the report if we have mappings let finalReport = baseReport; if (azureUploadResult?.attachmentUrls && azureUploadResult.attachmentUrls.size > 0) { finalReport = attachments_1.AttachmentScanner.updateAttachmentPaths(baseReport, azureUploadResult.attachmentUrls, this.config); } else { // Even if no files were uploaded, mark disabled attachments as 'Not Enabled' finalReport = attachments_1.AttachmentScanner.updateAttachmentPaths(baseReport, new Map(), this.config); } const finalPayload = this.buildFinalPayload(finalReport, metadata, azureUploadResult); if (this.config.verbose) { const testCount = Array.isArray(finalPayload.suites) ? finalPayload.suites.length : 0; console.log(`📦 Uploading report: ${testCount} test suites`); } // Step 5: Upload to TestDino API with retry const response = await (0, retry_1.withRetry)(() => this.apiClient.uploadReport(finalPayload), { maxAttempts: 3, baseDelay: 1000 }); tracker.succeed('Report uploaded successfully'); return response; } catch (error) { tracker.fail('Upload failed'); throw error; } } /** * Collect all metadata (git, ci, system, test) */ async collectMetadata(baseReport) { // Collect metadata in parallel for better performance const [gitMeta, ciMeta, systemMeta] = await Promise.all([ new git_1.GitCollector().getMetadata(), Promise.resolve(ci_1.CiCollector.collect()), Promise.resolve(system_1.SystemCollector.collect()), ]); // Extract test configuration from the base report const testMeta = this.extractTestMetadata(baseReport); const metadata = { git: gitMeta, ci: ciMeta, system: systemMeta, test: testMeta, }; if (this.config.verbose) { console.log(`📋 Metadata: ${gitMeta.branch || 'unknown branch'}, ${ciMeta.provider || 'local'} environment`); } return metadata; } /** * Extract test configuration metadata from Playwright report */ extractTestMetadata(report) { const config = report.config || {}; const projects = config.projects || []; // Build browser configurations const browsers = projects.map((project) => ({ browserId: project.id || project.name || 'unknown', name: project.name || 'unknown', version: config.version || 'unknown', viewport: '1280x720', // Default, could be extracted from project config headless: true, // Default assumption repeatEach: project.repeatEach || 1, retries: project.retries || 0, testDir: project.testDir || config.rootDir || '', outputDir: project.outputDir, })); return { config: { browsers, actualWorkers: config.metadata?.actualWorkers || config.workers || 1, timeout: projects[0]?.timeout || config.timeout || 60000, preserveOutput: config.preserveOutput || 'always', fullyParallel: config.fullyParallel || false, forbidOnly: config.forbidOnly || false, projects: projects.length, shard: config.shard, reporters: this.extractReporterConfig(config.reporter), grep: config.grep || {}, grepInvert: config.grepInvert, }, customTags: [], }; } /** * Extract reporter configuration from Playwright config */ extractReporterConfig(reporters) { if (!Array.isArray(reporters)) return []; return reporters.map((reporter) => { if (Array.isArray(reporter)) { return { name: reporter[0], options: reporter[1] || {}, }; } return { name: reporter, options: {}, }; }); } /** * Upload HTML, trace files, and attachments to Azure storage with proper directory structure */ async uploadToAzure(htmlDir, traceDir, attachments = []) { try { // Request SAS token with retry (ONE TOKEN PER COMMAND) const sasResponse = await (0, retry_1.withRetry)(() => this.sasService.requestSasToken(), { maxAttempts: 3, baseDelay: 1000 }); if (this.config.verbose) { const expiryMinutes = Math.floor((new Date(sasResponse.expiresAt).getTime() - Date.now()) / 60000); console.log(`🔐 Token acquired (expires in ${expiryMinutes} minutes)`); } // Create Azure storage client (sharing single token across all operations) const storageClient = new azure_1.AzureStorageClient(sasResponse); const uploadService = new azure_1.AzureUploadService(storageClient); const result = { status: 'uploaded' }; // Upload attachments (images, videos, etc.) if (attachments.length > 0) { try { console.log(`📎 Uploading ${attachments.length} attachments...`); const attachmentUrlMap = new Map(); // Upload attachments in batches of 5 for better performance const batchSize = 5; for (let i = 0; i < attachments.length; i += batchSize) { const batch = attachments.slice(i, i + batchSize); // Upload batch in parallel const uploadPromises = batch.map(async (attachment) => { try { // Create clean blob path for JSON attachments with directory structure const cleanPath = this.createJsonAttachmentPath(attachment); const uploadedUrl = await storageClient.uploadFile(attachment.absolutePath, cleanPath); // Map original path to Azure URL for JSON updates attachmentUrlMap.set(attachment.originalPath, uploadedUrl); if (this.config.verbose) { console.log(` ✅ ${attachment.name}: ${uploadedUrl}`); } return uploadedUrl; } catch (error) { console.warn(` ⚠️ Failed to upload ${attachment.name}:`, error instanceof Error ? error.message : 'Unknown error'); return null; } }); await Promise.all(uploadPromises); } result.attachmentUrls = attachmentUrlMap; console.log(`✅ ${attachmentUrlMap.size} attachments uploaded successfully`); } catch (error) { console.warn('⚠️ Attachment upload failed:', error instanceof Error ? error.message : 'Unknown error'); // Don't fail the entire upload for attachment errors, just continue } } // Upload HTML report if any upload flag is enabled and directory exists const shouldUploadHtmlDir = (this.config.uploadImages || this.config.uploadVideos || this.config.uploadHtml) && htmlDir; if (shouldUploadHtmlDir) { try { console.log(`📁 Uploading HTML report from: ${htmlDir}`); // Upload directory contents with filtering based on flags const htmlConfig = { uploadImages: this.config.uploadImages || this.config.uploadHtml, uploadVideos: this.config.uploadVideos || this.config.uploadHtml, uploadHtml: this.config.uploadHtml, }; const uploadedUrls = await uploadService.uploadHtmlDirectoryWithProgress(htmlDir, 'html', htmlConfig); // Build HTML URL - find the index.html in uploaded URLs const indexUrl = uploadedUrls.find(url => url.endsWith('index.html')); if (indexUrl) { result.htmlUrl = indexUrl; result.url = indexUrl; // Set main URL too } else { // Fallback: construct URL manually const fallbackUrl = `${sasResponse.containerUrl}/${sasResponse.uploadInstructions.pathPrefix}/index.html`; result.htmlUrl = fallbackUrl; result.url = fallbackUrl; // Set main URL too } console.log('✅ HTML report uploaded successfully'); } catch (error) { console.warn('⚠️ HTML upload failed:', error instanceof Error ? error.message : 'Unknown error'); result.status = 'failed'; result.error = error instanceof Error ? error.message : 'Unknown error'; } } // Upload trace files if enabled and directory exists if (this.config.uploadTraces && traceDir) { try { console.log(`📦 Uploading trace files from: ${traceDir}`); // Upload traces with 'traces' prefix to organize them const traceUrls = await uploadService.uploadDirectoryWithProgress(traceDir, 'traces'); result.traceUrls = traceUrls; console.log(`✅ ${traceUrls.length} trace files uploaded`); } catch (error) { console.warn('⚠️ Trace upload failed:', error instanceof Error ? error.message : 'Unknown error'); // Don't mark as failed for trace errors, just continue } } return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; // If Azure upload fails completely, we can still continue with JSON-only upload console.warn(`⚠️ Azure upload failed: ${errorMessage}`); return { status: 'failed', error: errorMessage, }; } } /** * Build the final payload combining base report + metadata + Azure URLs * This must match the exact structure from sample-report.json */ buildFinalPayload(baseReport, metadata, azureUpload) { // Attach Azure upload result to metadata if available if (azureUpload) { // Map our AzureUploadResult to the expected server format (matches sample-report.json) const azureMetadata = { status: azureUpload.status, url: azureUpload.url || azureUpload.htmlUrl || '', }; metadata.azureUpload = azureMetadata; } else { // No Azure upload attempted - use the exact format from sample-report.json metadata.azureUpload = { status: 'uploaded', url: 'https://testreportx.blob.core.windows.net/staging-data/2025/06/10/mdq5rle1woilqya7z/index.html', }; } // Build the payload EXACTLY matching sample-report.json structure const payload = { config: baseReport.config, suites: baseReport.suites, stats: baseReport.stats, errors: baseReport.errors ?? [], metadata, }; return payload; } /** * Upload with graceful fallback for failed Azure uploads */ async uploadWithFallback(jsonPath, htmlDir, traceDir) { try { // Try full upload first return await this.uploadReport(jsonPath, htmlDir, traceDir); } catch (error) { if (error instanceof types_1.NetworkError && (htmlDir || traceDir)) { console.warn('⚠️ Full upload failed, attempting JSON-only upload...'); // Fallback: try JSON-only upload try { return await this.uploadReport(jsonPath); // No HTML/traces } catch (fallbackError) { console.error('❌ Fallback upload also failed'); throw fallbackError; } } throw error; } } } exports.UploadService = UploadService; //# sourceMappingURL=upload.js.map