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:
undergroundwires
2024-10-07 17:33:47 +02:00
parent 4e06d543b3
commit 2f31bc7b06
44 changed files with 1484 additions and 590 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
};
}

View File

@@ -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;
}

View File

@@ -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,
});
}
}

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View 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;
}
}

View 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;
};
}
}

View 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('|');
}

View File

@@ -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();
}
}

View File

@@ -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('|');
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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,
});
}
}

View File

@@ -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;
}