408 lines
11 KiB
TypeScript
408 lines
11 KiB
TypeScript
/**
|
|
* 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<DownloadConfig> = {
|
|
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<DownloadConfig>
|
|
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<T>(
|
|
operation: () => Promise<T>,
|
|
context: string,
|
|
maxRetries = 3
|
|
): Promise<T> {
|
|
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<S3Object[]> {
|
|
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<void> {
|
|
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<void>((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<void> {
|
|
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<ValidationResult> {
|
|
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<void> => {
|
|
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
|