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:
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
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('PersistentApplicationDirectoryProvider', () => {
|
||||
describe('createDirectory', () => {
|
||||
describe('path construction', () => {
|
||||
it('bases path on user directory', async () => {
|
||||
// arrange
|
||||
const expectedBaseDirectory = 'base-directory';
|
||||
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withUserDirectoryResult(expectedBaseDirectory)
|
||||
.withDefaultSeparator(pathSegmentSeparator);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
const actualBaseDirectory = directoryAbsolutePath.split(pathSegmentSeparator)[0];
|
||||
expect(actualBaseDirectory).to.equal(expectedBaseDirectory);
|
||||
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);
|
||||
});
|
||||
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();
|
||||
|
||||
// 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, SubdirectoryNames[directoryType]]
|
||||
.join(pathSegmentSeparator);
|
||||
const fileSystemStub = new FileSystemOperationsStub()
|
||||
.withDefaultSeparator(pathSegmentSeparator)
|
||||
.withUserDirectoryResult(baseDirectory);
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withDirectoryType(directoryType);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
expect(success).to.equal(true);
|
||||
expect(directoryAbsolutePath).to.equal(expectedDirectory);
|
||||
});
|
||||
});
|
||||
describe('directory creation', () => {
|
||||
it('creates directory with recursion', async () => {
|
||||
// arrange
|
||||
const expectedIsRecursive = true;
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new PersistentDirectoryProviderTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expectTrue(success);
|
||||
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);
|
||||
expect(actualIsRecursive).to.equal(expectedIsRecursive);
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: DirectoryCreationErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
buildFaultyContext(
|
||||
setup: PersistentDirectoryProviderTestSetup,
|
||||
errorMessage: string,
|
||||
): PersistentDirectoryProviderTestSetup;
|
||||
}> = [
|
||||
{
|
||||
description: 'path combination failure',
|
||||
expectedErrorType: 'PathConstructionError',
|
||||
expectedErrorMessage: 'Error when combining paths',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.combinePaths = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'user data retrieval failure',
|
||||
expectedErrorType: 'UserDataFolderRetrievalError',
|
||||
expectedErrorMessage: 'Error when locating user data directory',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.getUserDataDirectory = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'directory creation failure',
|
||||
expectedErrorType: 'DirectoryWriteError',
|
||||
expectedErrorMessage: 'Error when creating directory',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.createDirectory = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||
}) => {
|
||||
it(`handles error - ${description}`, async () => {
|
||||
// arrange
|
||||
const context = buildFaultyContext(
|
||||
new PersistentDirectoryProviderTestSetup(),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
const { success, error } = await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(false);
|
||||
expectExists(error);
|
||||
expect(error.message).to.include(expectedErrorMessage);
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
new PersistentDirectoryProviderTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
await context.provideScriptDirectory();
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class PersistentDirectoryProviderTestSetup {
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private directoryType: DirectoryType = 'script-runs';
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
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', () => {
|
||||
describe('successful write and verify operations', () => {
|
||||
it('confirms successful operation', async () => {
|
||||
// arrange
|
||||
const context = new NodeReadbackFileWriterTestSetup();
|
||||
|
||||
// act
|
||||
const { success } = await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(true);
|
||||
});
|
||||
describe('file write operations', () => {
|
||||
it('writes to specified path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test.txt';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = fileWriteCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('writes specified contents', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'expected file contents';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFileContents(expectedFileContents);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [,actualFileContents] = fileWriteCalls[0].args;
|
||||
expect(actualFileContents).to.equal(expectedFileContents);
|
||||
});
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(fileWriteCalls).to.have.lengthOf(1);
|
||||
const [,,actualEncoding] = fileWriteCalls[0].args;
|
||||
expect(actualEncoding).to.equal(expectedEncoding);
|
||||
});
|
||||
});
|
||||
describe('existence verification', () => {
|
||||
it('checks correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'test-file-path';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
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);
|
||||
});
|
||||
});
|
||||
describe('content verification', () => {
|
||||
it('reads from correct path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path.txt';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub)
|
||||
.withFilePath(expectedFilePath);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
|
||||
expect(fileReadCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = fileReadCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('uses correct encoding', async () => {
|
||||
// arrange
|
||||
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
|
||||
expect(fileReadCalls).to.have.lengthOf(1);
|
||||
const [,actualEncoding] = fileReadCalls[0].args;
|
||||
expect(actualEncoding).to.equal(expectedEncoding);
|
||||
});
|
||||
});
|
||||
it('executes file system operations in correct sequence', async () => {
|
||||
// arrange
|
||||
const expectedOrder: ReadonlyArray<FunctionKeys<FileSystemOperations>> = [
|
||||
'writeFile',
|
||||
'isFileAvailable',
|
||||
'readFile',
|
||||
];
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
const context = new NodeReadbackFileWriterTestSetup()
|
||||
.withFileSystem(fileSystemStub);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
const actualOrder = fileSystemStub.callHistory.map((c) => c.methodName);
|
||||
expect(sequenceEqual(expectedOrder, actualOrder)).to.equal(true);
|
||||
});
|
||||
});
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: FileWriteErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
buildFaultyContext(
|
||||
setup: NodeReadbackFileWriterTestSetup,
|
||||
errorMessage: string,
|
||||
): NodeReadbackFileWriterTestSetup;
|
||||
}> = [
|
||||
{
|
||||
description: 'writing failure',
|
||||
expectedErrorType: 'WriteOperationFailed',
|
||||
expectedErrorMessage: 'Error when writing file',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'existence verification throws error',
|
||||
expectedErrorType: 'FileExistenceVerificationFailed',
|
||||
expectedErrorMessage: 'Access denied',
|
||||
buildFaultyContext: (setup, 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);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'reading failure',
|
||||
expectedErrorType: 'ReadVerificationFailed',
|
||||
expectedErrorMessage: 'Read error',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.reject(errorMessage);
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'content match failure',
|
||||
expectedErrorType: 'ContentVerificationFailed',
|
||||
expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
|
||||
buildFaultyContext: (setup) => {
|
||||
const fileSystemStub = new FileSystemOperationsStub();
|
||||
fileSystemStub.readFile = () => Promise.resolve('different contents');
|
||||
return setup
|
||||
.withFileSystem(fileSystemStub);
|
||||
},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||
}) => {
|
||||
it(`handles error - ${description}`, async () => {
|
||||
// arrange
|
||||
const context = buildFaultyContext(
|
||||
new NodeReadbackFileWriterTestSetup(),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
const { success, error } = await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(false);
|
||||
expectExists(error);
|
||||
expect(error.message).to.include(expectedErrorMessage);
|
||||
expect(error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
it(`logs error - ${description}`, async () => {
|
||||
// arrange
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = buildFaultyContext(
|
||||
new NodeReadbackFileWriterTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
await context.writeAndVerifyFile();
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class NodeReadbackFileWriterTestSetup {
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
|
||||
|
||||
private filePath = '/test/file/path.txt';
|
||||
|
||||
private fileContents = 'test file contents';
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileSystem(fileSystem: FileSystemOperations): this {
|
||||
this.fileSystem = fileSystem;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileContents(fileContents: string): this {
|
||||
this.fileContents = fileContents;
|
||||
return this;
|
||||
}
|
||||
|
||||
public writeAndVerifyFile(): ReturnType<NodeReadbackFileWriter['writeAndVerifyFile']> {
|
||||
const writer = new NodeReadbackFileWriter(
|
||||
this.logger,
|
||||
this.fileSystem,
|
||||
);
|
||||
return writer.writeAndVerifyFile(
|
||||
this.filePath,
|
||||
this.fileContents,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user