diff --git a/deletion-speed-benchmark.md b/deletion-speed-benchmark.md new file mode 100644 index 00000000..ea2a8a24 --- /dev/null +++ b/deletion-speed-benchmark.md @@ -0,0 +1,46 @@ +# Unix + +## Directorio grande + +- Ficheros: 277866 +- Tamaño: 4,35GB + +### Benchmark implementado + +- Método clásico (v0.12.2 - rm -rf): 9,90s +- Rsync: 14,82s +- Perl: 17,47s +- Find: 18,23s +- Rm-rf: 8,94s + +### Benchmark directo + +``` +❯ mkdir /tmp/empty +❯ time rsync -a --delete "/tmp/empty/" "node_modules2/" +0.87s user 10.95s system 56% cpu 20.922 total + +❯ time rm -rf node_modules3 +0.31s user 9.63s system 78% cpu 12.619 total + +❯ time perl -e 'use File::Path qw(remove_tree); remove_tree("node_modules", {verbose => 0, safe => 0});' +4.69s user 13.36s system 78% cpu 22.895 total +``` + +## Directorio pequeño + +- Ficheros: 92622 +- Tamaño: 1,50GB + +### Benchmark directo + +``` +❯ time rsync -a --delete "/tmp/empty/" "node_modules/" +0.20s user 3.10s system 95% cpu 3.444 total + +❯ time rsync -a --delete --ignore-errors --whole-file --inplace --remove-source-files /tmp/empty/ node_modules/ +0.22s user 3.12s system 95% cpu 3.502 total + +❯ time rm -rf node_modules +0.13s user 3.03s system 95% cpu 3.321 total +``` diff --git a/src/core/interfaces/file-service.interface.ts b/src/core/interfaces/file-service.interface.ts index 79e4021d..f7dd4071 100644 --- a/src/core/interfaces/file-service.interface.ts +++ b/src/core/interfaces/file-service.interface.ts @@ -3,6 +3,7 @@ import { GetNewestFileResult, RiskAnalysis } from '../interfaces/index.js'; import { ScanOptions } from './folder.interface.js'; import { Observable } from 'rxjs'; import { IsValidRootFolderResult } from './npkill.interface.js'; +import { DeletionStrategyManager } from '@core/services/files/index.js'; /** * Core file system operations service for npkill. @@ -11,6 +12,7 @@ import { IsValidRootFolderResult } from './npkill.interface.js'; export interface IFileService { /** Worker service for handling file operations in background threads. */ fileWorkerService: FileWorkerService; + delStrategyManager: DeletionStrategyManager; /** * Calculates the total size of a directory. diff --git a/src/core/npkill.ts b/src/core/npkill.ts index b3432296..4d5e1352 100644 --- a/src/core/npkill.ts +++ b/src/core/npkill.ts @@ -1,4 +1,7 @@ -import { FileWorkerService } from './services/files/index.js'; +import { + DeletionStrategyManager, + FileWorkerService, +} from './services/files/index.js'; import { from, Observable } from 'rxjs'; import { catchError, filter, map, mergeMap, take, tap } from 'rxjs/operators'; import { ScanStatus } from './interfaces/search-status.model.js'; @@ -171,14 +174,21 @@ function createDefaultServices( ); const streamService = new StreamService(); const resultsService = new ResultsService(); + const delStrategyManager = new DeletionStrategyManager(actualLogger); - const OSService = OSServiceMap[process.platform]; + const OSService = OSServiceMap[ + process.platform + ] as (typeof OSServiceMap)[keyof typeof OSServiceMap]; if (typeof OSService === 'undefined') { throw new Error( `Unsupported platform: ${process.platform}. Cannot load OS service.`, ); } - const fileService = new OSService(streamService, fileWorkerService); + const fileService = new OSService( + streamService, + fileWorkerService, + delStrategyManager, + ); return { logger: actualLogger, diff --git a/src/core/services/files/files.service.ts b/src/core/services/files/files.service.ts index f5021c41..2b83eb94 100644 --- a/src/core/services/files/files.service.ts +++ b/src/core/services/files/files.service.ts @@ -11,12 +11,18 @@ import { readdir, stat } from 'fs/promises'; import { map, Observable, Subject } from 'rxjs'; import { FileWorkerService } from './files.worker.service.js'; import { IsValidRootFolderResult } from '@core/interfaces/npkill.interface.js'; +import { DeletionStrategyManager } from './strategies/strategy-manager.js'; export abstract class FileService implements IFileService { public fileWorkerService: FileWorkerService; + public delStrategyManager: DeletionStrategyManager; - constructor(fileWorkerService: FileWorkerService) { + constructor( + fileWorkerService: FileWorkerService, + delStrategyManager: DeletionStrategyManager, + ) { this.fileWorkerService = fileWorkerService; + this.delStrategyManager = delStrategyManager; } abstract deleteDir(path: string): Promise; diff --git a/src/core/services/files/index.ts b/src/core/services/files/index.ts index 6ca8b802..b06c7215 100644 --- a/src/core/services/files/index.ts +++ b/src/core/services/files/index.ts @@ -2,3 +2,4 @@ export * from './files.service.js'; export * from './files.worker.service.js'; export * from './unix-files.service.js'; export * from './windows-files.service.js'; +export * from './strategies/index.js'; diff --git a/src/core/services/files/strategies/deletion-strategy.interface.ts b/src/core/services/files/strategies/deletion-strategy.interface.ts new file mode 100644 index 00000000..f00a1ccd --- /dev/null +++ b/src/core/services/files/strategies/deletion-strategy.interface.ts @@ -0,0 +1,16 @@ +export interface IDeletionStrategy { + readonly name: string; + + /** + * Checks if this deletion strategy is available on the current system. + * @returns Promise resolving to true if the strategy can be used. + */ + isAvailable(): Promise; + + /** + * Deletes a directory using this strategy. + * @param path Path to the directory to delete. + * @returns Promise resolving to true if deletion was successful. + */ + delete(path: string): Promise; +} diff --git a/src/core/services/files/strategies/index.ts b/src/core/services/files/strategies/index.ts new file mode 100644 index 00000000..ccd88898 --- /dev/null +++ b/src/core/services/files/strategies/index.ts @@ -0,0 +1,9 @@ +export * from './deletion-strategy.interface.js'; +export * from './unix-strategies/rsync-deletion.strategy.js'; +export * from './unix-strategies/find-deletion.strategy.js'; +export * from './unix-strategies/rm-rf-deletion.strategy.js'; +export * from './unix-strategies/perl-deletion.strategy.js'; +export * from './windows-strategies/robocopy-deletion.strategy.js'; +export * from './windows-strategies/powershell-deletion.strategy.js'; +export * from './windows-strategies/node-rm-deletion.strategy.js'; +export * from './strategy-manager.js'; diff --git a/src/core/services/files/strategies/strategy-manager.ts b/src/core/services/files/strategies/strategy-manager.ts new file mode 100644 index 00000000..8efbc205 --- /dev/null +++ b/src/core/services/files/strategies/strategy-manager.ts @@ -0,0 +1,105 @@ +import { LoggerService } from '@core/services/logger.service.js'; +import { IDeletionStrategy } from './deletion-strategy.interface.js'; + +/** + * Manages and coordinates different deletion strategies. + */ +export class DeletionStrategyManager { + private availableStrategies: IDeletionStrategy[] = []; + private selectedStrategy: IDeletionStrategy | null = null; + private strategiesInitialized = false; + + constructor(private readonly logger: LoggerService) {} + + /** + * Registers a deletion strategy. + * Strategies are tested in the order they are registered. + * @param strategy The strategy to register. + */ + registerStrategy(strategy: IDeletionStrategy): void { + this.availableStrategies.push(strategy); + this.logger.info( + `[DeletionStrategyManager] Registered ${strategy.name} strategy.`, + ); + } + + /** + * Initializes and selects the best available deletion strategy. + * This method tests strategies in registration order and caches the first available one. + * @returns Promise resolving to the selected strategy, or null if none are available. + */ + async initializeStrategy(): Promise { + if (this.strategiesInitialized && this.selectedStrategy) { + return this.selectedStrategy; + } + + for (const strategy of this.availableStrategies) { + try { + const isAvailable = await strategy.isAvailable(); + if (isAvailable) { + this.selectedStrategy = strategy; + this.strategiesInitialized = true; + this.logger.info(`[DeletionStrategyManager] Using ${strategy.name}.`); + return strategy; + } + } catch (error) { + // Strategy check failed, continue to next one + this.logger.warn( + `[DeletionStrategyManager] ${strategy.name} strategy unavailable.`, + ); + } + } + + this.strategiesInitialized = true; + return null; + } + + /** + * Deletes a directory using the selected strategy. + * Automatically initializes strategy if not already done. + * @param path Path to the directory to delete. + * @returns Promise resolving to true if deletion was successful. + * @throws Error if no strategies are available or deletion fails. + */ + async deleteDirectory(path: string): Promise { + if (!this.selectedStrategy) { + await this.initializeStrategy(); + } + + if (!this.selectedStrategy) { + throw new Error('No deletion strategies are available on this system'); + } + + try { + return await this.selectedStrategy.delete(path); + } catch (error) { + throw new Error( + `Deletion failed using ${this.selectedStrategy.name} strategy: ${error}`, + ); + } + } + + /** + * Gets the currently selected strategy. + * @returns The selected strategy or null if none is selected. + */ + getSelectedStrategy(): IDeletionStrategy | null { + return this.selectedStrategy; + } + + /** + * Gets all registered strategies. + * @returns Array of all registered strategies. + */ + getAllStrategies(): readonly IDeletionStrategy[] { + return [...this.availableStrategies]; + } + + /** + * Resets the strategy selection, forcing re-evaluation on next deletion. + */ + resetStrategy(): void { + this.selectedStrategy = null; + this.strategiesInitialized = false; + } +} diff --git a/src/core/services/files/strategies/unix-strategies/find-deletion.strategy.ts b/src/core/services/files/strategies/unix-strategies/find-deletion.strategy.ts new file mode 100644 index 00000000..49755c1b --- /dev/null +++ b/src/core/services/files/strategies/unix-strategies/find-deletion.strategy.ts @@ -0,0 +1,32 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class FindDeletionStrategy implements IDeletionStrategy { + readonly name = 'find'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('command -v find', (error) => { + resolve(error === null); + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + const command = `find "${path}" -depth -type f -delete && find "${path}" -depth -type d -delete`; + + exec(command, (error, _stdout, stderr) => { + if (error !== null) { + reject(error); + return; + } + if (stderr !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/strategies/unix-strategies/perl-deletion.strategy.ts b/src/core/services/files/strategies/unix-strategies/perl-deletion.strategy.ts new file mode 100644 index 00000000..acc9e8b5 --- /dev/null +++ b/src/core/services/files/strategies/unix-strategies/perl-deletion.strategy.ts @@ -0,0 +1,41 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class PerlDeletionStrategy implements IDeletionStrategy { + readonly name = 'perl'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('command -v perl', (error) => { + if (error) { + resolve(false); + return; + } + + // Check if File::Path module is available + exec('perl -e "use File::Path qw(remove_tree);"', (perlError) => { + resolve(perlError === null); + }); + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + const perlScript = `use File::Path qw(remove_tree); remove_tree("${path}", {verbose => 0, safe => 0});`; + const command = `perl -e '${perlScript}'`; + + exec(command, (error, _stdout, stderr) => { + if (error !== null) { + reject(error); + return; + } + if (stderr !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/strategies/unix-strategies/rm-rf-deletion.strategy.ts b/src/core/services/files/strategies/unix-strategies/rm-rf-deletion.strategy.ts new file mode 100644 index 00000000..cbc44303 --- /dev/null +++ b/src/core/services/files/strategies/unix-strategies/rm-rf-deletion.strategy.ts @@ -0,0 +1,32 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class RmRfDeletionStrategy implements IDeletionStrategy { + readonly name = 'rm-rf'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('command -v rm', (error) => { + resolve(error === null); + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + const command = `rm -rf "${path}"`; + + exec(command, (error, _stdout, stderr) => { + if (error !== null) { + reject(error); + return; + } + if (stderr !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/strategies/unix-strategies/rsync-deletion.strategy.ts b/src/core/services/files/strategies/unix-strategies/rsync-deletion.strategy.ts new file mode 100644 index 00000000..fd086363 --- /dev/null +++ b/src/core/services/files/strategies/unix-strategies/rsync-deletion.strategy.ts @@ -0,0 +1,39 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class RsyncDeletionStrategy implements IDeletionStrategy { + readonly name = 'rsync'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('command -v rsync', (error) => { + resolve(error === null); + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + const tempDir = '/tmp/npkill_empty_' + Date.now(); + const commands = [ + `mkdir -p "${tempDir}"`, + `rsync -a --delete "${tempDir}/" "${path}/"`, + `rmdir "${path}"`, + `rm -rf "${tempDir}"`, + ]; + + const command = commands.join(' && '); + exec(command, (error, _stdout, stderr) => { + if (error !== null) { + reject(error); + return; + } + if (stderr !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/strategies/windows-strategies/node-rm-deletion.strategy.ts b/src/core/services/files/strategies/windows-strategies/node-rm-deletion.strategy.ts new file mode 100644 index 00000000..19cba5ec --- /dev/null +++ b/src/core/services/files/strategies/windows-strategies/node-rm-deletion.strategy.ts @@ -0,0 +1,29 @@ +import { rm } from 'fs/promises'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class NodeRmDeletionStrategy implements IDeletionStrategy { + readonly name = 'node-rm'; + + async isAvailable(): Promise { + return Promise.resolve(true); + } + + async delete(path: string): Promise { + try { + await rm(path, { + recursive: true, + force: true, + // maxRetries: Automatically retry on Windows if files are locked + maxRetries: process.platform === 'win32' ? 3 : 0, + retryDelay: 100, + }); + return true; + } catch (error) { + // If the directory doesn't exist, consider it a success + if (error && (error as any).code === 'ENOENT') { + return true; + } + throw error; + } + } +} diff --git a/src/core/services/files/strategies/windows-strategies/powershell-deletion.strategy.ts b/src/core/services/files/strategies/windows-strategies/powershell-deletion.strategy.ts new file mode 100644 index 00000000..5f9dd04b --- /dev/null +++ b/src/core/services/files/strategies/windows-strategies/powershell-deletion.strategy.ts @@ -0,0 +1,54 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class PowerShellDeletionStrategy implements IDeletionStrategy { + readonly name = 'powershell'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('powershell -Command "Get-Command Remove-Item" 2>nul', (error) => { + resolve(error === null); + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + // Use PowerShell with optimized flags for fast deletion + // -Force: Override read-only and hidden attributes + // -Recurse: Delete all child items + // -ErrorAction Stop: Stop on first error + // Get-ChildItem | Remove-Item pattern is often faster than Remove-Item alone for large dirs + const psCommand = ` + if (Test-Path '${path}') { + try { + Get-ChildItem -Path '${path}' -Force -Recurse | Remove-Item -Force -Recurse -ErrorAction Stop + Remove-Item -Path '${path}' -Force -ErrorAction Stop + Write-Output 'Success' + } catch { + Write-Error $_.Exception.Message + exit 1 + } + } else { + Write-Output 'Success' + } + ` + .replace(/\s+/g, ' ') + .trim(); + + const command = `powershell -Command "${psCommand}"`; + + exec(command, (error, stdout, stderr) => { + if (error !== null) { + reject(error); + return; + } + if (stderr && stderr.trim() !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/strategies/windows-strategies/robocopy-deletion.strategy.ts b/src/core/services/files/strategies/windows-strategies/robocopy-deletion.strategy.ts new file mode 100644 index 00000000..cd670b46 --- /dev/null +++ b/src/core/services/files/strategies/windows-strategies/robocopy-deletion.strategy.ts @@ -0,0 +1,47 @@ +import { exec } from 'child_process'; +import { IDeletionStrategy } from '../deletion-strategy.interface.js'; + +export class RobocopyDeletionStrategy implements IDeletionStrategy { + readonly name = 'robocopy'; + + async isAvailable(): Promise { + return new Promise((resolve) => { + exec('robocopy /? >nul 2>&1', (error) => { + resolve(error === null || error.code === 16); // robocopy returns 16 for help + }); + }); + } + + async delete(path: string): Promise { + return new Promise((resolve, reject) => { + // Create a temporary empty directory + const tempDir = `${process.env.TEMP || process.env.TMP || 'C:\\temp'}\\npkill_empty_${Date.now()}`; + + // Use robocopy to mirror empty directory to target (purge), then remove both + // /MIR = Mirror directory tree (equivalent to /E plus /PURGE) + // /NFL = No File List (don't log file names) + // /NDL = No Directory List (don't log directory names) + // /NJH = No Job Header + // /NJS = No Job Summary + // /NP = No Progress + const command = `mkdir "${tempDir}" && robocopy "${tempDir}" "${path}" /MIR /NFL /NDL /NJH /NJS /NP && rmdir "${path}" && rmdir "${tempDir}"`; + + exec(command, (error, stdout, stderr) => { + // Robocopy returns different exit codes that aren't necessarily errors + // 0 = No files copied, no failures + // 1 = Files copied successfully + // 2 = Extra files or directories detected + // Exit codes 0-7 are generally successful + if (error && error.code && error.code > 7) { + reject(error); + return; + } + if (stderr && stderr.trim() !== '') { + reject(new Error(stderr)); + return; + } + resolve(true); + }); + }); + } +} diff --git a/src/core/services/files/unix-files.service.ts b/src/core/services/files/unix-files.service.ts index 772a2f79..e9c6823f 100644 --- a/src/core/services/files/unix-files.service.ts +++ b/src/core/services/files/unix-files.service.ts @@ -1,33 +1,46 @@ -import { exec } from 'child_process'; - import { FileService } from './files.service.js'; -import { Observable, Subject } from 'rxjs'; import { StreamService } from '../stream.service.js'; import { FileWorkerService } from './files.worker.service.js'; -import { ScanOptions } from '@core/index.js'; +import { DeletionStrategyManager } from './strategies/strategy-manager.js'; +import { + FindDeletionStrategy, + PerlDeletionStrategy, + RmRfDeletionStrategy, + RsyncDeletionStrategy, +} from './index.js'; export class UnixFilesService extends FileService { constructor( protected streamService: StreamService, public override fileWorkerService: FileWorkerService, + delstrategyManager: DeletionStrategyManager, ) { - super(fileWorkerService); + super(fileWorkerService, delstrategyManager); + this.initializeStrategies(); } async deleteDir(path: string): Promise { - return new Promise((resolve, reject) => { - const command = `rm -rf "${path}"`; - exec(command, (error, stdout, stderr) => { - if (error !== null) { - reject(error); - return; - } - if (stderr !== '') { - reject(stderr); - return; - } - resolve(true); - }); - }); + try { + return await this.delStrategyManager.deleteDirectory(path); + } catch (error) { + throw new Error(`Failed to delete directory ${path}: ${error}`); + } + } + + getSelectedDeletionStrategy(): string | null { + const strategy = this.delStrategyManager.getSelectedStrategy(); + return strategy ? strategy.name : null; + } + + resetDeletionStrategy(): void { + this.delStrategyManager.resetStrategy(); + } + + private initializeStrategies() { + // Order matter! + this.delStrategyManager.registerStrategy(new PerlDeletionStrategy()); + this.delStrategyManager.registerStrategy(new RsyncDeletionStrategy()); + this.delStrategyManager.registerStrategy(new FindDeletionStrategy()); + this.delStrategyManager.registerStrategy(new RmRfDeletionStrategy()); } } diff --git a/src/core/services/files/windows-files.service.ts b/src/core/services/files/windows-files.service.ts index 9793550e..85e7269b 100644 --- a/src/core/services/files/windows-files.service.ts +++ b/src/core/services/files/windows-files.service.ts @@ -1,20 +1,44 @@ -import { Subject, Observable } from 'rxjs'; import { FileService } from './files.service.js'; import { FileWorkerService } from './files.worker.service.js'; -import { ScanOptions } from '@core/index.js'; import { StreamService } from '../stream.service.js'; -import { rm } from 'fs/promises'; +import { DeletionStrategyManager } from './strategies/strategy-manager.js'; +import { + RobocopyDeletionStrategy, + PowerShellDeletionStrategy, + NodeRmDeletionStrategy, +} from './strategies/index.js'; export class WindowsFilesService extends FileService { constructor( - private readonly streamService: StreamService, + protected streamService: StreamService, public override fileWorkerService: FileWorkerService, + delstrategyManager: DeletionStrategyManager, ) { - super(fileWorkerService); + super(fileWorkerService, delstrategyManager); + this.initializeStrategies(); } async deleteDir(path: string): Promise { - await rm(path, { recursive: true, force: true }); - return true; + try { + return await this.delStrategyManager.deleteDirectory(path); + } catch (error) { + throw new Error(`Failed to delete directory ${path}: ${error}`); + } + } + + getSelectedDeletionStrategy(): string | null { + const strategy = this.delStrategyManager.getSelectedStrategy(); + return strategy ? strategy.name : null; + } + + resetDeletionStrategy(): void { + this.delStrategyManager.resetStrategy(); + } + + private initializeStrategies() { + // Order matters! + this.delStrategyManager.registerStrategy(new RobocopyDeletionStrategy()); + this.delStrategyManager.registerStrategy(new PowerShellDeletionStrategy()); + this.delStrategyManager.registerStrategy(new NodeRmDeletionStrategy()); } } diff --git a/tests/core/services/files/strategies/strategy-manager.test.ts b/tests/core/services/files/strategies/strategy-manager.test.ts new file mode 100644 index 00000000..d66ebc7c --- /dev/null +++ b/tests/core/services/files/strategies/strategy-manager.test.ts @@ -0,0 +1,122 @@ +import { DeletionStrategyManager } from '../../../../../src/core/services/files/strategies/strategy-manager.js'; +import { IDeletionStrategy } from '../../../../../src/core/services/files/strategies/deletion-strategy.interface.js'; + +class MockStrategy implements IDeletionStrategy { + constructor( + public readonly name: string, + private availabilityResult: boolean = true, + private deleteResult: boolean = true, + ) {} + + async isAvailable(): Promise { + return this.availabilityResult; + } + + async delete(path: string): Promise { + if (!this.deleteResult) { + throw new Error(`Mock deletion failed for ${path}`); + } + return this.deleteResult; + } +} + +describe('DeletionStrategyManager', () => { + let manager: DeletionStrategyManager; + + beforeEach(() => { + manager = new DeletionStrategyManager(); + }); + + describe('Strategy Selection', () => { + it('should initialize and select the first available strategy', async () => { + // Register some mock strategies first + manager.registerStrategy(new MockStrategy('mock1', true)); + manager.registerStrategy(new MockStrategy('mock2', false)); + manager.registerStrategy(new MockStrategy('mock3', true)); + + const strategy = await manager.initializeStrategy(); + expect(strategy).toBeDefined(); + expect(strategy?.name).toBe('mock1'); // Should select the first available one + }); + + it('should cache the selected strategy after initialization', async () => { + manager.registerStrategy(new MockStrategy('mock1', true)); + + const firstCall = await manager.initializeStrategy(); + const secondCall = await manager.initializeStrategy(); + expect(firstCall).toBe(secondCall); + }); + + it('should return the same strategy when called multiple times', async () => { + manager.registerStrategy(new MockStrategy('mock1', true)); + + const strategy1 = await manager.initializeStrategy(); + const strategy2 = manager.getSelectedStrategy(); + expect(strategy1).toBe(strategy2); + }); + }); + + describe('Directory Deletion', () => { + it('should delete directory using selected strategy', async () => { + manager.registerStrategy(new MockStrategy('mock1', true, true)); + + const result = await manager.deleteDirectory('/tmp/test'); + expect(result).toBe(true); + }); + + it('should throw error when no strategies are available', async () => { + // Don't register any strategies + manager.resetStrategy(); + + try { + await manager.deleteDirectory('/tmp/test'); + fail('Should have thrown an error'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + 'No deletion strategies are available', + ); + } + }); + }); + + describe('Strategy Management', () => { + it('should reset strategy selection', async () => { + manager.registerStrategy(new MockStrategy('mock1', true)); + + await manager.initializeStrategy(); + const firstStrategy = manager.getSelectedStrategy(); + expect(firstStrategy).toBeDefined(); + + manager.resetStrategy(); + const afterReset = manager.getSelectedStrategy(); + expect(afterReset).toBeNull(); + }); + + it('should return all registered strategies', () => { + manager.registerStrategy(new MockStrategy('mock1', true)); + manager.registerStrategy(new MockStrategy('mock2', true)); + + const strategies = manager.getAllStrategies(); + expect(strategies.length).toBe(2); + expect(strategies[0].name).toBe('mock1'); + expect(strategies[1].name).toBe('mock2'); + }); + }); +}); + +describe('Individual Deletion Strategies', () => { + it('should test strategy availability checks', async () => { + const manager = new DeletionStrategyManager(); + // Register some mock strategies to test + manager.registerStrategy(new MockStrategy('mock1', true)); + manager.registerStrategy(new MockStrategy('mock2', false)); + + const strategies = manager.getAllStrategies(); + + for (const strategy of strategies) { + const isAvailable = await strategy.isAvailable(); + expect(typeof isAvailable).toBe('boolean'); + } + }); +}); diff --git a/tests/core/services/files/unix-files.service.integration.test.ts b/tests/core/services/files/unix-files.service.integration.test.ts new file mode 100644 index 00000000..5d04d5eb --- /dev/null +++ b/tests/core/services/files/unix-files.service.integration.test.ts @@ -0,0 +1,113 @@ +import { UnixFilesService } from '../../../../src/core/services/files/unix-files.service.js'; +import { StreamService } from '../../../../src/core/services/stream.service.js'; +import { FileWorkerService } from '../../../../src/core/services/files/files.worker.service.js'; +import { LoggerService } from '../../../../src/core/services/logger.service.js'; +import { ScanStatus } from '../../../../src/core/interfaces/search-status.model.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import { tmpdir } from 'os'; + +describe('UnixFilesService Integration', () => { + let service: UnixFilesService; + let streamService: StreamService; + let fileWorkerService: FileWorkerService; + let testDir: string; + + beforeEach(() => { + streamService = new StreamService(); + const logger = new LoggerService(); + const scanStatus = new ScanStatus(); + fileWorkerService = new FileWorkerService(logger, scanStatus); + service = new UnixFilesService(streamService, fileWorkerService); + + // Create a temporary test directory + testDir = path.join(tmpdir(), 'npkill-test-' + Date.now()); + }); + + afterEach(() => { + // Clean up test directory if it still exists + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('Strategy Information', () => { + it('should return strategy information', async () => { + // First trigger initialization by doing a deletion or calling getSelectedDeletionStrategy after initialization + await service + .deleteDir('/tmp/non-existent-test-' + Date.now()) + .catch(() => { + // This might fail, but that's ok for testing strategy selection + }); + + const strategyName = service.getSelectedDeletionStrategy(); + expect(strategyName === null || typeof strategyName === 'string').toBe( + true, + ); + + // If a strategy was selected, it should be one of the expected ones + if (strategyName) { + expect(['perl', 'rsync', 'find', 'rm-rf'].includes(strategyName)).toBe( + true, + ); + } + }); + + it('should allow strategy reset', () => { + service.resetDeletionStrategy(); + const strategy = service.getSelectedDeletionStrategy(); + expect(strategy).toBe(null); + }); + }); + + describe('Directory Deletion', () => { + it('should delete a directory with files', async () => { + // Create test directory structure + const subDir = path.join(testDir, 'subdir'); + fs.mkdirSync(testDir, { recursive: true }); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(testDir, 'file1.txt'), 'test content'); + fs.writeFileSync(path.join(subDir, 'file2.txt'), 'test content 2'); + + // Verify directory exists + expect(fs.existsSync(testDir)).toBe(true); + + // Delete using the service + const result = await service.deleteDir(testDir); + expect(result).toBe(true); + + // Verify directory is deleted + expect(fs.existsSync(testDir)).toBe(false); + }); + + it('should handle deletion of non-existent directory', async () => { + const nonExistentPath = path.join(tmpdir(), 'non-existent-' + Date.now()); + + try { + await service.deleteDir(nonExistentPath); + // Some strategies might succeed when deleting non-existent dirs + // This is actually expected behavior for rm -rf + } catch (error) { + // Other strategies might fail, which is also acceptable + expect(error).toBeDefined(); + } + }); + }); + + describe('Error Handling', () => { + it('should provide meaningful error messages', async () => { + // Try to delete a path that would cause permission issues + const restrictedPath = '/root/restricted-test'; + + try { + await service.deleteDir(restrictedPath); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + 'Failed to delete directory', + ); + expect((error as Error).message).toContain(restrictedPath); + } + }); + }); +});