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

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