Fix file retention after updates on macOS #417
This fixes issue #417 where autoupdate installer files were not deleted on macOS, leading to accumulation of old installers. Key changes: - Store update files in application-specific directory - Clear update files directory on every app launch Other supporting changes: - Refactor file system operations to be more testable and reusable - Improve separation of concerns in directory management - Enhance dependency injection for auto-update logic - Fix async completion to support `await` operations - Add additional logging and revise some log messages during updates
This commit is contained in:
@@ -1,22 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import type { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
import { ScriptDirectoryProviderStub } from '@tests/unit/shared/Stubs/ScriptDirectoryProviderStub';
|
||||
import { ApplicationDirectoryProviderStub } from '@tests/unit/shared/Stubs/ApplicationDirectoryProviderStub';
|
||||
import type { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator';
|
||||
import { FilenameGeneratorStub } from '@tests/unit/shared/Stubs/FilenameGeneratorStub';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||
import type { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
|
||||
import type { ApplicationDirectoryProvider, DirectoryCreationErrorType } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
|
||||
describe('ScriptFileCreationOrchestrator', () => {
|
||||
describe('createScriptFile', () => {
|
||||
@@ -25,15 +23,15 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
// arrange
|
||||
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
|
||||
const expectedScriptDirectory = '/expected-script-directory';
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const context = new ScriptFileCreatorTestSetup()
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withLocation(
|
||||
new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator),
|
||||
)
|
||||
.withFileSystem(filesystem))
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryProvider(
|
||||
new ScriptDirectoryProviderStub().withDirectoryPath(expectedScriptDirectory),
|
||||
new ApplicationDirectoryProviderStub().withDirectoryPath(
|
||||
'script-runs',
|
||||
expectedScriptDirectory,
|
||||
),
|
||||
);
|
||||
|
||||
// act
|
||||
@@ -52,13 +50,12 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
it('correctly generates filename', async () => {
|
||||
// arrange
|
||||
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const expectedFilename = 'expected-script-file-name';
|
||||
const context = new ScriptFileCreatorTestSetup()
|
||||
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename))
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withFileSystem(filesystem)
|
||||
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)));
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||
@@ -97,15 +94,13 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
const expectedPath = 'expected-script-path';
|
||||
const filename = 'filename';
|
||||
const directoryPath = 'directory-path';
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withJoinResult(expectedPath, directoryPath, filename);
|
||||
const context = new ScriptFileCreatorTestSetup()
|
||||
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(filename))
|
||||
.withDirectoryProvider(new ScriptDirectoryProviderStub().withDirectoryPath(directoryPath))
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withFileSystem(filesystem)
|
||||
.withLocation(
|
||||
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, filename),
|
||||
));
|
||||
.withDirectoryProvider(new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('script-runs', directoryPath))
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||
@@ -169,11 +164,11 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
expectedErrorMessage: 'Error when combining paths',
|
||||
expectLogs: true,
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const locationStub = new LocationOpsStub();
|
||||
locationStub.combinePaths = () => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.combinePaths = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
...FileWriteOperationErrors.map((writeError): FileCreationFailureTestScenario => ({
|
||||
@@ -214,23 +209,40 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
return setup.withFilenameGenerator(filenameGenerator);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'script directory provision failure',
|
||||
expectedErrorType: 'DirectoryCreationError',
|
||||
expectedErrorMessage: 'Error when providing directory',
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup, errorMessage, errorType) => {
|
||||
const directoryProvider = new ScriptDirectoryProviderStub();
|
||||
directoryProvider.provideScriptDirectory = () => Promise.resolve({
|
||||
success: false,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: errorType,
|
||||
},
|
||||
});
|
||||
return setup.withDirectoryProvider(directoryProvider);
|
||||
},
|
||||
},
|
||||
...(() => {
|
||||
const directoryErrorScenarios: Record<DirectoryCreationErrorType, {
|
||||
readonly directoryErrorMessage: string;
|
||||
}> = {
|
||||
DirectoryWriteError: {
|
||||
directoryErrorMessage: 'Injected error when writing to directory',
|
||||
},
|
||||
PathConstructionError: {
|
||||
directoryErrorMessage: 'Injected error when constructing path',
|
||||
},
|
||||
UserDataFolderRetrievalError: {
|
||||
directoryErrorMessage: 'Injected error when locating user data folder',
|
||||
},
|
||||
};
|
||||
return Object.entries(directoryErrorScenarios).map(([
|
||||
directoryErrorType, { directoryErrorMessage },
|
||||
]): FileCreationFailureTestScenario => ({
|
||||
description: `script directory creation failure: ${directoryErrorType}`,
|
||||
expectedErrorType: 'DirectoryCreationError',
|
||||
expectedErrorMessage: `[${directoryErrorType}] ${directoryErrorMessage}`,
|
||||
expectLogs: false,
|
||||
buildFaultyContext: (setup) => {
|
||||
const directoryProvider = new ApplicationDirectoryProviderStub();
|
||||
directoryProvider.provideDirectory = () => Promise.resolve({
|
||||
success: false,
|
||||
error: {
|
||||
type: directoryErrorType as DirectoryCreationErrorType,
|
||||
message: directoryErrorMessage,
|
||||
},
|
||||
});
|
||||
return setup.withDirectoryProvider(directoryProvider);
|
||||
},
|
||||
}));
|
||||
})(),
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
|
||||
@@ -276,11 +288,11 @@ describe('ScriptFileCreationOrchestrator', () => {
|
||||
});
|
||||
|
||||
class ScriptFileCreatorTestSetup {
|
||||
private system: SystemOperations = new SystemOperationsStub();
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub();
|
||||
|
||||
private directoryProvider: ScriptDirectoryProvider = new ScriptDirectoryProviderStub();
|
||||
private directoryProvider: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
@@ -298,7 +310,7 @@ class ScriptFileCreatorTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDirectoryProvider(directoryProvider: ScriptDirectoryProvider): this {
|
||||
public withDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
|
||||
this.directoryProvider = directoryProvider;
|
||||
return this;
|
||||
}
|
||||
@@ -308,8 +320,8 @@ class ScriptFileCreatorTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSystem(system: SystemOperations): this {
|
||||
this.system = system;
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -330,7 +342,7 @@ class ScriptFileCreatorTestSetup {
|
||||
|
||||
public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> {
|
||||
const creator = new ScriptFileCreationOrchestrator(
|
||||
this.system,
|
||||
this.fileSystem,
|
||||
this.filenameGenerator,
|
||||
this.directoryProvider,
|
||||
this.fileWriter,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FileSystemExecutablePermissionSetter } from '@/infrastructure/CodeRunne
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
@@ -15,7 +15,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
it('sets permissions on the specified file', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
const context = new TestContext()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
@@ -33,7 +33,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
it('applies the correct permissions mode', async () => {
|
||||
// arrange
|
||||
const expectedMode = '755';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
|
||||
it('reports success when permissions are set without errors', async () => {
|
||||
// arrange
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.resolve();
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
@@ -67,7 +67,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
// arrange
|
||||
const thrownErrorMessage = 'File system error';
|
||||
const expectedErrorMessage = `Error setting script file permission: ${thrownErrorMessage}`;
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
@@ -84,7 +84,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
it('returns expected error type when filesystem throws', async () => {
|
||||
// arrange
|
||||
const expectedErrorType: CodeRunErrorType = 'FilePermissionChangeError';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error('File system error'));
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
@@ -103,7 +103,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
|
||||
// arrange
|
||||
const thrownErrorMessage = 'File system error';
|
||||
const logger = new LoggerStub();
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const fileSystem = new FileSystemOperationsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
|
||||
const context = new TestContext()
|
||||
.withLogger(logger)
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { FileReadbackVerificationErrors, FileWriteOperationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
|
||||
import { NodePathOperationsStub } from './NodePathOperationsStub';
|
||||
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ExecutionSubdirectory, PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import type { DirectoryCreationErrorType, DirectoryType } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
import { PersistentApplicationDirectoryProvider, SubdirectoryNames } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
|
||||
describe('PersistentDirectoryProvider', () => {
|
||||
describe('PersistentApplicationDirectoryProvider', () => {
|
||||
describe('createDirectory', () => {
|
||||
describe('path construction', () => {
|
||||
it('bases path on user directory', async () => {
|
||||
// arrange
|
||||
const expectedBaseDirectory = 'base-directory';
|
||||
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
||||
const locationOps = new LocationOpsStub()
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withUserDirectoryResult(expectedBaseDirectory)
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withOperatingSystem(new OperatingSystemOpsStub()
|
||||
.withUserDirectoryResult(expectedBaseDirectory))
|
||||
.withLocation(locationOps));
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
@@ -33,51 +28,66 @@ describe('PersistentDirectoryProvider', () => {
|
||||
expectTrue(success);
|
||||
const actualBaseDirectory = directoryAbsolutePath.split(pathSegmentSeparator)[0];
|
||||
expect(actualBaseDirectory).to.equal(expectedBaseDirectory);
|
||||
const calls = locationOps.callHistory.filter((call) => call.methodName === 'combinePaths');
|
||||
const calls = fileSystemStub.callHistory.filter((call) => call.methodName === 'combinePaths');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [combinedBaseDirectory] = calls[0].args;
|
||||
expect(combinedBaseDirectory).to.equal(expectedBaseDirectory);
|
||||
});
|
||||
it('includes execution subdirectory in path', async () => {
|
||||
// arrange
|
||||
const expectedSubdirectory = ExecutionSubdirectory;
|
||||
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
||||
const locationOps = new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withLocation(locationOps));
|
||||
describe('includes correct execution subdirectory in path', () => {
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly givenDirectoryType: DirectoryType;
|
||||
readonly expectedSubdirectoryName: string;
|
||||
}[] = Object.entries(SubdirectoryNames).map(([type, name]) => ({
|
||||
description: `returns '${name}' for '${type}'`,
|
||||
givenDirectoryType: type as DirectoryType,
|
||||
expectedSubdirectoryName: name,
|
||||
}));
|
||||
testScenarios.forEach(({
|
||||
description, expectedSubdirectoryName, givenDirectoryType,
|
||||
}) => {
|
||||
it(description, async () => {
|
||||
// arrange
|
||||
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryType(givenDirectoryType);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
const actualSubdirectory = directoryAbsolutePath
|
||||
.split(pathSegmentSeparator)
|
||||
.pop();
|
||||
expect(actualSubdirectory).to.equal(expectedSubdirectory);
|
||||
const calls = locationOps.callHistory.filter((call) => call.methodName === 'combinePaths');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [,combinedSubdirectory] = calls[0].args;
|
||||
expect(combinedSubdirectory).to.equal(expectedSubdirectory);
|
||||
// assert
|
||||
expectTrue(success);
|
||||
const actualSubdirectory = directoryAbsolutePath
|
||||
.split(pathSegmentSeparator)
|
||||
.pop();
|
||||
expect(actualSubdirectory).to.equal(expectedSubdirectoryName);
|
||||
const calls = fileSystemStub.callHistory.filter((call) => call.methodName === 'combinePaths');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [,combinedSubdirectory] = calls[0].args;
|
||||
expect(combinedSubdirectory).to.equal(expectedSubdirectoryName);
|
||||
});
|
||||
});
|
||||
});
|
||||
it('forms full path correctly', async () => {
|
||||
// arrange
|
||||
const directoryType: DirectoryType = 'script-runs';
|
||||
const pathSegmentSeparator = '/';
|
||||
const baseDirectory = 'base-directory';
|
||||
const expectedDirectory = [baseDirectory, ExecutionSubdirectory].join(pathSegmentSeparator);
|
||||
const expectedDirectory = [baseDirectory, SubdirectoryNames[directoryType]]
|
||||
.join(pathSegmentSeparator);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator)
|
||||
.withUserDirectoryResult(baseDirectory);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withSystem(new SystemOperationsStub()
|
||||
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator))
|
||||
.withOperatingSystem(
|
||||
new OperatingSystemOpsStub().withUserDirectoryResult(baseDirectory),
|
||||
));
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryType(directoryType);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
expect(success).to.equal(true);
|
||||
expect(directoryAbsolutePath).to.equal(expectedDirectory);
|
||||
});
|
||||
});
|
||||
@@ -85,16 +95,16 @@ describe('PersistentDirectoryProvider', () => {
|
||||
it('creates directory with recursion', async () => {
|
||||
// arrange
|
||||
const expectedIsRecursive = true;
|
||||
const filesystem = new FileSystemOpsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
const calls = filesystem.callHistory.filter((call) => call.methodName === 'createDirectory');
|
||||
const calls = fileSystemStub.callHistory.filter((call) => call.methodName === 'createDirectory');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualPath, actualIsRecursive] = calls[0].args;
|
||||
expect(actualPath).to.equal(directoryAbsolutePath);
|
||||
@@ -104,7 +114,7 @@ describe('PersistentDirectoryProvider', () => {
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: CodeRunErrorType;
|
||||
readonly expectedErrorType: DirectoryCreationErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
buildFaultyContext(
|
||||
setup: PersistentDirectoryProviderTestSetup,
|
||||
@@ -113,40 +123,38 @@ describe('PersistentDirectoryProvider', () => {
|
||||
}> = [
|
||||
{
|
||||
description: 'path combination failure',
|
||||
expectedErrorType: 'DirectoryCreationError',
|
||||
expectedErrorType: 'PathConstructionError',
|
||||
expectedErrorMessage: 'Error when combining paths',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const locationStub = new LocationOpsStub();
|
||||
locationStub.combinePaths = () => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.combinePaths = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'user data retrieval failure',
|
||||
expectedErrorType: 'DirectoryCreationError',
|
||||
expectedErrorType: 'UserDataFolderRetrievalError',
|
||||
expectedErrorMessage: 'Error when locating user data directory',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const operatingSystemStub = new OperatingSystemOpsStub();
|
||||
operatingSystemStub.getUserDataDirectory = () => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.getUserDataDirectory = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withSystem(
|
||||
new SystemOperationsStub().withOperatingSystem(operatingSystemStub),
|
||||
);
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'directory creation failure',
|
||||
expectedErrorType: 'DirectoryCreationError',
|
||||
expectedErrorType: 'DirectoryWriteError',
|
||||
expectedErrorMessage: 'Error when creating directory',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOpsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.createDirectory = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -190,12 +198,14 @@ describe('PersistentDirectoryProvider', () => {
|
||||
});
|
||||
|
||||
class PersistentDirectoryProviderTestSetup {
|
||||
private system: SystemOperations = new SystemOperationsStub();
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
public withSystem(system: SystemOperations): this {
|
||||
this.system = system;
|
||||
private directoryType: DirectoryType = 'script-runs';
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -204,8 +214,13 @@ class PersistentDirectoryProviderTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideScriptDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> {
|
||||
const provider = new PersistentDirectoryProvider(this.system, this.logger);
|
||||
return provider.provideScriptDirectory();
|
||||
public withDirectoryType(directoryType: DirectoryType): this {
|
||||
this.directoryType = directoryType;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideScriptDirectory(): ReturnType<PersistentApplicationDirectoryProvider['provideDirectory']> {
|
||||
const provider = new PersistentApplicationDirectoryProvider(this.fileSystem, this.logger);
|
||||
return provider.provideDirectory(this.directoryType);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import type { FunctionKeys } from '@/TypeHelpers';
|
||||
import { sequenceEqual } from '@/application/Common/Array';
|
||||
import type { FileWriteErrorType } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import type { FileWriteErrorType } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { type FileReadWriteOperations, NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { FileReadWriteOperationsStub } from './FileReadWriteOperationsStub';
|
||||
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
|
||||
describe('NodeReadbackFileWriter', () => {
|
||||
describe('writeAndVerifyFile', () => {
|
||||
@@ -26,7 +26,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
it('writes to specified path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test.txt';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withFileSystem(fileSystemStub);
|
||||
@@ -43,7 +43,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
it('writes specified contents', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'expected file contents';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFileContents(expectedFileContents);
|
||||
@@ -60,7 +60,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
it('checks correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test-file-path';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
@@ -87,33 +87,17 @@ describe('NodeReadbackFileWriter', () => {
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
|
||||
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'isFileAvailable');
|
||||
expect(accessCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = accessCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('uses correct mode', async () => {
|
||||
// arrange
|
||||
const expectedMode = constants.F_OK;
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
|
||||
expect(accessCalls).to.have.lengthOf(1);
|
||||
const [,actualMode] = accessCalls[0].args;
|
||||
expect(actualMode).to.equal(expectedMode);
|
||||
});
|
||||
});
|
||||
describe('content verification', () => {
|
||||
it('reads from correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path.txt';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
@@ -130,7 +114,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
@@ -146,12 +130,12 @@ describe('NodeReadbackFileWriter', () => {
|
||||
});
|
||||
it('executes file system operations in correct sequence', async () => {
|
||||
// arrange
|
||||
const expectedOrder: ReadonlyArray<FunctionKeys<FileReadWriteOperations>> = [
|
||||
const expectedOrder: ReadonlyArray<FunctionKeys<FileSystemOperations>> = [
|
||||
'writeFile',
|
||||
'access',
|
||||
'isFileAvailable',
|
||||
'readFile',
|
||||
];
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
@@ -178,19 +162,30 @@ describe('NodeReadbackFileWriter', () => {
|
||||
expectedErrorType: 'WriteOperationFailed',
|
||||
expectedErrorMessage: 'Error when writing file',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'existence verification error',
|
||||
description: 'existence verification throws error',
|
||||
expectedErrorType: 'FileExistenceVerificationFailed',
|
||||
expectedErrorMessage: 'Access denied',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
fileSystemStub.access = () => Promise.reject(errorMessage);
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'existence verification returnf alse',
|
||||
expectedErrorType: 'FileExistenceVerificationFailed',
|
||||
expectedErrorMessage: 'File does not exist.',
|
||||
buildFaultyContext: (setup) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => Promise.resolve(false);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
@@ -200,7 +195,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
expectedErrorType: 'ReadVerificationFailed',
|
||||
expectedErrorMessage: 'Read error',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
@@ -211,7 +206,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
expectedErrorType: 'ContentVerificationFailed',
|
||||
expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
|
||||
buildFaultyContext: (setup) => {
|
||||
const fileSystemStub = new FileReadWriteOperationsStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.resolve('different contents');
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
@@ -260,7 +255,7 @@ describe('NodeReadbackFileWriter', () => {
|
||||
class NodeReadbackFileWriterTestSetup {
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private fileSystem: FileReadWriteOperations = new FileReadWriteOperationsStub();
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private filePath = '/test/file/path.txt';
|
||||
|
||||
@@ -271,7 +266,7 @@ class NodeReadbackFileWriterTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileReadWriteOperations): this {
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { FileReadWriteOperations } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
|
||||
export class FileReadWriteOperationsStub
|
||||
extends StubWithObservableMethodCalls<FileReadWriteOperations>
|
||||
implements FileReadWriteOperations {
|
||||
private readonly writtenFiles: Record<string, string> = {};
|
||||
|
||||
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeFile',
|
||||
args: [filePath, fileContents, encoding],
|
||||
});
|
||||
this.writtenFiles[filePath] = fileContents;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
public access = (...args: Parameters<FileReadWriteOperations['access']>) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'access',
|
||||
args: [...args],
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'readFile',
|
||||
args: [filePath, encoding],
|
||||
});
|
||||
const fileContents = this.writtenFiles[filePath];
|
||||
return Promise.resolve(fileContents);
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { ScriptEnvironmentDiagnosticsCollector } from '@/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { ScriptDirectoryProviderStub } from '@tests/unit/shared/Stubs/ScriptDirectoryProviderStub';
|
||||
import { ApplicationDirectoryProviderStub } from '@tests/unit/shared/Stubs/ApplicationDirectoryProviderStub';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
|
||||
describe('ScriptEnvironmentDiagnosticsCollector', () => {
|
||||
it('collects operating system path correctly', async () => {
|
||||
@@ -26,8 +26,8 @@ describe('ScriptEnvironmentDiagnosticsCollector', () => {
|
||||
it('collects path correctly', async () => {
|
||||
// arrange
|
||||
const expectedScriptsPath = '/expected/scripts/path';
|
||||
const directoryProvider = new ScriptDirectoryProviderStub()
|
||||
.withDirectoryPath(expectedScriptsPath);
|
||||
const directoryProvider = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('script-runs', expectedScriptsPath);
|
||||
const collector = new CollectorBuilder()
|
||||
.withScriptDirectoryProvider(directoryProvider)
|
||||
.build();
|
||||
@@ -42,7 +42,7 @@ describe('ScriptEnvironmentDiagnosticsCollector', () => {
|
||||
});
|
||||
|
||||
class CollectorBuilder {
|
||||
private directoryProvider: ScriptDirectoryProvider = new ScriptDirectoryProviderStub();
|
||||
private directoryProvider: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
|
||||
|
||||
private environment: RuntimeEnvironment = new RuntimeEnvironmentStub();
|
||||
|
||||
@@ -51,7 +51,7 @@ class CollectorBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withScriptDirectoryProvider(directoryProvider: ScriptDirectoryProvider): this {
|
||||
public withScriptDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
|
||||
this.directoryProvider = directoryProvider;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
import { ApplicationDirectoryProviderStub } from '@tests/unit/shared/Stubs/ApplicationDirectoryProviderStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import { clearUpdateInstallationFiles } from '@/presentation/electron/main/Update/ManualUpdater/InstallationFiles/InstallationFileCleaner';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { collectExceptionAsync } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { indentText } from '@/application/Common/Text/IndentText';
|
||||
|
||||
describe('InstallationFileCleaner', () => {
|
||||
describe('clearUpdateInstallationFiles', () => {
|
||||
describe('deleting files', () => {
|
||||
it('deletes all update installation files and directories', async () => {
|
||||
// arrange
|
||||
const expectedDirectoryEntries = ['file1', 'file2', 'file3', 'directory1', 'directory2'];
|
||||
const directoryPath = 'directory-name';
|
||||
const pathSeparator = 'test-separator';
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', directoryPath);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSeparator)
|
||||
.withDirectoryContents(directoryPath, expectedDirectoryEntries);
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const actualDeletedEntries = fileSystemStub.callHistory
|
||||
.filter((c) => c.methodName === 'deletePath')
|
||||
.map((c) => c.args[0])
|
||||
.map((path) => path.split(pathSeparator).pop());
|
||||
expect(expectedDirectoryEntries.sort()).to.deep.equal(actualDeletedEntries.sort());
|
||||
});
|
||||
it('deletes files at the correct absolute paths', async () => {
|
||||
// arrange
|
||||
const directoryItemName = 'expected-item-name';
|
||||
const directoryPath = 'expected-directory';
|
||||
const pathSeparator = '[expected-separator]';
|
||||
const expectedFullPath = [directoryPath, directoryItemName].join(pathSeparator);
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', directoryPath);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSeparator)
|
||||
.withDirectoryContents(directoryPath, [directoryItemName]);
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const actualDeletedEntries = fileSystemStub.callHistory
|
||||
.filter((c) => c.methodName === 'deletePath')
|
||||
.map((c) => c.args[0]);
|
||||
expect(actualDeletedEntries).to.have.lengthOf(1);
|
||||
const actualFullPath = actualDeletedEntries[0];
|
||||
expect(actualFullPath).to.equal(expectedFullPath);
|
||||
});
|
||||
it('continues deleting other items if one cannot be deleted', async () => {
|
||||
// arrange
|
||||
const expectedDeletedItems = ['success-1', 'success-2', 'success-3'];
|
||||
const expectedDirectoryEntries = ['fail-1', ...expectedDeletedItems, 'fail-2'];
|
||||
const directoryPath = 'directory-name';
|
||||
const pathSeparator = 'test-separator';
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', directoryPath);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSeparator)
|
||||
.withDirectoryContents(directoryPath, expectedDirectoryEntries);
|
||||
fileSystemStub.deletePath = async (path) => {
|
||||
await FileSystemOperationsStub.prototype
|
||||
.deletePath.call(fileSystemStub, path); // register call history
|
||||
if (expectedDeletedItems.some((item) => path.endsWith(item))) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Path is not configured to succeed, so it fails: ${path}`);
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
try {
|
||||
await context.run();
|
||||
} catch { /* Swallow */ }
|
||||
// assert
|
||||
const actualDeletedEntries = fileSystemStub.callHistory
|
||||
.filter((c) => c.methodName === 'deletePath')
|
||||
.map((c) => c.args[0])
|
||||
.map((path) => path.split(pathSeparator).pop());
|
||||
expect(expectedDirectoryEntries.sort()).to.deep.equal(actualDeletedEntries.sort());
|
||||
});
|
||||
});
|
||||
it('does nothing if directory is empty', async () => {
|
||||
// arrange
|
||||
const directoryPath = 'directory-path';
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', directoryPath);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDirectoryContents(directoryPath, []);
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const actualDeletedEntries = fileSystemStub.callHistory
|
||||
.filter((c) => c.methodName === 'deletePath');
|
||||
expect(actualDeletedEntries).to.have.lengthOf(0);
|
||||
});
|
||||
describe('error handling', () => {
|
||||
it('throws if installation directory cannot be provided', async () => {
|
||||
// arrange
|
||||
const expectedError = 'Cannot locate the installation files directory path';
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withFailure();
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProviderStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedError);
|
||||
});
|
||||
it('throws if directory contents cannot be listed', async () => {
|
||||
// arrange
|
||||
const expectedError = 'Failed to read directory contents';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.listDirectoryContents = () => Promise.reject(new Error(expectedError));
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedError);
|
||||
});
|
||||
it('throws if all items cannot be deleted', async () => {
|
||||
// arrange
|
||||
const itemsWithErrors: Map<string, Error> = new Map([
|
||||
['item-1', new Error('Access Denied: item-1')],
|
||||
['item-2', new Error('Disk I/O Error: item-2')],
|
||||
]);
|
||||
const expectedErrorParts = [
|
||||
'Failed to delete some items',
|
||||
...[...itemsWithErrors.values()].map((item: Error) => item.message),
|
||||
];
|
||||
const loggerStub = new LoggerStub();
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.listDirectoryContents = async () => {
|
||||
await FileSystemOperationsStub.prototype
|
||||
.listDirectoryContents.call(fileSystemStub); // register call history
|
||||
return [...itemsWithErrors.keys()];
|
||||
};
|
||||
fileSystemStub.deletePath = (path) => {
|
||||
const name = [...itemsWithErrors.keys()]
|
||||
.find((fileName) => path.endsWith(fileName));
|
||||
if (!name) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const error = itemsWithErrors.get(name)!;
|
||||
return Promise.reject(error);
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withLogger(loggerStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
const error = await collectExceptionAsync(act);
|
||||
expectExists(error, formatAssertionMessage([
|
||||
`FileSystem calls: ${JSON.stringify(fileSystemStub.callHistory)}`,
|
||||
`Log calls: ${JSON.stringify(loggerStub.callHistory)}`,
|
||||
]));
|
||||
const errorMessage = error.message;
|
||||
const notExistingErrorMessageParts = expectedErrorParts.filter(
|
||||
(e) => !errorMessage.includes(e),
|
||||
);
|
||||
expect(notExistingErrorMessageParts).to.have.lengthOf(0, formatAssertionMessage([
|
||||
'Actual error message:',
|
||||
indentText(errorMessage),
|
||||
'Expected parts:',
|
||||
indentText(expectedErrorParts.map((part) => `- ${part}`).join('\n')),
|
||||
]));
|
||||
});
|
||||
it('throws if some items cannot be deleted', async () => {
|
||||
// arrange
|
||||
const itemsWithErrors: Map<string, Error> = new Map([
|
||||
['item-1', new Error('Access Denied: item-1')],
|
||||
['item-2', new Error('Disk I/O Error: item-2')],
|
||||
]);
|
||||
const expectedErrorParts = [
|
||||
'Failed to delete some items',
|
||||
...[...itemsWithErrors.values()].map((item: Error) => item.message),
|
||||
];
|
||||
const itemsWithSuccess = ['successful-item-1', 'successful-item-2'];
|
||||
const allItems = [
|
||||
itemsWithSuccess[0],
|
||||
...[...itemsWithErrors.keys()],
|
||||
itemsWithSuccess[1],
|
||||
];
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const loggerStub = new LoggerStub();
|
||||
fileSystemStub.listDirectoryContents = async () => {
|
||||
await FileSystemOperationsStub.prototype
|
||||
.listDirectoryContents.call(fileSystemStub); // register call history
|
||||
return allItems;
|
||||
};
|
||||
fileSystemStub.deletePath = async (path) => {
|
||||
await FileSystemOperationsStub.prototype
|
||||
.deletePath.call(fileSystemStub, path); // register call history
|
||||
const name = [...itemsWithErrors.keys()].find((n) => path.endsWith(n));
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const error = itemsWithErrors.get(name)!;
|
||||
throw error;
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withLogger(loggerStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
const error = await collectExceptionAsync(act);
|
||||
expectExists(error, formatAssertionMessage([
|
||||
`Calls: ${JSON.stringify(fileSystemStub.callHistory)}`,
|
||||
`Logs: ${JSON.stringify(loggerStub.callHistory)}`,
|
||||
]));
|
||||
const errorMessage = error.message;
|
||||
const notExistingErrorMessageParts = expectedErrorParts.filter(
|
||||
(e) => !error.message.includes(e),
|
||||
);
|
||||
expect(notExistingErrorMessageParts)
|
||||
.to.have.lengthOf(0, formatAssertionMessage([
|
||||
'Actual error message:',
|
||||
indentText(errorMessage),
|
||||
'Expected parts:',
|
||||
indentText(expectedErrorParts.map((part) => `- ${part}`).join('\n')),
|
||||
]));
|
||||
expect(itemsWithSuccess.some((item) => errorMessage.includes(item)))
|
||||
.to.equal(false, formatAssertionMessage([
|
||||
'Actual error message:',
|
||||
indentText(errorMessage),
|
||||
'Unexpected parts:',
|
||||
indentText(itemsWithSuccess.map((part) => `- ${part}`).join('\n')),
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private directoryProvider: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
|
||||
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
public withDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
|
||||
this.directoryProvider = directoryProvider;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public run(): ReturnType<typeof clearUpdateInstallationFiles> {
|
||||
return clearUpdateInstallationFiles({
|
||||
logger: this.logger,
|
||||
directoryProvider: this.directoryProvider,
|
||||
fileSystem: this.fileSystem,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { it, describe, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { ApplicationDirectoryProviderStub } from '@tests/unit/shared/Stubs/ApplicationDirectoryProviderStub';
|
||||
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
|
||||
import type { FileSystemAccessorWithRetry } from '@/presentation/electron/main/Update/ManualUpdater/FileSystemAccessorWithRetry';
|
||||
import { FileSystemAccessorWithRetryStub } from '@tests/unit/shared/Stubs/FileSystemAccessorWithRetryStub';
|
||||
import { InstallerFileSuffix, provideUpdateInstallationFilepath } from '@/presentation/electron/main/Update/ManualUpdater/InstallationFiles/InstallationFilepathProvider';
|
||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { collectExceptionAsync } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
describe('InstallationFilePathProvider', () => {
|
||||
describe('provideUpdateInstallationFilePath', () => {
|
||||
it('returns correct filepath', async () => {
|
||||
// arrange
|
||||
const version = '1.2.3';
|
||||
const baseDirectoryPath = '/updates';
|
||||
const pathSegmentSeparator = '/separator/';
|
||||
const expectedPath = [
|
||||
baseDirectoryPath, pathSegmentSeparator, version, InstallerFileSuffix,
|
||||
].join('');
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', baseDirectoryPath);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withVersion(version);
|
||||
// act
|
||||
const actualPath = await context.run();
|
||||
// assert
|
||||
expect(actualPath).to.equal(expectedPath);
|
||||
});
|
||||
it('checks if file exists at correct path', async () => {
|
||||
// arrange
|
||||
const version = '1.2.3';
|
||||
const baseDirectoryPath = '/updates';
|
||||
const pathSegmentSeparator = '/separator/';
|
||||
const expectedPath = [
|
||||
baseDirectoryPath, pathSegmentSeparator, version, InstallerFileSuffix,
|
||||
].join('');
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', baseDirectoryPath);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withVersion(version);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const calls = fileSystemStub.callHistory.filter((c) => c.methodName === 'isFileAvailable');
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = calls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedPath);
|
||||
});
|
||||
it('deletes file at correct path', async () => {
|
||||
// arrange
|
||||
const version = '1.2.3';
|
||||
const baseDirectoryPath = '/updates';
|
||||
const pathSegmentSeparator = '/separator/';
|
||||
const expectedPath = [
|
||||
baseDirectoryPath, pathSegmentSeparator, version, InstallerFileSuffix,
|
||||
].join('');
|
||||
const isFileAvailable = true;
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withFileAvailability(expectedPath, isFileAvailable)
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const directoryProviderStub = new ApplicationDirectoryProviderStub()
|
||||
.withDirectoryPath('update-installation-files', baseDirectoryPath);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryProvider(directoryProviderStub)
|
||||
.withVersion(version);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const calls = fileSystemStub.callHistory.filter((c) => c.methodName === 'deletePath');
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const [deletedFilePath] = calls[0].args;
|
||||
expect(deletedFilePath).to.equal(expectedPath);
|
||||
});
|
||||
it('deletes existing file', async () => {
|
||||
// arrange
|
||||
const isFileAvailable = true;
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => Promise.resolve(isFileAvailable);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const calls = fileSystemStub.callHistory.filter((c) => c.methodName === 'deletePath');
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
});
|
||||
it('does not attempt to delete non-existent file', async () => {
|
||||
// arrange
|
||||
const isFileAvailable = false;
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => Promise.resolve(isFileAvailable);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const calls = fileSystemStub.callHistory.filter((c) => c.methodName === 'deletePath');
|
||||
expect(calls).to.have.lengthOf(0);
|
||||
});
|
||||
describe('file system error handling', () => {
|
||||
it('retries on file deletion failure', async () => {
|
||||
// arrange
|
||||
const forcedRetries = 2;
|
||||
const expectedTotalCalls = forcedRetries + 1;
|
||||
const isFileAvailable = true;
|
||||
const accessorStub = new FileSystemAccessorWithRetryStub()
|
||||
.withAlwaysRetry(forcedRetries);
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => Promise.resolve(isFileAvailable);
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withAccessor(accessorStub.get());
|
||||
// act
|
||||
await context.run();
|
||||
// assert
|
||||
const calls = fileSystemStub.callHistory
|
||||
.filter((c) => c.methodName === 'deletePath');
|
||||
expect(calls).to.have.lengthOf(expectedTotalCalls);
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
it('throws when directory provision fails', async () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'Failed to provide download directory.';
|
||||
const directoryProvider = new ApplicationDirectoryProviderStub()
|
||||
.withFailure();
|
||||
const context = new TestContext()
|
||||
.withDirectoryProvider(directoryProvider);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedErrorMessage);
|
||||
});
|
||||
it('throws on file availability check failure', async () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'File availability check failed';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.isFileAvailable = () => {
|
||||
return Promise.reject(new Error(expectedErrorMessage));
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedErrorMessage);
|
||||
});
|
||||
it('throws on existing file deletion failure', async () => {
|
||||
// arrange
|
||||
const expectedErrorMessagePart = 'Failed to prepare the file path for the installer';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.deletePath = () => {
|
||||
return Promise.reject(new Error('Internal error'));
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withFileSystem(fileSystemStub);
|
||||
// act
|
||||
const act = () => context.run();
|
||||
// assert
|
||||
const error = await collectExceptionAsync(act);
|
||||
expectExists(error, formatAssertionMessage([
|
||||
`File system calls: ${fileSystemStub.methodCalls}`,
|
||||
]));
|
||||
expect(error.message).to.include(expectedErrorMessagePart);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private version = '3.5.5';
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private directoryProvider
|
||||
: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
|
||||
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private accessor: FileSystemAccessorWithRetry = new FileSystemAccessorWithRetryStub().get();
|
||||
|
||||
public withVersion(version: string): this {
|
||||
this.version = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withAccessor(accessor: FileSystemAccessorWithRetry): this {
|
||||
this.accessor = accessor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
|
||||
this.directoryProvider = directoryProvider;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public run() {
|
||||
return provideUpdateInstallationFilepath(this.version, {
|
||||
logger: this.logger,
|
||||
directoryProvider: this.directoryProvider,
|
||||
fileSystem: this.fileSystem,
|
||||
accessFileSystemWithRetry: this.accessor,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,19 @@ function collectException(
|
||||
error = err;
|
||||
}
|
||||
if (!error) {
|
||||
throw new Error('action did not throw');
|
||||
throw new Error('Action did not throw');
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function collectExceptionAsync(
|
||||
action: () => Promise<unknown>,
|
||||
): Promise<Error | undefined> {
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
41
tests/unit/shared/Stubs/ApplicationDirectoryProviderStub.ts
Normal file
41
tests/unit/shared/Stubs/ApplicationDirectoryProviderStub.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
DirectoryCreationOutcome,
|
||||
ApplicationDirectoryProvider,
|
||||
DirectoryType,
|
||||
DirectoryCreationError,
|
||||
} from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
|
||||
export class ApplicationDirectoryProviderStub implements ApplicationDirectoryProvider {
|
||||
private directoryPaths: Record<DirectoryType, string> = {
|
||||
'update-installation-files': `[${ApplicationDirectoryProviderStub.name}]update installation files directory`,
|
||||
'script-runs': `[${ApplicationDirectoryProviderStub.name}]scripts directory`,
|
||||
};
|
||||
|
||||
private failure: DirectoryCreationError | undefined = undefined;
|
||||
|
||||
public withDirectoryPath(type: DirectoryType, directoryPath: string): this {
|
||||
this.directoryPaths[type] = directoryPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
|
||||
if (this.failure) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: this.failure,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
directoryAbsolutePath: this.directoryPaths[type],
|
||||
});
|
||||
}
|
||||
|
||||
public withFailure(error?: DirectoryCreationError): this {
|
||||
this.failure = error ?? {
|
||||
type: 'DirectoryWriteError',
|
||||
message: `[${ApplicationDirectoryProviderStub.name}]injected failure`,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
21
tests/unit/shared/Stubs/FileSystemAccessorWithRetryStub.ts
Normal file
21
tests/unit/shared/Stubs/FileSystemAccessorWithRetryStub.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { FileSystemAccessorWithRetry } from '@/presentation/electron/main/Update/ManualUpdater/FileSystemAccessorWithRetry';
|
||||
|
||||
export class FileSystemAccessorWithRetryStub {
|
||||
private retryAmount = 0;
|
||||
|
||||
public withAlwaysRetry(retryAmount: number): this {
|
||||
this.retryAmount = retryAmount;
|
||||
return this;
|
||||
}
|
||||
|
||||
public get(): FileSystemAccessorWithRetry {
|
||||
return async (fileOperation) => {
|
||||
const result = await fileOperation();
|
||||
for (let i = 0; i < this.retryAmount; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fileOperation();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
}
|
||||
161
tests/unit/shared/Stubs/FileSystemOperationsStub.ts
Normal file
161
tests/unit/shared/Stubs/FileSystemOperationsStub.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class FileSystemOperationsStub
|
||||
extends StubWithObservableMethodCalls<FileSystemOperations>
|
||||
implements FileSystemOperations {
|
||||
private readonly writtenFiles: Map<string, string> = new Map();
|
||||
|
||||
private readonly fileAvailability: Map<string, boolean> = new Map();
|
||||
|
||||
private directoryContents: Map<string, string[]> = new Map();
|
||||
|
||||
private userDataDirectory = `/${FileSystemOperationsStub.name}-user-data-dir/`;
|
||||
|
||||
private combinePathSequence = new Array<string>();
|
||||
|
||||
private combinePathScenarios = new Map<string, string>();
|
||||
|
||||
private combinePathDefaultSeparator = `/[${FileSystemOperationsStub.name}]PATH-SEGMENT-SEPARATOR/`;
|
||||
|
||||
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'setFilePermissions',
|
||||
args: [filePath, mode],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeFile',
|
||||
args: [filePath, fileContents, encoding],
|
||||
});
|
||||
this.writtenFiles.set(filePath, fileContents);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
|
||||
this.registerMethodCall({
|
||||
methodName: 'readFile',
|
||||
args: [filePath, encoding],
|
||||
});
|
||||
const fileContents = this.writtenFiles.get(filePath);
|
||||
return Promise.resolve(fileContents ?? `[${FileSystemOperationsStub.name}] file-contents`);
|
||||
};
|
||||
|
||||
public createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'createDirectory',
|
||||
args: [directoryPath, isRecursive],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public isFileAvailable(filePath: string): Promise<boolean> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'isFileAvailable',
|
||||
args: [filePath],
|
||||
});
|
||||
const availability = this.fileAvailability.get(filePath);
|
||||
if (availability !== undefined) {
|
||||
return Promise.resolve(availability);
|
||||
}
|
||||
const fileContents = this.writtenFiles.get(filePath);
|
||||
if (fileContents !== undefined) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
public isDirectoryAvailable(directoryPath: string): Promise<boolean> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'isDirectoryAvailable',
|
||||
args: [directoryPath],
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
public deletePath(filePath: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'deletePath',
|
||||
args: [filePath],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public withUserDirectoryResult(directory: string): this {
|
||||
this.userDataDirectory = directory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getUserDataDirectory(): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'getUserDataDirectory',
|
||||
args: [],
|
||||
});
|
||||
return this.userDataDirectory;
|
||||
}
|
||||
|
||||
public listDirectoryContents(directoryPath: string): Promise<string[]> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'listDirectoryContents',
|
||||
args: [directoryPath],
|
||||
});
|
||||
const contents = this.directoryContents.get(directoryPath);
|
||||
return Promise.resolve(contents ?? []);
|
||||
}
|
||||
|
||||
public withDirectoryContents(
|
||||
directoryPath: string,
|
||||
fileOrFolderNames: readonly string[],
|
||||
): this {
|
||||
this.directoryContents.set(directoryPath, [...fileOrFolderNames]);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileAvailability(
|
||||
filePath: string,
|
||||
isAvailable: boolean,
|
||||
): this {
|
||||
this.fileAvailability.set(filePath, isAvailable);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withJoinResult(returnValue: string, ...paths: string[]): this {
|
||||
this.combinePathScenarios.set(getCombinePathsScenarioKey(paths), returnValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withJoinResultSequence(...valuesToReturn: string[]): this {
|
||||
this.combinePathSequence.push(...valuesToReturn);
|
||||
this.combinePathSequence.reverse();
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDefaultSeparator(defaultSeparator: string): this {
|
||||
this.combinePathDefaultSeparator = defaultSeparator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public combinePaths(...pathSegments: string[]): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'combinePaths',
|
||||
args: pathSegments,
|
||||
});
|
||||
const nextInSequence = this.combinePathSequence.pop();
|
||||
if (nextInSequence) {
|
||||
return nextInSequence;
|
||||
}
|
||||
const key = getCombinePathsScenarioKey(pathSegments);
|
||||
const foundScenario = this.combinePathScenarios.get(key);
|
||||
if (foundScenario) {
|
||||
return foundScenario;
|
||||
}
|
||||
return pathSegments.join(this.combinePathDefaultSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
function getCombinePathsScenarioKey(paths: string[]): string {
|
||||
return paths.join('|');
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { FileSystemOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class FileSystemOpsStub
|
||||
extends StubWithObservableMethodCalls<FileSystemOps>
|
||||
implements FileSystemOps {
|
||||
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'setFilePermissions',
|
||||
args: [filePath, mode],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'createDirectory',
|
||||
args: [directoryPath, isRecursive],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { LocationOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class LocationOpsStub
|
||||
extends StubWithObservableMethodCalls<LocationOps>
|
||||
implements LocationOps {
|
||||
private sequence = new Array<string>();
|
||||
|
||||
private scenarios = new Map<string, string>();
|
||||
|
||||
private defaultSeparator = `/[${LocationOpsStub.name}]PATH-SEGMENT-SEPARATOR/`;
|
||||
|
||||
public withJoinResult(returnValue: string, ...paths: string[]): this {
|
||||
this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
public withJoinResultSequence(...valuesToReturn: string[]): this {
|
||||
this.sequence.push(...valuesToReturn);
|
||||
this.sequence.reverse();
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDefaultSeparator(defaultSeparator: string): this {
|
||||
this.defaultSeparator = defaultSeparator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public combinePaths(...pathSegments: string[]): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'combinePaths',
|
||||
args: pathSegments,
|
||||
});
|
||||
const nextInSequence = this.sequence.pop();
|
||||
if (nextInSequence) {
|
||||
return nextInSequence;
|
||||
}
|
||||
const key = LocationOpsStub.getScenarioKey(pathSegments);
|
||||
const foundScenario = this.scenarios.get(key);
|
||||
if (foundScenario) {
|
||||
return foundScenario;
|
||||
}
|
||||
return pathSegments.join(this.defaultSeparator);
|
||||
}
|
||||
|
||||
private static getScenarioKey(paths: string[]): string {
|
||||
return paths.join('|');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { OperatingSystemOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class OperatingSystemOpsStub
|
||||
extends StubWithObservableMethodCalls<OperatingSystemOps>
|
||||
implements OperatingSystemOps {
|
||||
private userDataDirectory = `/${OperatingSystemOpsStub.name}-user-data-dir/`;
|
||||
|
||||
public withUserDirectoryResult(directory: string): this {
|
||||
this.userDataDirectory = directory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public getUserDataDirectory(): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'getUserDataDirectory',
|
||||
args: [],
|
||||
});
|
||||
return this.userDataDirectory;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import type { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ReadbackFileWriterStub
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
|
||||
export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
||||
private directoryPath = `[${ScriptDirectoryProviderStub.name}]scriptDirectory`;
|
||||
|
||||
public withDirectoryPath(directoryPath: string): this {
|
||||
this.directoryPath = directoryPath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
directoryAbsolutePath: this.directoryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,17 @@
|
||||
import type {
|
||||
CommandOps,
|
||||
FileSystemOps,
|
||||
OperatingSystemOps,
|
||||
LocationOps,
|
||||
SystemOperations,
|
||||
} from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
import { CommandOpsStub } from './CommandOpsStub';
|
||||
import { FileSystemOpsStub } from './FileSystemOpsStub';
|
||||
import { LocationOpsStub } from './LocationOpsStub';
|
||||
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
|
||||
import { FileSystemOperationsStub } from './FileSystemOperationsStub';
|
||||
|
||||
export class SystemOperationsStub implements SystemOperations {
|
||||
public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub();
|
||||
|
||||
public location: LocationOps = new LocationOpsStub();
|
||||
|
||||
public fileSystem: FileSystemOps = new FileSystemOpsStub();
|
||||
public fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
public command: CommandOps = new CommandOpsStub();
|
||||
|
||||
public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this {
|
||||
this.operatingSystem = operatingSystemOps;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLocation(location: LocationOps): this {
|
||||
this.location = location;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOps): this {
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user