diff --git a/src/commands/upload-file.ts b/src/commands/upload-file.ts index 1ed07569..80e51ddd 100644 --- a/src/commands/upload-file.ts +++ b/src/commands/upload-file.ts @@ -62,12 +62,18 @@ export default class UploadFile extends Command { reporter: this.log.bind(this), })) ?? user.rootFolderId; + const timings = { + networkUpload: 0, + driveUpload: 0, + thumbnailUpload: 0, + }; + // 1. Prepare the network const networkFacade = await CLIUtils.prepareNetwork({ loginUserDetails: user, jsonFlag: flags['json'] }); // 2. Upload file to the Network const readStream = createReadStream(filePath); - const timer = CLIUtils.timer(); + const networkUploadTimer = CLIUtils.timer(); const progressBar = CLIUtils.progress( { format: 'Uploading file [{bar}] {percentage}%', @@ -107,8 +113,10 @@ export default class UploadFile extends Command { process.exit(1); }); }); + timings.networkUpload = networkUploadTimer.stop(); // 3. Create the file in Drive + const driveUploadTimer = CLIUtils.timer(); const createdDriveFile = await DriveFileService.instance.createFile({ plainName: fileInfo.name, type: fileType, @@ -120,7 +128,9 @@ export default class UploadFile extends Command { creationTime: stats.birthtime?.toISOString(), modificationTime: stats.mtime?.toISOString(), }); + timings.driveUpload = driveUploadTimer.stop(); + const thumbnailTimer = CLIUtils.timer(); try { if (isThumbnailable && bufferStream) { const thumbnailBuffer = bufferStream.getBuffer(); @@ -138,14 +148,24 @@ export default class UploadFile extends Command { } catch (error) { ErrorUtils.report(error, { command: this.id }); } + timings.thumbnailUpload = thumbnailTimer.stop(); progressBar?.update(100); progressBar?.stop(); - const uploadTime = timer.stop(); + const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); + const throughputMBps = CLIUtils.calculateThroughputMBps(stats.size, timings.networkUpload); + + this.log('\n'); + this.log( + `[PUT] Timing breakdown:\n + Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n + Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n + Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n`, + ); this.log('\n'); // eslint-disable-next-line max-len - const message = `File uploaded in ${uploadTime}ms, view it at ${ConfigService.instance.get('DRIVE_WEB_URL')}/file/${createdDriveFile.uuid}`; + const message = `File uploaded successfully in ${CLIUtils.formatDuration(totalTime)}, view it at ${ConfigService.instance.get('DRIVE_WEB_URL')}/file/${createdDriveFile.uuid}`; CLIUtils.success(this.log.bind(this), message); return { success: true, diff --git a/src/services/network/upload/upload-facade.service.ts b/src/services/network/upload/upload-facade.service.ts index 25bed0dc..a5aef4e7 100644 --- a/src/services/network/upload/upload-facade.service.ts +++ b/src/services/network/upload/upload-facade.service.ts @@ -38,7 +38,7 @@ export class UploadFacade { // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 await AsyncUtils.sleep(500); - const totalBytes = await UploadFileService.instance.uploadFilesInChunks({ + const totalBytes = await UploadFileService.instance.uploadFilesConcurrently({ network, filesToUpload: scanResult.files, folderMap, diff --git a/src/services/network/upload/upload-file.service.ts b/src/services/network/upload/upload-file.service.ts index b0e8acca..6634f75a 100644 --- a/src/services/network/upload/upload-file.service.ts +++ b/src/services/network/upload/upload-file.service.ts @@ -3,7 +3,7 @@ import { DELAYS_MS, MAX_CONCURRENT_UPLOADS, MAX_RETRIES, - UploadFilesInBatchesParams, + UploadFilesConcurrentlyParams, UploadFileWithRetryParams, } from './upload.types'; import { DriveFileService } from '../../drive/drive-file.service'; @@ -12,11 +12,12 @@ import { isAlreadyExistsError } from '../../../utils/errors.utils'; import { stat } from 'node:fs/promises'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { createFileStreamWithBuffer, tryUploadThumbnail } from '../../../utils/thumbnail.utils'; +import { CLIUtils } from '../../../utils/cli.utils'; export class UploadFileService { static readonly instance = new UploadFileService(); - async uploadFilesInChunks({ + async uploadFilesConcurrently({ network, filesToUpload, folderMap, @@ -24,14 +25,14 @@ export class UploadFileService { destinationFolderUuid, currentProgress, emitProgress, - }: UploadFilesInBatchesParams): Promise { + }: UploadFilesConcurrentlyParams): Promise { let bytesUploaded = 0; - const chunks = this.chunkArray(filesToUpload, MAX_CONCURRENT_UPLOADS); + const concurrentFiles = this.concurrencyArray(filesToUpload, MAX_CONCURRENT_UPLOADS); - for (const chunk of chunks) { + for (const fileArray of concurrentFiles) { await Promise.allSettled( - chunk.map(async (file) => { + fileArray.map(async (file) => { const parentPath = dirname(file.relativePath); const parentFolderUuid = parentPath === '.' || parentPath === '' ? destinationFolderUuid : folderMap.get(parentPath); @@ -78,6 +79,13 @@ export class UploadFileService { fileType, }); + const timings = { + networkUpload: 0, + driveUpload: 0, + thumbnailUpload: 0, + }; + + const uploadTimer = CLIUtils.timer(); const fileId = await new Promise((resolve, reject) => { network.uploadFile( fileStream, @@ -92,7 +100,9 @@ export class UploadFileService { () => {}, ); }); + timings.networkUpload = uploadTimer.stop(); + const driveTimer = CLIUtils.timer(); const createdDriveFile = await DriveFileService.instance.createFile({ plainName: file.name, type: fileType, @@ -104,7 +114,9 @@ export class UploadFileService { creationTime: stats.birthtime?.toISOString(), modificationTime: stats.mtime?.toISOString(), }); + timings.driveUpload = driveTimer.stop(); + const thumbnailTimer = CLIUtils.timer(); if (bufferStream) { void tryUploadThumbnail({ bufferStream, @@ -114,6 +126,18 @@ export class UploadFileService { networkFacade: network, }); } + timings.thumbnailUpload = thumbnailTimer.stop(); + + const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); + const throughputMBps = CLIUtils.calculateThroughputMBps(stats.size, timings.networkUpload); + logger.info(`Uploaded '${file.name}' (${CLIUtils.formatBytesToString(stats.size)})`); + logger.info( + `Timing breakdown:\n + Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n + Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n + Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n + Total: ${CLIUtils.formatDuration(totalTime)}\n`, + ); return createdDriveFile.fileId; } catch (error: unknown) { @@ -136,11 +160,11 @@ export class UploadFileService { } return null; } - private chunkArray(array: T[], chunkSize: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < array.length; i += chunkSize) { - chunks.push(array.slice(i, i + chunkSize)); + private concurrencyArray(array: T[], arraySize: number): T[][] { + const arrays: T[][] = []; + for (let i = 0; i < array.length; i += arraySize) { + arrays.push(array.slice(i, i + arraySize)); } - return chunks; + return arrays; } } diff --git a/src/services/network/upload/upload.types.ts b/src/services/network/upload/upload.types.ts index d45348f6..60d7c86f 100644 --- a/src/services/network/upload/upload.types.ts +++ b/src/services/network/upload/upload.types.ts @@ -33,7 +33,7 @@ export interface CreateFolderWithRetryParams { parentFolderUuid: string; } -export interface UploadFilesInBatchesParams { +export interface UploadFilesConcurrentlyParams { network: NetworkFacade; filesToUpload: FileSystemNode[]; folderMap: Map; @@ -49,6 +49,6 @@ export interface UploadFileWithRetryParams { bucket: string; parentFolderUuid: string; } -export const MAX_CONCURRENT_UPLOADS = 5; +export const MAX_CONCURRENT_UPLOADS = 10; export const DELAYS_MS = [500, 1000, 2000]; export const MAX_RETRIES = 2; diff --git a/src/utils/cli.utils.ts b/src/utils/cli.utils.ts index ae34c8de..6be9a305 100644 --- a/src/utils/cli.utils.ts +++ b/src/utils/cli.utils.ts @@ -216,6 +216,43 @@ export class CLIUtils { }; }; + static readonly formatDuration = (milliseconds: number): string => { + if (milliseconds <= 0) { + return '00:00:00.000'; + } + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const ms = Math.floor(milliseconds % 1000); + const hoursFormated = hours.toString().padStart(2, '0'); + const minutesFormated = minutes.toString().padStart(2, '0'); + const secondsFormated = seconds.toString().padStart(2, '0'); + const msFormated = ms.toString().padStart(3, '0'); + return `${hoursFormated}:${minutesFormated}:${secondsFormated}.${msFormated}`; + }; + + static readonly formatBytesToString = (bytes: number): string => { + if (bytes <= 0) { + return '0.00 KB'; + } + const kb = bytes / 1024; + if (kb < 1024) { + return `${kb.toFixed(2)} KB`; + } + const mb = kb / 1024; + return `${mb.toFixed(2)} MB`; + }; + + static readonly calculateThroughputMBps = (bytes: number, milliseconds: number): number => { + if (bytes <= 0 || milliseconds <= 0) { + return 0; + } + const megabytes = bytes / 1024 / 1024; + const seconds = milliseconds / 1000; + return megabytes / seconds; + }; + static readonly catchError = ({ error, logReporter, diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index e685845f..69bf3e13 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -48,7 +48,15 @@ export class PUTRequestHandler implements WebDavMethodHandler { throw new NotFoundError('Folders cannot be created with PUT. Use MKCOL instead.'); } webdavLogger.info(`[PUT] Request received for file at ${resource.url}`); - webdavLogger.info(`[PUT] Uploading '${resource.name}' to '${resource.parentPath}'`); + webdavLogger.info( + `[PUT] Uploading '${resource.name}' (${CLIUtils.formatBytesToString(contentLength)}) to '${resource.parentPath}'`, + ); + + const timings = { + networkUpload: 0, + driveUpload: 0, + thumbnailUpload: 0, + }; const parentDriveFolderItem = (await this.dependencies.webDavFolderService.getDriveFolderItemFromPath(resource.parentPath)) ?? @@ -69,8 +77,6 @@ export class PUTRequestHandler implements WebDavMethodHandler { const fileType = resource.path.ext.replace('.', ''); - const timer = CLIUtils.timer(); - let bufferStream: BufferStream | undefined; let fileStream: Readable = req; const isThumbnailable = isFileThumbnailable(fileType); @@ -88,6 +94,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { } }; + const networkUploadTimer = CLIUtils.timer(); const fileId = await new Promise((resolve: (fileId: string) => void, reject) => { const state = this.dependencies.networkFacade.uploadFile( fileStream, @@ -111,9 +118,11 @@ export class PUTRequestHandler implements WebDavMethodHandler { }); }); uploaded = true; + timings.networkUpload = networkUploadTimer.stop(); webdavLogger.info('[PUT] ✅ File uploaded to network'); + const driveTimer = CLIUtils.timer(); const file = await DriveFileService.instance.createFile({ plainName: resource.path.name, type: fileType, @@ -123,7 +132,9 @@ export class PUTRequestHandler implements WebDavMethodHandler { bucket: user.bucket, encryptVersion: EncryptionVersion.Aes03, }); + timings.driveUpload = driveTimer.stop(); + const thumbnailTimer = CLIUtils.timer(); try { if (isThumbnailable && bufferStream) { const thumbnailBuffer = bufferStream.getBuffer(); @@ -141,15 +152,25 @@ export class PUTRequestHandler implements WebDavMethodHandler { } catch (error) { webdavLogger.info(`[PUT] ❌ File thumbnail upload failed ${(error as Error).message}`); } + timings.thumbnailUpload = thumbnailTimer.stop(); - const uploadTime = timer.stop(); - webdavLogger.info(`[PUT] ✅ File uploaded in ${uploadTime}ms to Internxt Drive`); + const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); + const throughputMBps = CLIUtils.calculateThroughputMBps(contentLength, timings.networkUpload); + + webdavLogger.info(`[PUT] ✅ File uploaded in ${CLIUtils.formatDuration(totalTime)} to Internxt Drive`); + + webdavLogger.info( + `[PUT] Timing breakdown:\n + Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n + Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n + Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n`, + ); // Wait for backend search index to propagate (same as folder creation delay in PB-1446) await AsyncUtils.sleep(500); webdavLogger.info( - `[PUT] [RESPONSE-201] ${resource.url} - Returning 201 Created after ${uploadTime}ms (+ 500ms propagation delay)`, + `[PUT] [RESPONSE-201] ${resource.url} - Returning 201 Created after ${CLIUtils.formatDuration(totalTime)}`, ); res.status(201).send(); diff --git a/test/services/network/upload/upload-facade.service.test.ts b/test/services/network/upload/upload-facade.service.test.ts index 413864a8..b2d1ed32 100644 --- a/test/services/network/upload/upload-facade.service.test.ts +++ b/test/services/network/upload/upload-facade.service.test.ts @@ -42,7 +42,7 @@ vi.mock('../../../../src/services/network/upload/upload-folder.service', () => ( vi.mock('../../../../src/services/network/upload/upload-file.service', () => ({ UploadFileService: { instance: { - uploadFilesInChunks: vi.fn(), + uploadFilesConcurrently: vi.fn(), }, }, })); @@ -84,7 +84,7 @@ describe('UploadFacade', () => { totalBytes: 500, }); vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); - vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(500); + vi.mocked(UploadFileService.instance.uploadFilesConcurrently).mockResolvedValue(500); vi.mocked(CLIUtils.timer).mockReturnValue({ stop: vi.fn().mockReturnValue(1000), }); @@ -116,7 +116,7 @@ describe('UploadFacade', () => { ).rejects.toThrow('Failed to create folders, cannot upload files'); expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); - expect(UploadFileService.instance.uploadFilesInChunks).not.toHaveBeenCalled(); + expect(UploadFileService.instance.uploadFilesConcurrently).not.toHaveBeenCalled(); }); it('should properly handle the upload of folder and the creation of file and return proper result', async () => { @@ -133,7 +133,7 @@ describe('UploadFacade', () => { expect(result.rootFolderId).toBe('folder-uuid-123'); expect(result.uploadTimeMs).toBe(1000); expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); - expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled(); + expect(UploadFileService.instance.uploadFilesConcurrently).toHaveBeenCalled(); expect(logger.info).toHaveBeenCalledWith(`Scanned folder ${localPath}: found 2 items, total size 500 bytes.`); }); @@ -148,7 +148,7 @@ describe('UploadFacade', () => { }, ); - vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockImplementation( + vi.mocked(UploadFileService.instance.uploadFilesConcurrently).mockImplementation( async ({ currentProgress, emitProgress }) => { currentProgress.itemsUploaded = 2; currentProgress.bytesUploaded = 500; @@ -174,7 +174,7 @@ describe('UploadFacade', () => { vi.useFakeTimers(); vi.mocked(UploadFolderService.instance.createFolders).mockResolvedValue(folderMap); - vi.mocked(UploadFileService.instance.uploadFilesInChunks).mockResolvedValue(100); + vi.mocked(UploadFileService.instance.uploadFilesConcurrently).mockResolvedValue(100); const uploadPromise = sut.uploadFolder({ localPath, @@ -190,7 +190,7 @@ describe('UploadFacade', () => { expect(AsyncUtils.sleep).toHaveBeenCalledWith(500); expect(AsyncUtils.sleep).toHaveBeenCalledTimes(1); expect(UploadFolderService.instance.createFolders).toHaveBeenCalled(); - expect(UploadFileService.instance.uploadFilesInChunks).toHaveBeenCalled(); + expect(UploadFileService.instance.uploadFilesConcurrently).toHaveBeenCalled(); vi.useRealTimers(); }); diff --git a/test/services/network/upload/upload-file.service.test.ts b/test/services/network/upload/upload-file.service.test.ts index 6cf58e37..48b375fd 100644 --- a/test/services/network/upload/upload-file.service.test.ts +++ b/test/services/network/upload/upload-file.service.test.ts @@ -90,7 +90,7 @@ describe('UploadFileService', () => { } as Awaited>); }); - describe('uploadFilesInChunks', () => { + describe('uploadFilesConcurrently', () => { const bucket = 'test-bucket'; const destinationFolderUuid = 'dest-uuid'; const folderMap = new Map(); @@ -105,7 +105,7 @@ describe('UploadFileService', () => { const uploadFileWithRetrySpy = vi.spyOn(sut, 'uploadFileWithRetry').mockResolvedValue('mock-file-id'); - const result = await sut.uploadFilesInChunks({ + const result = await sut.uploadFilesConcurrently({ network: mockNetworkFacade, filesToUpload: files, folderMap, @@ -120,7 +120,7 @@ describe('UploadFileService', () => { uploadFileWithRetrySpy.mockRestore(); }); - it('should properly upload files in chunks of max 5', async () => { + it('should properly upload files in arrays of max 10', async () => { const files = Array.from({ length: 12 }, (_, i) => createFileSystemNodeFixture({ type: 'file', @@ -132,8 +132,10 @@ describe('UploadFileService', () => { const { currentProgress, emitProgress } = createProgressFixtures(); const uploadFileWithRetrySpy = vi.spyOn(sut, 'uploadFileWithRetry').mockResolvedValue('mock-file-id'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const concurrencyArraySpy = vi.spyOn(sut as any, 'concurrencyArray'); - await sut.uploadFilesInChunks({ + await sut.uploadFilesConcurrently({ network: mockNetworkFacade, filesToUpload: files, folderMap, @@ -144,7 +146,14 @@ describe('UploadFileService', () => { }); expect(uploadFileWithRetrySpy).toHaveBeenCalledTimes(12); + expect(concurrencyArraySpy).toHaveBeenCalledWith(files, 10); + const batches = concurrencyArraySpy.mock.results[0].value; + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(10); + expect(batches[1]).toHaveLength(2); + uploadFileWithRetrySpy.mockRestore(); + concurrencyArraySpy.mockRestore(); }); it('should properly emit progress and update the currentProgress object', async () => { @@ -156,7 +165,7 @@ describe('UploadFileService', () => { const uploadFileWithRetrySpy = vi.spyOn(sut, 'uploadFileWithRetry').mockResolvedValue('mock-file-id'); - await sut.uploadFilesInChunks({ + await sut.uploadFilesConcurrently({ network: mockNetworkFacade, filesToUpload: files, folderMap, @@ -187,7 +196,7 @@ describe('UploadFileService', () => { const uploadFileWithRetrySpy = vi.spyOn(sut, 'uploadFileWithRetry'); - const result = await sut.uploadFilesInChunks({ + const result = await sut.uploadFilesConcurrently({ network: mockNetworkFacade, filesToUpload: files, folderMap, diff --git a/test/utils/cli.utils.test.ts b/test/utils/cli.utils.test.ts index 99bb590d..5d787a17 100644 --- a/test/utils/cli.utils.test.ts +++ b/test/utils/cli.utils.test.ts @@ -197,6 +197,7 @@ describe('CliUtils', () => { expect(ux.action.stop).not.toHaveBeenCalled(); }); }); + describe('prepareNetwork', () => { it('should properly create a networkFacade instance and return it', () => { const result = CLIUtils.prepareNetwork({ loginUserDetails: mockLoginUserDetails }); @@ -227,4 +228,154 @@ describe('CliUtils', () => { expect(doneSpy).toHaveBeenCalledWith(jsonFlag); }); }); + + describe('timer', () => { + it('should measure elapsed time correctly', () => { + vi.useFakeTimers(); + const timer = CLIUtils.timer(); + vi.advanceTimersByTime(1500); + const elapsed = timer.stop(); + expect(elapsed).toBe(1500); + vi.useRealTimers(); + }); + + it('should measure zero time when stopped immediately', () => { + vi.useFakeTimers(); + const timer = CLIUtils.timer(); + const elapsed = timer.stop(); + expect(elapsed).toBe(0); + vi.useRealTimers(); + }); + + it('should handle multiple timers independently', () => { + vi.useFakeTimers(); + const timer1 = CLIUtils.timer(); + vi.advanceTimersByTime(500); + const timer2 = CLIUtils.timer(); + vi.advanceTimersByTime(500); + const elapsed1 = timer1.stop(); + const elapsed2 = timer2.stop(); + expect(elapsed1).toBe(1000); + expect(elapsed2).toBe(500); + vi.useRealTimers(); + }); + }); + + describe('formatDuration', () => { + it('should format seconds correctly', () => { + expect(CLIUtils.formatDuration(5000)).toBe('00:00:05.000'); + }); + + it('should format minutes and seconds correctly', () => { + expect(CLIUtils.formatDuration(125000)).toBe('00:02:05.000'); + }); + + it('should format hours, minutes, and seconds correctly', () => { + expect(CLIUtils.formatDuration(3665000)).toBe('01:01:05.000'); + }); + + it('should format zero milliseconds', () => { + expect(CLIUtils.formatDuration(0)).toBe('00:00:00.000'); + }); + + it('should handle milliseconds less than a second', () => { + expect(CLIUtils.formatDuration(999)).toBe('00:00:00.999'); + }); + + it('should handle large durations', () => { + expect(CLIUtils.formatDuration(86400000)).toBe('24:00:00.000'); + }); + + it('should pad single digits with zeros', () => { + expect(CLIUtils.formatDuration(3661000)).toBe('01:01:01.000'); + }); + + it('should handle negative values gracefully', () => { + expect(CLIUtils.formatDuration(-5000)).toBe('00:00:00.000'); + }); + + it('should format milliseconds correctly', () => { + expect(CLIUtils.formatDuration(1234)).toBe('00:00:01.234'); + }); + + it('should pad milliseconds with zeros', () => { + expect(CLIUtils.formatDuration(5001)).toBe('00:00:05.001'); + }); + }); + + describe('formatBytesToString', () => { + it('should format bytes to MB correctly', () => { + expect(CLIUtils.formatBytesToString(1048576)).toBe('1.00 MB'); + }); + + it('should handle zero bytes', () => { + expect(CLIUtils.formatBytesToString(0)).toBe('0.00 KB'); + }); + + it('should format small byte values in KB', () => { + expect(CLIUtils.formatBytesToString(1024)).toBe('1.00 KB'); + }); + + it('should format large byte values in MB', () => { + expect(CLIUtils.formatBytesToString(10485760)).toBe('10.00 MB'); + }); + + it('should round to two decimal places for MB', () => { + expect(CLIUtils.formatBytesToString(1572864)).toBe('1.50 MB'); + }); + + it('should handle fractional MB values', () => { + expect(CLIUtils.formatBytesToString(2621440)).toBe('2.50 MB'); + }); + + it('should handle negative values gracefully', () => { + expect(CLIUtils.formatBytesToString(-1048576)).toBe('0.00 KB'); + }); + + it('should format bytes less than 1 KB', () => { + expect(CLIUtils.formatBytesToString(512)).toBe('0.50 KB'); + }); + + it('should switch from KB to MB at 1024 KB', () => { + expect(CLIUtils.formatBytesToString(1048575)).toBe('1024.00 KB'); + expect(CLIUtils.formatBytesToString(1048576)).toBe('1.00 MB'); + }); + }); + + describe('calculateThroughputMBps', () => { + it('should calculate throughput in MB/s correctly', () => { + const throughput = CLIUtils.calculateThroughputMBps(10485760, 1000); + expect(throughput).toBe(10); + }); + + it('should handle zero bytes', () => { + const throughput = CLIUtils.calculateThroughputMBps(0, 1000); + expect(throughput).toBe(0); + }); + + it('should handle fractional throughput', () => { + const throughput = CLIUtils.calculateThroughputMBps(5242880, 2000); + expect(throughput).toBe(2.5); + }); + + it('should handle very small time values', () => { + const throughput = CLIUtils.calculateThroughputMBps(1048576, 100); + expect(throughput).toBe(10); + }); + + it('should handle large byte values', () => { + const throughput = CLIUtils.calculateThroughputMBps(104857600, 10000); + expect(throughput).toBe(10); + }); + + it('should handle negative bytes gracefully', () => { + const throughput = CLIUtils.calculateThroughputMBps(-1048576, 1000); + expect(throughput).toBe(0); + }); + + it('should handle negative time gracefully', () => { + const throughput = CLIUtils.calculateThroughputMBps(1048576, -1000); + expect(throughput).toBe(0); + }); + }); });