/** * Playwright Browser Download Service * * Downloads Playwright browser files from S3 to local directory * with progress tracking and basic validation. */ import * as fs from 'fs' import * as path from 'path' import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3' import { createLogger } from '../logger' const log = createLogger('PlaywrightDownloadService') /** * Download progress event */ export interface DownloadProgress { percent: number downloadedBytes: number totalBytes: number currentFile: string speed: number eta?: number } /** * S3 Object information */ export interface S3Object { key: string size?: number lastModified?: Date } /** * Validation result */ export interface ValidationResult { success: boolean message: string fileCount?: number chromeExeExists?: boolean expectedChromePath?: string } /** * Download configuration */ export interface DownloadConfig { s3Client?: S3Client bucket?: string prefix?: string destDir?: string } /** * Default configuration */ const DEFAULT_CONFIG: Required = { s3Client: null as unknown as S3Client, bucket: 'erpauto', prefix: 'resources/ms-playwright/', // ✅ Fixed: removed 'erpauto/' prefix destDir: path.join(process.env.APPDATA || '', 'erpauto', 'ms-playwright') } /** * DownloadService class * Handles downloading Playwright browser files from S3 */ export class DownloadService { private config: Required private s3Client: S3Client constructor(config?: DownloadConfig) { if (!config?.s3Client) { throw new Error('S3Client is required') } this.config = { ...DEFAULT_CONFIG, ...config, bucket: config?.bucket || DEFAULT_CONFIG.bucket, prefix: config?.prefix || DEFAULT_CONFIG.prefix, destDir: config?.destDir || DEFAULT_CONFIG.destDir } this.s3Client = this.config.s3Client } /** * Execute an operation with retry logic using exponential backoff * @param operation - The async operation to execute * @param context - Description of the operation for logging * @param maxRetries - Maximum number of retry attempts (default: 3) * @returns The result of the operation */ private async withRetry( operation: () => Promise, context: string, maxRetries = 3 ): Promise { let lastError: Error | undefined for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await operation() } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) // Check if error is retryable const isRetryable = this.isRetryableError(lastError) if (!isRetryable || attempt === maxRetries) { break } // Exponential backoff: 1s, 2s, 4s const delay = Math.pow(2, attempt - 1) * 1000 log.warn(`${context} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`, { error: lastError.message }) await new Promise((resolve) => setTimeout(resolve, delay)) } } throw lastError } /** * Determine if an error is retryable based on error type and message * @param error - The error to classify * @returns true if the error should trigger a retry */ private isRetryableError(error: Error): boolean { const message = error.message.toLowerCase() const code = (error as any).code?.toLowerCase() || '' // Retryable: network errors, timeouts const retryablePatterns = [ 'etimedout', 'econnreset', 'timeout', 'network', 'socket hang up', 'connection reset' ] // Not retryable: S3 errors, validation errors const nonRetryablePatterns = [ '404', '403', 'not found', 'access denied', 'invalid', 'validation' ] // Check non-retryable first for (const pattern of nonRetryablePatterns) { if (message.includes(pattern) || code.includes(pattern)) { return false } } // Check retryable for (const pattern of retryablePatterns) { if (message.includes(pattern) || code.includes(pattern)) { return true } } // Default: don't retry unknown errors return false } /** * List all S3 objects under the configured prefix * Filters to include ALL Chromium files (regular + headless) * Simple filter: includes 'chromium' keyword */ async listObjects(): Promise { log.info('Listing S3 objects', { bucket: this.config.bucket, prefix: this.config.prefix }) const objects: S3Object[] = [] let continuationToken: string | undefined do { const response = await this.s3Client.send( new ListObjectsV2Command({ Bucket: this.config.bucket, Prefix: this.config.prefix, ContinuationToken: continuationToken }) ) for (const obj of response.Contents || []) { if (!obj.Key) continue // Simple filter: only download Chromium-related files // This includes: chromium-1200, chromium-1208, chromium_headless_shell-1200, chromium_headless_shell-1208 // Excludes: firefox, webkit, ffmpeg, winldd, .links, .settings if (!obj.Key.includes('chromium')) { continue } objects.push({ key: obj.Key, size: obj.Size, lastModified: obj.LastModified }) } continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined } while (continuationToken) log.info(`Found ${objects.length} Chromium objects`) return objects } /** * Download a single file from S3 with retry logic */ async downloadFile(key: string, destPath: string): Promise { await this.withRetry(async () => { log.debug('Downloading file', { key, destPath }) await fs.promises.mkdir(path.dirname(destPath), { recursive: true }) const response = await this.s3Client.send( new GetObjectCommand({ Bucket: this.config.bucket, Key: key }) ) const output = fs.createWriteStream(destPath) const body = response.Body as NodeJS.ReadableStream await new Promise((resolve, reject) => { body.on('error', reject) output.on('error', reject) output.on('finish', resolve) body.pipe(output) }) log.debug('File downloaded', { key, destPath }) }, `Download ${key}`) } /** * Download all Chromium browser files from S3 * Emits progress events during download */ async downloadAll(onProgress: (progress: DownloadProgress) => void): Promise { log.info('Starting download of all browser files', { destDir: this.config.destDir }) const objects = await this.listObjects() if (objects.length === 0) { throw new Error('No Chromium browser files found in S3') } // Calculate total bytes const totalBytes = objects.reduce((sum, obj) => sum + (obj.size || 0), 0) log.info(`Total files: ${objects.length}, Total bytes: ${totalBytes}`) let downloadedBytes = 0 const startTime = Date.now() for (const obj of objects) { const relativePath = obj.key.replace(this.config.prefix, '') const destPath = path.join(this.config.destDir, relativePath) // Emit progress for current file onProgress({ percent: Math.round((downloadedBytes / totalBytes) * 100), downloadedBytes, totalBytes, currentFile: relativePath, speed: 0, eta: undefined }) try { await this.downloadFile(obj.key, destPath) const fileSize = obj.size || 0 downloadedBytes += fileSize // Calculate speed and ETA const elapsedSeconds = (Date.now() - startTime) / 1000 const speed = Math.round(downloadedBytes / elapsedSeconds) const remainingBytes = totalBytes - downloadedBytes const eta = speed > 0 ? Math.round(remainingBytes / speed) : undefined onProgress({ percent: Math.round((downloadedBytes / totalBytes) * 100), downloadedBytes, totalBytes, currentFile: relativePath, speed, eta }) log.debug('Downloaded file', { key: obj.key, size: fileSize, speed: `${speed} bytes/s` }) } catch (error) { log.error('Failed to download file', { key: obj.key, error: error instanceof Error ? error.message : String(error) }) throw new Error( `Failed to download ${relativePath}: ${error instanceof Error ? error.message : String(error)}` ) } } const totalElapsed = (Date.now() - startTime) / 1000 log.info('Download completed', { totalFiles: objects.length, totalBytes, duration: `${totalElapsed.toFixed(1)}s`, avgSpeed: `${Math.round(downloadedBytes / totalElapsed)} bytes/s` }) // Final progress event onProgress({ percent: 100, downloadedBytes: totalBytes, totalBytes, currentFile: 'Complete', speed: Math.round(downloadedBytes / totalElapsed), eta: 0 }) } /** * Validate the downloaded files * Checks if the destination directory exists and chrome.exe is present */ async validateDownload(): Promise { log.info('Validating download', { destDir: this.config.destDir }) // Check if destination directory exists try { await fs.promises.access(this.config.destDir) } catch { return { success: false, message: 'Download directory does not exist', fileCount: 0, chromeExeExists: false } } // Find chrome.exe in chromium-* folders const chromeExePattern = /chromium-\d+[/\\]chrome-win64[/\\]chrome\.exe$/ let chromeExeExists = false let expectedChromePath: string | undefined let fileCount = 0 const findChromeExe = async (dir: string): Promise => { const entries = await fs.promises.readdir(dir, { withFileTypes: true }) for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { await findChromeExe(fullPath) } else if (entry.name.toLowerCase() === 'chrome.exe') { // Check if path matches chromium-*/chrome-win64/chrome.exe pattern const relativePath = path.relative(this.config.destDir, fullPath) if (chromeExePattern.test(relativePath.replace(/\\/g, '/'))) { chromeExeExists = true expectedChromePath = fullPath } } fileCount++ } } try { await findChromeExe(this.config.destDir) } catch (error) { log.error('Error scanning download directory', { error: error instanceof Error ? error.message : String(error) }) } const result: ValidationResult = { success: chromeExeExists, message: chromeExeExists ? 'Validation passed' : 'chrome.exe not found in expected location', fileCount, chromeExeExists, expectedChromePath } log.info('Validation result', result) return result } } export default DownloadService