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