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

@@ -34,8 +34,10 @@ The desktop version ensures secure delivery through cryptographic signatures and
[Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy. [Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs. > **Note for macOS users:**
> On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually. > Users get notified about updates but might need to complete the installation manually.
> Updater stores update installation files temporarily at `$HOME/Library/Application Support/privacy.sexy/updates`.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️. > Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging ### Logging

View File

@@ -1,23 +0,0 @@
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
export interface ScriptDirectoryProvider {
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
}
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
interface ScriptDirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: CodeRunError;
}
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: false;
readonly error: CodeRunError;
}

View File

@@ -1,21 +1,22 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator'; import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
import type { SystemOperations } from '../System/SystemOperations';
import type { FilenameGenerator } from './Filename/FilenameGenerator'; import type { FilenameGenerator } from './Filename/FilenameGenerator';
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator'; import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
export class ScriptFileCreationOrchestrator implements ScriptFileCreator { export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(), private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(), private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
@@ -26,9 +27,12 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): Promise<ScriptFileCreationOutcome> { ): Promise<ScriptFileCreationOutcome> {
const { const {
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath, success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
} = await this.directoryProvider.provideScriptDirectory(); } = await this.directoryProvider.provideDirectory('script-runs');
if (!isDirectoryCreated) { if (!isDirectoryCreated) {
return createFailure(directoryCreationError); return createFailure({
type: 'DirectoryCreationError',
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
});
} }
const { const {
success: isFilePathConstructed, error: filePathGenerationError, filePath, success: isFilePathConstructed, error: filePathGenerationError, filePath,
@@ -54,7 +58,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): FilePathConstructionOutcome { ): FilePathConstructionOutcome {
try { try {
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts); const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
const filePath = this.system.location.combinePaths(directoryPath, filename); const filePath = this.fileSystem.combinePaths(directoryPath, filename);
return { success: true, filePath }; return { success: true, filePath };
} catch (error) { } catch (error) {
return { return {

View File

@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter { export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly system: SystemOperations = NodeElectronSystemOperations,
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }

View File

@@ -7,7 +7,7 @@ import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunn
export class LoggingNodeShellCommandRunner implements ShellCommandRunner { export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(), private readonly systemOps: SystemOperations = NodeElectronSystemOperations,
) { ) {
} }

View File

@@ -1,57 +1,13 @@
import { join } from 'node:path';
import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { app } from 'electron/main'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import type { import type { SystemOperations } from './SystemOperations';
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
} from './SystemOperations';
/** /**
* Thin wrapper for Node and Electron APIs. * Thin wrapper for Node and Electron APIs.
*/ */
export class NodeElectronSystemOperations implements SystemOperations { export const NodeElectronSystemOperations: SystemOperations = {
public readonly operatingSystem: OperatingSystemOps = { fileSystem: NodeElectronFileSystemOperations,
/* command: {
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
getUserDataDirectory: () => {
return app.getPath('userData');
},
};
public readonly location: LocationOps = {
combinePaths: (...pathSegments) => join(...pathSegments),
};
public readonly fileSystem: FileSystemOps = {
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
};
public readonly command: CommandOps = {
exec, exec,
},
}; };
}

View File

@@ -1,25 +1,11 @@
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { exec } from 'node:child_process'; import type { exec } from 'node:child_process';
export interface SystemOperations { export interface SystemOperations {
readonly operatingSystem: OperatingSystemOps; readonly fileSystem: FileSystemOperations;
readonly location: LocationOps;
readonly fileSystem: FileSystemOps;
readonly command: CommandOps; readonly command: CommandOps;
} }
export interface OperatingSystemOps {
getUserDataDirectory(): string;
}
export interface LocationOps {
combinePaths(...pathSegments: string[]): string;
}
export interface CommandOps { export interface CommandOps {
exec(command: string): ReturnType<typeof exec>; exec(command: string): ReturnType<typeof exec>;
} }
export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
}

View File

@@ -5,8 +5,8 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { import {
FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome, FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome,
} from '@/presentation/common/Dialog'; } from '@/presentation/common/Dialog';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog'; import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog { export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {

View File

@@ -0,0 +1,30 @@
export interface ApplicationDirectoryProvider {
provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome>;
}
export type DirectoryType = 'update-installation-files' | 'script-runs';
export type DirectoryCreationOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
export type DirectoryCreationErrorType = 'PathConstructionError' | 'DirectoryWriteError' | 'UserDataFolderRetrievalError';
export interface DirectoryCreationError {
readonly type: DirectoryCreationErrorType;
readonly message: string;
}
interface DirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: DirectoryCreationError;
}
interface SuccessfulDirectoryCreation extends DirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends DirectoryCreationStatus {
readonly success: false;
readonly error: DirectoryCreationError;
}

View File

@@ -1,32 +1,37 @@
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations'; import type {
import type { SystemOperations } from '../../System/SystemOperations'; DirectoryCreationOutcome, ApplicationDirectoryProvider, DirectoryType,
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider'; DirectoryCreationError, DirectoryCreationErrorType,
} from './ApplicationDirectoryProvider';
import type { FileSystemOperations } from '../FileSystemOperations';
export const ExecutionSubdirectory = 'runs'; export const SubdirectoryNames: Record<DirectoryType, string> = {
'script-runs': 'runs',
'update-installation-files': 'updates',
};
/** /**
* Provides a dedicated directory for script execution. * Provides persistent directories.
* Benefits of using a persistent directory: * Benefits of using a persistent directory:
* - Antivirus Exclusions: Easier antivirus configuration. * - Antivirus Exclusions: Easier antivirus configuration.
* - Auditability: Stores script execution history for troubleshooting. * - Auditability: Stores script execution history for troubleshooting.
* - Reliability: Avoids issues with directory clean-ups during execution, * - Reliability: Avoids issues with directory clean-ups during execution,
* seen in Windows Pro Azure VMs when stored on Windows temporary directory. * seen in Windows Pro Azure VMs when stored on Windows temporary directory.
*/ */
export class PersistentDirectoryProvider implements ScriptDirectoryProvider { export class PersistentApplicationDirectoryProvider implements ApplicationDirectoryProvider {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> { public async provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
const { const {
success: isPathConstructed, success: isPathConstructed,
error: pathConstructionError, error: pathConstructionError,
directoryPath, directoryPath,
} = this.constructScriptDirectoryPath(); } = this.constructScriptDirectoryPath(type);
if (!isPathConstructed) { if (!isPathConstructed) {
return { return {
success: false, success: false,
@@ -52,7 +57,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> { private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
try { try {
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`); this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
await this.system.fileSystem.createDirectory(directoryPath, true); await this.fileSystem.createDirectory(directoryPath, true);
this.logger.info(`Script directory successfully created at: ${directoryPath}`); this.logger.info(`Script directory successfully created at: ${directoryPath}`);
return { return {
success: true, success: true,
@@ -60,17 +65,26 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleException(error, 'DirectoryCreationError'), error: this.handleError(error, 'DirectoryWriteError'),
}; };
} }
} }
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome { private constructScriptDirectoryPath(type: DirectoryType): DirectoryPathConstructionOutcome {
let parentDirectory: string;
try { try {
const parentDirectory = this.system.operatingSystem.getUserDataDirectory(); parentDirectory = this.fileSystem.getUserDataDirectory();
const scriptDirectory = this.system.location.combinePaths( } catch (error) {
return {
success: false,
error: this.handleError(error, 'UserDataFolderRetrievalError'),
};
}
try {
const subdirectoryName = SubdirectoryNames[type];
const scriptDirectory = this.fileSystem.combinePaths(
parentDirectory, parentDirectory,
ExecutionSubdirectory, subdirectoryName,
); );
return { return {
success: true, success: true,
@@ -79,15 +93,15 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleException(error, 'DirectoryCreationError'), error: this.handleError(error, 'PathConstructionError'),
}; };
} }
} }
private handleException( private handleError(
exception: Error, exception: Error,
errorType: CodeRunErrorType, errorType: DirectoryCreationErrorType,
): CodeRunError { ): DirectoryCreationError {
const errorMessage = 'Error during script directory creation'; const errorMessage = 'Error during script directory creation';
this.logger.error(errorType, errorMessage, exception); this.logger.error(errorType, errorMessage, exception);
return { return {
@@ -99,7 +113,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
type DirectoryPathConstructionOutcome = { type DirectoryPathConstructionOutcome = {
readonly success: false; readonly success: false;
readonly error: CodeRunError; readonly error: DirectoryCreationError;
readonly directoryPath?: undefined; readonly directoryPath?: undefined;
} | { } | {
readonly success: true; readonly success: true;
@@ -109,7 +123,7 @@ type DirectoryPathConstructionOutcome = {
type DirectoryPathCreationOutcome = { type DirectoryPathCreationOutcome = {
readonly success: false; readonly success: false;
readonly error: CodeRunError; readonly error: DirectoryCreationError;
} | { } | {
readonly success: true; readonly success: true;
readonly error?: undefined; readonly error?: undefined;

View File

@@ -0,0 +1,20 @@
export interface FileSystemOperations {
getUserDataDirectory(): string;
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
isFileAvailable(filePath: string): Promise<boolean>;
isDirectoryAvailable(filePath: string): Promise<boolean>;
deletePath(filePath: string): Promise<void>;
listDirectoryContents(directoryPath: string): Promise<string[]>;
combinePaths(...pathSegments: string[]): string;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
writeFile: (
filePath: string,
fileContents: string,
encoding: NodeJS.BufferEncoding,
) => Promise<void>;
}

View File

@@ -0,0 +1,68 @@
import { join } from 'node:path';
import {
chmod, mkdir,
readdir, rm, stat,
readFile, writeFile,
} from 'node:fs/promises';
import { app } from 'electron/main';
import type { FileSystemOperations } from './FileSystemOperations';
import type { Stats } from 'node:original-fs';
/**
* Thin wrapper for Node and Electron APIs.
*/
export const NodeElectronFileSystemOperations: FileSystemOperations = {
combinePaths: (...pathSegments) => join(...pathSegments),
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
isFileAvailable: async (path) => isPathAvailable(path, (stats) => stats.isFile()),
isDirectoryAvailable: async (path) => isPathAvailable(path, (stats) => stats.isDirectory()),
deletePath: (path) => rm(path, { recursive: true, force: true }),
listDirectoryContents: (directoryPath) => readdir(directoryPath),
getUserDataDirectory: () => {
/*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
return app.getPath('userData');
},
writeFile,
readFile,
};
async function isPathAvailable(
path: string,
condition: (stats: Stats) => boolean,
): Promise<boolean> {
try {
const stats = await stat(path);
return condition(stats);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // path does not exist
}
throw error;
}
}

View File

@@ -1,22 +1,18 @@
import { writeFile, access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../Log/ElectronLogger'; import { ElectronLogger } from '../../Log/ElectronLogger';
import { NodeElectronFileSystemOperations } from '../NodeElectronFileSystemOperations';
import type { import type {
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType, FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
FileWriteOutcome, SuccessfulFileWrite, FileWriteOutcome, SuccessfulFileWrite,
} from './ReadbackFileWriter'; } from './ReadbackFileWriter';
import type { FileSystemOperations } from '../FileSystemOperations';
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8'; const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
export class NodeReadbackFileWriter implements ReadbackFileWriter { export class NodeReadbackFileWriter implements ReadbackFileWriter {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly fileSystem: FileReadWriteOperations = { private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
writeFile,
readFile: (path, encoding) => readFile(path, encoding),
access,
},
) { } ) { }
public async writeAndVerifyFile( public async writeAndVerifyFile(
@@ -55,7 +51,9 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
filePath: string, filePath: string,
): Promise<FileWriteOutcome> { ): Promise<FileWriteOutcome> {
try { try {
await this.fileSystem.access(filePath, constants.F_OK); if (!(await this.fileSystem.isFileAvailable(filePath))) {
return this.reportFailure('FileExistenceVerificationFailed', 'File does not exist.');
}
return this.reportSuccess('Verified file existence without reading.'); return this.reportSuccess('Verified file existence without reading.');
} catch (error) { } catch (error) {
return this.reportFailure('FileExistenceVerificationFailed', error); return this.reportFailure('FileExistenceVerificationFailed', error);
@@ -107,9 +105,3 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
}; };
} }
} }
export interface FileReadWriteOperations {
readonly writeFile: typeof writeFile;
readonly access: typeof access;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
}

View File

@@ -1,19 +1,22 @@
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider'; import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import type { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider'; import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector { export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector {
constructor( constructor(
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly environment: RuntimeEnvironment = CurrentEnvironment, private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { } ) { }
public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> { public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> {
const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory(); const {
directoryAbsolutePath: scriptsDirectory,
} = await this.directoryProvider.provideDirectory('script-runs');
return { return {
scriptsDirectoryAbsolutePath: directoryAbsolutePath, scriptsDirectoryAbsolutePath: scriptsDirectory,
currentOperatingSystem: this.environment.os, currentOperatingSystem: this.environment.os,
}; };
} }

View File

@@ -7,63 +7,100 @@ import type { ProgressInfo } from 'electron-builder';
export async function handleAutoUpdate() { export async function handleAutoUpdate() {
const autoUpdater = getAutoUpdater(); const autoUpdater = getAutoUpdater();
if (await askDownloadAndInstall() === DownloadDialogResult.NotNow) { if (await askDownloadAndInstall() === UpdateDialogResult.Postpone) {
ElectronLogger.info('User chose to postpone update');
return; return;
} }
startHandlingUpdateProgress(autoUpdater); ElectronLogger.info('User chose to download and install update');
await autoUpdater.downloadUpdate(); try {
await startHandlingUpdateProgress(autoUpdater);
} catch (error) {
ElectronLogger.error('Failed to handle auto-update process', { error });
}
} }
function startHandlingUpdateProgress(autoUpdater: AppUpdater) { function startHandlingUpdateProgress(autoUpdater: AppUpdater): Promise<void> {
return new Promise((resolve, reject) => { // Block until update process completes
const progressBar = new UpdateProgressBar(); const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState(); progressBar.showIndeterminateState();
autoUpdater.on('error', (e) => { autoUpdater.on('error', (e) => {
progressBar.showError(e); progressBar.showError(e);
reject(e);
}); });
autoUpdater.on('download-progress', (progress: ProgressInfo) => { autoUpdater.on('download-progress', (progress: ProgressInfo) => {
/* /*
On macOS, download-progress event is not called. On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished. So the indeterminate progress will continue until download is finished.
*/ */
ElectronLogger.debug('@download-progress@\n', progress); ElectronLogger.debug('Update download progress', { progress });
if (progressBar.isOpen) { // May be closed by the user if (progressBar.isOpen) { // May be closed by the user
progressBar.showProgress(progress); progressBar.showProgress(progress);
} }
}); });
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => { autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
ElectronLogger.info('@update-downloaded@\n', info); ElectronLogger.info('Update downloaded successfully', { version: info.version });
progressBar.closeIfOpen(); progressBar.closeIfOpen();
try {
await handleUpdateDownloaded(autoUpdater); await handleUpdateDownloaded(autoUpdater);
} catch (error) {
ElectronLogger.error('Failed to handle downloaded update', { error });
reject(error);
}
resolve();
});
autoUpdater.downloadUpdate();
}); });
} }
async function handleUpdateDownloaded(autoUpdater: AppUpdater) { async function handleUpdateDownloaded(
if (await askRestartAndInstall() === InstallDialogResult.NotNow) { autoUpdater: AppUpdater,
return; ): Promise<void> {
return new Promise((resolve, reject) => { // Block until update download process completes
askRestartAndInstall()
.then((result) => {
if (result === InstallDialogResult.InstallAndRestart) {
ElectronLogger.info('User chose to install and restart for update');
setTimeout(() => {
try {
autoUpdater.quitAndInstall();
resolve();
} catch (error) {
ElectronLogger.error('Failed to quit and install update', { error });
reject(error);
} }
setTimeout(() => autoUpdater.quitAndInstall(), 1); }, 1);
} else {
ElectronLogger.info('User chose to postpone update installation');
resolve();
}
})
.catch((error) => {
ElectronLogger.error('Failed to prompt user for restart and install', { error });
reject(error);
});
});
} }
enum DownloadDialogResult { enum UpdateDialogResult {
Install = 0, Update = 0,
NotNow = 1, Postpone = 1,
} }
async function askDownloadAndInstall(): Promise<DownloadDialogResult> { async function askDownloadAndInstall(): Promise<UpdateDialogResult> {
const updateDialogResult = await dialog.showMessageBox({ const updateDialogResult = await dialog.showMessageBox({
type: 'question', type: 'question',
buttons: ['Install', 'Not now'], buttons: ['Install', 'Not now'],
title: 'Confirm Update', title: 'Confirm Update',
message: 'Update available.\n\nWould you like to download and install new version?', message: 'Update available.\n\nWould you like to download and install new version?',
detail: 'Application will automatically restart to apply update after download', detail: 'Application will automatically restart to apply update after download',
defaultId: DownloadDialogResult.Install, defaultId: UpdateDialogResult.Update,
cancelId: DownloadDialogResult.NotNow, cancelId: UpdateDialogResult.Postpone,
}); });
return updateDialogResult.response; return updateDialogResult.response;
} }
enum InstallDialogResult { enum InstallDialogResult {
InstallAndRestart = 0, InstallAndRestart = 0,
NotNow = 1, Postpone = 1,
} }
async function askRestartAndInstall(): Promise<InstallDialogResult> { async function askRestartAndInstall(): Promise<InstallDialogResult> {
const installDialogResult = await dialog.showMessageBox({ const installDialogResult = await dialog.showMessageBox({
@@ -72,7 +109,7 @@ async function askRestartAndInstall(): Promise<InstallDialogResult> {
message: `A new version of ${app.name} has been downloaded.`, message: `A new version of ${app.name} has been downloaded.`,
detail: 'It will be installed the next time you restart the application.', detail: 'It will be installed the next time you restart the application.',
defaultId: InstallDialogResult.InstallAndRestart, defaultId: InstallDialogResult.InstallAndRestart,
cancelId: InstallDialogResult.NotNow, cancelId: InstallDialogResult.Postpone,
}); });
return installDialogResult.response; return installDialogResult.response;
} }

View File

@@ -1,10 +1,8 @@
import { existsSync, createWriteStream, type WriteStream } from 'node:fs'; import { createWriteStream, type WriteStream } from 'node:fs';
import { unlink, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { app } from 'electron/main';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger';
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar'; import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { provideUpdateInstallationFilepath, type InstallationFilepathProvider } from './InstallationFiles/InstallationFilepathProvider';
import type { UpdateInfo } from 'electron-updater'; import type { UpdateInfo } from 'electron-updater';
import type { ReadableStream } from 'node:stream/web'; import type { ReadableStream } from 'node:stream/web';
@@ -18,18 +16,25 @@ export type DownloadUpdateResult = {
readonly installerPath: string; readonly installerPath: string;
}; };
interface UpdateDownloadUtilities {
readonly logger: Logger;
readonly provideInstallationFilePath: InstallationFilepathProvider;
}
export async function downloadUpdate( export async function downloadUpdate(
info: UpdateInfo, info: UpdateInfo,
remoteFileUrl: string, remoteFileUrl: string,
progressBar: UpdateProgressBar, progressBar: UpdateProgressBar,
utilities: UpdateDownloadUtilities = DefaultUtilities,
): Promise<DownloadUpdateResult> { ): Promise<DownloadUpdateResult> {
ElectronLogger.info('Starting manual update download.'); utilities.logger.info('Starting manual update download.');
progressBar.showIndeterminateState(); progressBar.showIndeterminateState();
try { try {
const { filePath } = await downloadInstallerFile( const { filePath } = await downloadInstallerFile(
info.version, info.version,
remoteFileUrl, remoteFileUrl,
(percentage) => { progressBar.showPercentage(percentage); }, (percentage) => { progressBar.showPercentage(percentage); },
utilities,
); );
return { return {
success: true, success: true,
@@ -47,58 +52,40 @@ async function downloadInstallerFile(
version: string, version: string,
remoteFileUrl: string, remoteFileUrl: string,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): Promise<{ readonly filePath: string; }> { ): Promise<{ readonly filePath: string; }> {
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${version}-installer.dmg`; const filePath = await utilities.provideInstallationFilePath(version);
if (!await ensureFilePathReady(filePath)) {
throw new Error(`Failed to prepare the file path for the installer: ${filePath}`);
}
await downloadFileWithProgress( await downloadFileWithProgress(
remoteFileUrl, remoteFileUrl,
filePath, filePath,
progressHandler, progressHandler,
utilities,
); );
return { filePath }; return { filePath };
} }
async function ensureFilePathReady(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => {
try {
const parentFolder = path.dirname(filePath);
if (existsSync(filePath)) {
ElectronLogger.info(`Existing update file found and will be replaced: ${filePath}`);
await unlink(filePath);
} else {
await mkdir(parentFolder, { recursive: true });
}
return true;
} catch (error) {
ElectronLogger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
type ProgressCallback = (progress: number) => void; type ProgressCallback = (progress: number) => void;
async function downloadFileWithProgress( async function downloadFileWithProgress(
url: string, url: string,
filePath: string, filePath: string,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
) { ) {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types. utilities.logger.info(`Retrieving update from ${url}.`);
ElectronLogger.info(`Retrieving update from ${url}.`);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`); throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`);
} }
const contentLength = getContentLengthFromResponse(response); const contentLength = getContentLengthFromResponse(response, utilities);
await withWriteStream(filePath, async (writer) => { await withWriteStream(filePath, async (writer) => {
ElectronLogger.info(contentLength.isValid utilities.logger.info(contentLength.isValid
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).` ? `Saving file to '${filePath}' (Size: ${contentLength.totalLength} bytes).`
: `Saving file to ${filePath}.`); : `Saving file to '${filePath}'.`);
await withReadableStream(response, async (reader) => { await withReadableStream(response, async (reader) => {
await streamWithProgress(contentLength, reader, writer, progressHandler); await streamWithProgress(contentLength, reader, writer, progressHandler, utilities);
}); });
ElectronLogger.info(`Successfully saved the file: '${filePath}'`);
}); });
} }
@@ -109,16 +96,19 @@ type ResponseContentLength = {
readonly isValid: false; readonly isValid: false;
}; };
function getContentLengthFromResponse(response: Response): ResponseContentLength { function getContentLengthFromResponse(
response: Response,
utilities: UpdateDownloadUtilities,
): ResponseContentLength {
const contentLengthString = response.headers.get('content-length'); const contentLengthString = response.headers.get('content-length');
const headersInfo = Array.from(response.headers.entries()); const headersInfo = Array.from(response.headers.entries());
if (!contentLengthString) { if (!contentLengthString) {
ElectronLogger.warn('Missing \'Content-Length\' header in the response.', headersInfo); utilities.logger.warn('Missing \'Content-Length\' header in the response.', headersInfo);
return { isValid: false }; return { isValid: false };
} }
const contentLength = Number(contentLengthString); const contentLength = Number(contentLengthString);
if (Number.isNaN(contentLength) || contentLength <= 0) { if (Number.isNaN(contentLength) || contentLength <= 0) {
ElectronLogger.error('Unable to determine download size from server response.', headersInfo); utilities.logger.error('Unable to determine download size from server response.', headersInfo);
return { isValid: false }; return { isValid: false };
} }
return { totalLength: contentLength, isValid: true }; return { totalLength: contentLength, isValid: true };
@@ -153,6 +143,7 @@ async function streamWithProgress(
readStream: ReadableStream, readStream: ReadableStream,
writeStream: WriteStream, writeStream: WriteStream,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): Promise<void> { ): Promise<void> {
let receivedLength = 0; let receivedLength = 0;
let logThreshold = 0; let logThreshold = 0;
@@ -163,22 +154,23 @@ async function streamWithProgress(
writeStream.write(Buffer.from(chunk)); writeStream.write(Buffer.from(chunk));
receivedLength += chunk.length; receivedLength += chunk.length;
notifyProgress(contentLength, receivedLength, progressHandler); notifyProgress(contentLength, receivedLength, progressHandler);
const progressLog = logProgress(receivedLength, contentLength, logThreshold); const progressLog = logProgress(receivedLength, contentLength, logThreshold, utilities);
logThreshold = progressLog.nextLogThreshold; logThreshold = progressLog.nextLogThreshold;
} }
ElectronLogger.info('Update download completed successfully.'); utilities.logger.info('Update download completed successfully.');
} }
function logProgress( function logProgress(
receivedLength: number, receivedLength: number,
contentLength: ResponseContentLength, contentLength: ResponseContentLength,
logThreshold: number, logThreshold: number,
utilities: UpdateDownloadUtilities,
): { readonly nextLogThreshold: number; } { ): { readonly nextLogThreshold: number; } {
const { const {
shouldLog, nextLogThreshold, shouldLog, nextLogThreshold,
} = shouldLogProgress(receivedLength, contentLength, logThreshold); } = shouldLogProgress(receivedLength, contentLength, logThreshold);
if (shouldLog) { if (shouldLog) {
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`); utilities.logger.debug(`Download progress: ${receivedLength} bytes received.`);
} }
return { nextLogThreshold }; return { nextLogThreshold };
} }
@@ -220,3 +212,8 @@ function createReader(response: Response): ReadableStream {
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004 // https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004
return response.body as ReadableStream; return response.body as ReadableStream;
} }
const DefaultUtilities: UpdateDownloadUtilities = {
logger: ElectronLogger,
provideInstallationFilePath: provideUpdateInstallationFilepath,
};

View File

@@ -1,15 +1,21 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export function retryFileSystemAccess( export interface FileSystemAccessorWithRetry {
(
fileOperation: () => Promise<boolean>, fileOperation: () => Promise<boolean>,
): Promise<boolean> { ): Promise<boolean>;
}
export const retryFileSystemAccess: FileSystemAccessorWithRetry = (
fileOperation,
) => {
return retryWithExponentialBackoff( return retryWithExponentialBackoff(
fileOperation, fileOperation,
TOTAL_RETRIES, TOTAL_RETRIES,
INITIAL_DELAY_MS, INITIAL_DELAY_MS,
); );
} };
// These values provide a balanced approach for handling transient file system // These values provide a balanced approach for handling transient file system
// issues without excessive waiting. // issues without excessive waiting.

View File

@@ -0,0 +1,107 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
export interface InstallationFileCleaner {
(
utilities?: UpdateFileUtilities,
): Promise<void>;
}
interface UpdateFileUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
}
export const clearUpdateInstallationFiles: InstallationFileCleaner = async (
utilities = DefaultUtilities,
) => {
utilities.logger.info('Starting update installation files cleanup');
const { success, error, directoryAbsolutePath } = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Failed to locate installation files directory', { error });
throw new Error('Cannot locate the installation files directory path');
}
const installationFileNames = await readDirectoryContents(directoryAbsolutePath, utilities);
if (installationFileNames.length === 0) {
utilities.logger.info('No update installation files found');
return;
}
utilities.logger.debug(`Found ${installationFileNames.length} installation files to delete`);
utilities.logger.info('Deleting installation files');
const errors = await executeIndependentTasksAndCollectErrors(
installationFileNames.map(async (fileOrFolderName) => {
await deleteItemFromDirectory(directoryAbsolutePath, fileOrFolderName, utilities);
}),
);
if (errors.length > 0) {
utilities.logger.error('Failed to delete some installation files', { errors });
throw new Error(`Failed to delete some items:\n${errors.join('\n')}`);
}
utilities.logger.info('Update installation files cleanup completed successfully');
};
async function deleteItemFromDirectory(
directoryPath: string,
fileOrFolderName: string,
utilities: UpdateFileUtilities,
): Promise<void> {
const itemPath = utilities.fileSystem.combinePaths(
directoryPath,
fileOrFolderName,
);
try {
utilities.logger.debug(`Deleting installation artifact: ${itemPath}`);
await utilities.fileSystem.deletePath(itemPath);
utilities.logger.debug(`Successfully deleted installation artifact: ${itemPath}`);
} catch (error) {
utilities.logger.error(`Failed to delete installation artifact: ${itemPath}`, { error });
throw error;
}
}
async function readDirectoryContents(
directoryPath: string,
utilities: UpdateFileUtilities,
): Promise<string[]> {
try {
utilities.logger.debug(`Reading directory contents: ${directoryPath}`);
const items = await utilities.fileSystem.listDirectoryContents(directoryPath);
utilities.logger.debug(`Read ${items.length} items from directory: ${directoryPath}`);
return items;
} catch (error) {
utilities.logger.error('Failed to read directory contents', { directoryPath, error });
throw new Error('Failed to read directory contents', { cause: error });
}
}
async function executeIndependentTasksAndCollectErrors(
tasks: (Promise<void>)[],
): Promise<string[]> {
const results = await Promise.allSettled(tasks);
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
return errors.map((error) => {
if (!error) {
return 'unknown error';
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
});
}
const DefaultUtilities: UpdateFileUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
};

View File

@@ -0,0 +1,73 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess, type FileSystemAccessorWithRetry } from '../FileSystemAccessorWithRetry';
export interface InstallationFilepathProvider {
(
version: string,
utilities?: InstallationFilepathProviderUtilities,
): Promise<string>;
}
interface InstallationFilepathProviderUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
readonly accessFileSystemWithRetry: FileSystemAccessorWithRetry;
}
export const InstallerFileSuffix = '-installer.dmg';
export const provideUpdateInstallationFilepath: InstallationFilepathProvider = async (
version,
utilities = DefaultUtilities,
) => {
const {
success, error, directoryAbsolutePath,
} = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Error when providing download directory', error);
throw new Error('Failed to provide download directory.');
}
const filepath = utilities.fileSystem.combinePaths(directoryAbsolutePath, `${version}${InstallerFileSuffix}`);
if (!await makeFilepathAvailable(filepath, utilities)) {
throw new Error(`Failed to prepare the file path for the installer: ${filepath}`);
}
return filepath;
};
async function makeFilepathAvailable(
filePath: string,
utilities: InstallationFilepathProviderUtilities,
): Promise<boolean> {
let isFileAvailable = false;
try {
isFileAvailable = await utilities.fileSystem.isFileAvailable(filePath);
} catch (error) {
throw new Error('File availability check failed');
}
if (!isFileAvailable) {
return true;
}
return utilities.accessFileSystemWithRetry(async () => {
try {
utilities.logger.info(`Existing update file found and will be replaced: ${filePath}`);
await utilities.fileSystem.deletePath(filePath);
return true;
} catch (error) {
utilities.logger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
const DefaultUtilities: InstallationFilepathProviderUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
accessFileSystemWithRetry: retryFileSystemAccess,
};

View File

@@ -1,7 +1,7 @@
import { app } from 'electron/main'; import { app } from 'electron/main';
import { shell } from 'electron/common'; import { shell } from 'electron/common';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function startInstallation(filePath: string): Promise<boolean> { export async function startInstallation(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => { return retryFileSystemAccess(async () => {

View File

@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function checkIntegrity( export async function checkIntegrity(
filePath: string, filePath: string,

View File

@@ -14,29 +14,43 @@ import {
import { type DownloadUpdateResult, downloadUpdate } from './Downloader'; import { type DownloadUpdateResult, downloadUpdate } from './Downloader';
import { checkIntegrity } from './Integrity'; import { checkIntegrity } from './Integrity';
import { startInstallation } from './Installer'; import { startInstallation } from './Installer';
import { clearUpdateInstallationFiles } from './InstallationFiles/InstallationFileCleaner';
import type { UpdateInfo } from 'electron-updater'; import type { UpdateInfo } from 'electron-updater';
export function requiresManualUpdate(): boolean { export function requiresManualUpdate(
return process.platform === 'darwin'; nodePlatform: string = process.platform,
): boolean {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
return nodePlatform === 'darwin';
} }
export async function startManualUpdateProcess(info: UpdateInfo) { export async function startManualUpdateProcess(info: UpdateInfo) {
try {
await clearUpdateInstallationFiles();
} catch (error) {
ElectronLogger.warn('Failed to clear previous update installation files', { error });
} finally {
await executeManualUpdateProcess(info);
}
}
async function executeManualUpdateProcess(info: UpdateInfo): Promise<void> {
try { try {
const updateAction = await promptForManualUpdate(); const updateAction = await promptForManualUpdate();
if (updateAction === ManualUpdateChoice.NoAction) { if (updateAction === ManualUpdateChoice.NoAction) {
ElectronLogger.info('User cancelled the update.'); ElectronLogger.info('User chose to cancel the update');
return; return;
} }
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version); const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
if (updateAction === ManualUpdateChoice.VisitReleasesPage) { if (updateAction === ManualUpdateChoice.VisitReleasesPage) {
ElectronLogger.info(`Navigating to release page: ${releaseUrl}`); ElectronLogger.info('User chose to visit release page', { url: releaseUrl });
await shell.openExternal(releaseUrl); await shell.openExternal(releaseUrl);
} else if (updateAction === ManualUpdateChoice.UpdateNow) { } else if (updateAction === ManualUpdateChoice.UpdateNow) {
ElectronLogger.info('Initiating update download and installation.'); ElectronLogger.info('User chose to download and install update');
await downloadAndInstallUpdate(downloadUrl, info); await downloadAndInstallUpdate(downloadUrl, info);
} }
} catch (err) { } catch (err) {
ElectronLogger.error('Unexpected error during updates', err); ElectronLogger.error('Failed to execute auto-update process', { error: err });
await handleUnexpectedError(info); await handleUnexpectedError(info);
} }
} }
@@ -56,9 +70,10 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
} }
const userAction = await promptIntegrityCheckFailure(); const userAction = await promptIntegrityCheckFailure();
if (userAction === IntegrityCheckChoice.RetryDownload) { if (userAction === IntegrityCheckChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after integrity check failure');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} else if (userAction === IntegrityCheckChoice.ContinueAnyway) { } else if (userAction === IntegrityCheckChoice.ContinueAnyway) {
ElectronLogger.warn('Proceeding to install with failed integrity check.'); ElectronLogger.warn('User chose to proceed with installation despite failed integrity check');
await openInstaller(download.installerPath, info); await openInstaller(download.installerPath, info);
} }
} }
@@ -66,9 +81,9 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
async function handleFailedDownload(info: UpdateInfo) { async function handleFailedDownload(info: UpdateInfo) {
const userAction = await promptDownloadError(); const userAction = await promptDownloadError();
if (userAction === DownloadErrorChoice.Cancel) { if (userAction === DownloadErrorChoice.Cancel) {
ElectronLogger.info('Update download canceled.'); ElectronLogger.info('User chose to cancel update download');
} else if (userAction === DownloadErrorChoice.RetryDownload) { } else if (userAction === DownloadErrorChoice.RetryDownload) {
ElectronLogger.info('Retrying update download.'); ElectronLogger.info('User chose to retry update download');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} }
} }
@@ -76,9 +91,9 @@ async function handleFailedDownload(info: UpdateInfo) {
async function handleUnexpectedError(info: UpdateInfo) { async function handleUnexpectedError(info: UpdateInfo) {
const userAction = await showUnexpectedError(); const userAction = await showUnexpectedError();
if (userAction === UnexpectedErrorChoice.Cancel) { if (userAction === UnexpectedErrorChoice.Cancel) {
ElectronLogger.info('Unexpected error handling canceled.'); ElectronLogger.info('User chose to cancel update process after unexpected error');
} else if (userAction === UnexpectedErrorChoice.RetryUpdate) { } else if (userAction === UnexpectedErrorChoice.RetryUpdate) {
ElectronLogger.info('Retrying the update process.'); ElectronLogger.info('User chose to retry update process after unexpected error');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} }
} }
@@ -89,8 +104,10 @@ async function openInstaller(installerPath: string, info: UpdateInfo) {
} }
const userAction = await promptInstallerOpenError(); const userAction = await promptInstallerOpenError();
if (userAction === InstallerErrorChoice.RetryDownload) { if (userAction === InstallerErrorChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after installer open error');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} else if (userAction === InstallerErrorChoice.RetryOpen) { } else if (userAction === InstallerErrorChoice.RetryOpen) {
ElectronLogger.info('User chose to retry opening installer');
await openInstaller(installerPath, info); await openInstaller(installerPath, info);
} }
} }
@@ -119,16 +136,16 @@ async function isIntegrityPreserved(
function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined { function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined {
const fileInfos = info.files.filter((file) => fileUrl.includes(file.url)); const fileInfos = info.files.filter((file) => fileUrl.includes(file.url));
if (!fileInfos.length) { if (!fileInfos.length) {
ElectronLogger.error(`Remote hash not found for the URL: ${fileUrl}`, info.files); ElectronLogger.error('Failed to find remote hash for download URL', { url: fileUrl, files: info.files });
if (info.files.length > 0) { if (info.files.length > 0) {
const firstHash = info.files[0].sha512; const firstHash = info.files[0].sha512;
ElectronLogger.info(`Selecting the first available hash: ${firstHash}`); ElectronLogger.info('Using first available hash due to missing match', { hash: firstHash });
return firstHash; return firstHash;
} }
return undefined; return undefined;
} }
if (fileInfos.length > 1) { if (fileInfos.length > 1) {
ElectronLogger.error(`Found multiple file entries for the URL: ${fileUrl}`, fileInfos); ElectronLogger.warn('Multiple file entries found for download URL', { url: fileUrl, entries: fileInfos });
} }
return fileInfos[0].sha512; return fileInfos[0].sha512;
} }

View File

@@ -97,7 +97,7 @@ if (isDevelopment) {
} }
} }
function loadApplication(window: BrowserWindow) { function loadApplication(window: BrowserWindow): void {
if (RENDERER_URL) { // Populated in a dev server during development if (RENDERER_URL) { // Populated in a dev server during development
loadUrlWithNodeWorkaround(window, RENDERER_URL); loadUrlWithNodeWorkaround(window, RENDERER_URL);
} else { } else {

View File

@@ -3,13 +3,13 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { describe, it } from 'vitest'; import { describe, it } from 'vitest';
import type { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator'; import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator';
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner'; import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/LinuxVisibleTerminalCommand'; import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/LinuxVisibleTerminalCommand';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
describe('ScriptFileCodeRunner', () => { describe('ScriptFileCodeRunner', () => {
it('executes simple script correctly', async ({ skip }) => { it('executes simple script correctly', async ({ skip }) => {
@@ -79,16 +79,16 @@ function isLinuxTerminalEmulatorSupported(): Promise<boolean> {
}); });
} }
function createCodeRunner(directoryProvider: ScriptDirectoryProvider): ScriptFileCodeRunner { function createCodeRunner(directoryProvider: ApplicationDirectoryProvider): ScriptFileCodeRunner {
return new ScriptFileCodeRunner( return new ScriptFileCodeRunner(
undefined, undefined,
new ScriptFileCreationOrchestrator(undefined, undefined, directoryProvider), new ScriptFileCreationOrchestrator(undefined, undefined, directoryProvider),
); );
} }
function createTemporaryDirectoryProvider(): ScriptDirectoryProvider { function createTemporaryDirectoryProvider(): ApplicationDirectoryProvider {
return { return {
provideScriptDirectory: async () => { provideDirectory: async () => {
const temporaryDirectoryPathPrefix = join(tmpdir(), 'privacy-sexy-tests-'); const temporaryDirectoryPathPrefix = join(tmpdir(), 'privacy-sexy-tests-');
const temporaryDirectoryFullPath = await mkdtemp(temporaryDirectoryPathPrefix); const temporaryDirectoryFullPath = await mkdtemp(temporaryDirectoryPathPrefix);
return { return {

View File

@@ -1,22 +1,20 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator'; import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; 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 type { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import type { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider'; import { ApplicationDirectoryProviderStub } from '@tests/unit/shared/Stubs/ApplicationDirectoryProviderStub';
import { ScriptDirectoryProviderStub } from '@tests/unit/shared/Stubs/ScriptDirectoryProviderStub';
import type { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator'; import type { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator';
import { FilenameGeneratorStub } from '@tests/unit/shared/Stubs/FilenameGeneratorStub'; 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 type { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue'; import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; 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 { 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('ScriptFileCreationOrchestrator', () => {
describe('createScriptFile', () => { describe('createScriptFile', () => {
@@ -25,15 +23,15 @@ describe('ScriptFileCreationOrchestrator', () => {
// arrange // arrange
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/'; const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
const expectedScriptDirectory = '/expected-script-directory'; const expectedScriptDirectory = '/expected-script-directory';
const filesystem = new FileSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub()
.withDefaultSeparator(pathSegmentSeparator);
const context = new ScriptFileCreatorTestSetup() const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub() .withFileSystem(fileSystemStub)
.withLocation(
new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator),
)
.withFileSystem(filesystem))
.withDirectoryProvider( .withDirectoryProvider(
new ScriptDirectoryProviderStub().withDirectoryPath(expectedScriptDirectory), new ApplicationDirectoryProviderStub().withDirectoryPath(
'script-runs',
expectedScriptDirectory,
),
); );
// act // act
@@ -52,13 +50,12 @@ describe('ScriptFileCreationOrchestrator', () => {
it('correctly generates filename', async () => { it('correctly generates filename', async () => {
// arrange // arrange
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/'; const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
const filesystem = new FileSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub()
.withDefaultSeparator(pathSegmentSeparator);
const expectedFilename = 'expected-script-file-name'; const expectedFilename = 'expected-script-file-name';
const context = new ScriptFileCreatorTestSetup() const context = new ScriptFileCreatorTestSetup()
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename)) .withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename))
.withSystem(new SystemOperationsStub() .withFileSystem(fileSystemStub);
.withFileSystem(filesystem)
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)));
// act // act
const { success, scriptFileAbsolutePath } = await context.createScriptFile(); const { success, scriptFileAbsolutePath } = await context.createScriptFile();
@@ -97,15 +94,13 @@ describe('ScriptFileCreationOrchestrator', () => {
const expectedPath = 'expected-script-path'; const expectedPath = 'expected-script-path';
const filename = 'filename'; const filename = 'filename';
const directoryPath = 'directory-path'; const directoryPath = 'directory-path';
const filesystem = new FileSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub()
.withJoinResult(expectedPath, directoryPath, filename);
const context = new ScriptFileCreatorTestSetup() const context = new ScriptFileCreatorTestSetup()
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(filename)) .withFilenameGenerator(new FilenameGeneratorStub().withFilename(filename))
.withDirectoryProvider(new ScriptDirectoryProviderStub().withDirectoryPath(directoryPath)) .withDirectoryProvider(new ApplicationDirectoryProviderStub()
.withSystem(new SystemOperationsStub() .withDirectoryPath('script-runs', directoryPath))
.withFileSystem(filesystem) .withFileSystem(fileSystemStub);
.withLocation(
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, filename),
));
// act // act
const { success, scriptFileAbsolutePath } = await context.createScriptFile(); const { success, scriptFileAbsolutePath } = await context.createScriptFile();
@@ -169,11 +164,11 @@ describe('ScriptFileCreationOrchestrator', () => {
expectedErrorMessage: 'Error when combining paths', expectedErrorMessage: 'Error when combining paths',
expectLogs: true, expectLogs: true,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const locationStub = new LocationOpsStub(); const fileSystemStub = new FileSystemOperationsStub();
locationStub.combinePaths = () => { fileSystemStub.combinePaths = () => {
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub)); return setup.withFileSystem(fileSystemStub);
}, },
}, },
...FileWriteOperationErrors.map((writeError): FileCreationFailureTestScenario => ({ ...FileWriteOperationErrors.map((writeError): FileCreationFailureTestScenario => ({
@@ -214,23 +209,40 @@ describe('ScriptFileCreationOrchestrator', () => {
return setup.withFilenameGenerator(filenameGenerator); return setup.withFilenameGenerator(filenameGenerator);
}, },
}, },
{ ...(() => {
description: 'script directory provision failure', 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', expectedErrorType: 'DirectoryCreationError',
expectedErrorMessage: 'Error when providing directory', expectedErrorMessage: `[${directoryErrorType}] ${directoryErrorMessage}`,
expectLogs: false, expectLogs: false,
buildFaultyContext: (setup, errorMessage, errorType) => { buildFaultyContext: (setup) => {
const directoryProvider = new ScriptDirectoryProviderStub(); const directoryProvider = new ApplicationDirectoryProviderStub();
directoryProvider.provideScriptDirectory = () => Promise.resolve({ directoryProvider.provideDirectory = () => Promise.resolve({
success: false, success: false,
error: { error: {
message: errorMessage, type: directoryErrorType as DirectoryCreationErrorType,
type: errorType, message: directoryErrorMessage,
}, },
}); });
return setup.withDirectoryProvider(directoryProvider); return setup.withDirectoryProvider(directoryProvider);
}, },
}, }));
})(),
]; ];
testScenarios.forEach(({ testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs, description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
@@ -276,11 +288,11 @@ describe('ScriptFileCreationOrchestrator', () => {
}); });
class ScriptFileCreatorTestSetup { class ScriptFileCreatorTestSetup {
private system: SystemOperations = new SystemOperationsStub(); private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub(); private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub();
private directoryProvider: ScriptDirectoryProvider = new ScriptDirectoryProviderStub(); private directoryProvider: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
private logger: Logger = new LoggerStub(); private logger: Logger = new LoggerStub();
@@ -298,7 +310,7 @@ class ScriptFileCreatorTestSetup {
return this; return this;
} }
public withDirectoryProvider(directoryProvider: ScriptDirectoryProvider): this { public withDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
this.directoryProvider = directoryProvider; this.directoryProvider = directoryProvider;
return this; return this;
} }
@@ -308,8 +320,8 @@ class ScriptFileCreatorTestSetup {
return this; return this;
} }
public withSystem(system: SystemOperations): this { public withFileSystem(fileSystem: FileSystemOperations): this {
this.system = system; this.fileSystem = fileSystem;
return this; return this;
} }
@@ -330,7 +342,7 @@ class ScriptFileCreatorTestSetup {
public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> { public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> {
const creator = new ScriptFileCreationOrchestrator( const creator = new ScriptFileCreationOrchestrator(
this.system, this.fileSystem,
this.filenameGenerator, this.filenameGenerator,
this.directoryProvider, this.directoryProvider,
this.fileWriter, this.fileWriter,

View File

@@ -5,7 +5,7 @@ import { FileSystemExecutablePermissionSetter } from '@/infrastructure/CodeRunne
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor'; import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations'; import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue'; 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 { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
@@ -15,7 +15,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
it('sets permissions on the specified file', async () => { it('sets permissions on the specified file', async () => {
// arrange // arrange
const expectedFilePath = 'expected-file-path'; const expectedFilePath = 'expected-file-path';
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
const context = new TestContext() const context = new TestContext()
.withFilePath(expectedFilePath) .withFilePath(expectedFilePath)
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -33,7 +33,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
it('applies the correct permissions mode', async () => { it('applies the correct permissions mode', async () => {
// arrange // arrange
const expectedMode = '755'; const expectedMode = '755';
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
const context = new TestContext() const context = new TestContext()
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -49,7 +49,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
it('reports success when permissions are set without errors', async () => { it('reports success when permissions are set without errors', async () => {
// arrange // arrange
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
fileSystem.setFilePermissions = () => Promise.resolve(); fileSystem.setFilePermissions = () => Promise.resolve();
const context = new TestContext() const context = new TestContext()
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -67,7 +67,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
// arrange // arrange
const thrownErrorMessage = 'File system error'; const thrownErrorMessage = 'File system error';
const expectedErrorMessage = `Error setting script file permission: ${thrownErrorMessage}`; const expectedErrorMessage = `Error setting script file permission: ${thrownErrorMessage}`;
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage)); fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
const context = new TestContext() const context = new TestContext()
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -84,7 +84,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
it('returns expected error type when filesystem throws', async () => { it('returns expected error type when filesystem throws', async () => {
// arrange // arrange
const expectedErrorType: CodeRunErrorType = 'FilePermissionChangeError'; const expectedErrorType: CodeRunErrorType = 'FilePermissionChangeError';
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
fileSystem.setFilePermissions = () => Promise.reject(new Error('File system error')); fileSystem.setFilePermissions = () => Promise.reject(new Error('File system error'));
const context = new TestContext() const context = new TestContext()
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -103,7 +103,7 @@ describe('FileSystemExecutablePermissionSetter', () => {
// arrange // arrange
const thrownErrorMessage = 'File system error'; const thrownErrorMessage = 'File system error';
const logger = new LoggerStub(); const logger = new LoggerStub();
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOperationsStub();
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage)); fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
const context = new TestContext() const context = new TestContext()
.withLogger(logger) .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 { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub'; 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 { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
import { NodePathOperationsStub } from './NodePathOperationsStub'; import { NodePathOperationsStub } from './NodePathOperationsStub';

View File

@@ -1,30 +1,25 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { Logger } from '@/application/Common/Log/Logger'; 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 { 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 { expectExists } from '@tests/shared/Assertions/ExpectExists';
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue'; 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('createDirectory', () => {
describe('path construction', () => { describe('path construction', () => {
it('bases path on user directory', async () => { it('bases path on user directory', async () => {
// arrange // arrange
const expectedBaseDirectory = 'base-directory'; const expectedBaseDirectory = 'base-directory';
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/'; const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
const locationOps = new LocationOpsStub() const fileSystemStub = new FileSystemOperationsStub()
.withUserDirectoryResult(expectedBaseDirectory)
.withDefaultSeparator(pathSegmentSeparator); .withDefaultSeparator(pathSegmentSeparator);
const context = new PersistentDirectoryProviderTestSetup() const context = new PersistentDirectoryProviderTestSetup()
.withSystem(new SystemOperationsStub() .withFileSystem(fileSystemStub);
.withOperatingSystem(new OperatingSystemOpsStub()
.withUserDirectoryResult(expectedBaseDirectory))
.withLocation(locationOps));
// act // act
const { success, directoryAbsolutePath } = await context.provideScriptDirectory(); const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
@@ -33,19 +28,32 @@ describe('PersistentDirectoryProvider', () => {
expectTrue(success); expectTrue(success);
const actualBaseDirectory = directoryAbsolutePath.split(pathSegmentSeparator)[0]; const actualBaseDirectory = directoryAbsolutePath.split(pathSegmentSeparator)[0];
expect(actualBaseDirectory).to.equal(expectedBaseDirectory); 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); expect(calls.length).to.equal(1);
const [combinedBaseDirectory] = calls[0].args; const [combinedBaseDirectory] = calls[0].args;
expect(combinedBaseDirectory).to.equal(expectedBaseDirectory); expect(combinedBaseDirectory).to.equal(expectedBaseDirectory);
}); });
it('includes execution subdirectory in path', async () => { 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 // arrange
const expectedSubdirectory = ExecutionSubdirectory;
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/'; const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
const locationOps = new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator); const fileSystemStub = new FileSystemOperationsStub()
.withDefaultSeparator(pathSegmentSeparator);
const context = new PersistentDirectoryProviderTestSetup() const context = new PersistentDirectoryProviderTestSetup()
.withSystem(new SystemOperationsStub() .withFileSystem(fileSystemStub)
.withLocation(locationOps)); .withDirectoryType(givenDirectoryType);
// act // act
const { success, directoryAbsolutePath } = await context.provideScriptDirectory(); const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
@@ -55,29 +63,31 @@ describe('PersistentDirectoryProvider', () => {
const actualSubdirectory = directoryAbsolutePath const actualSubdirectory = directoryAbsolutePath
.split(pathSegmentSeparator) .split(pathSegmentSeparator)
.pop(); .pop();
expect(actualSubdirectory).to.equal(expectedSubdirectory); expect(actualSubdirectory).to.equal(expectedSubdirectoryName);
const calls = locationOps.callHistory.filter((call) => call.methodName === 'combinePaths'); const calls = fileSystemStub.callHistory.filter((call) => call.methodName === 'combinePaths');
expect(calls.length).to.equal(1); expect(calls.length).to.equal(1);
const [,combinedSubdirectory] = calls[0].args; const [,combinedSubdirectory] = calls[0].args;
expect(combinedSubdirectory).to.equal(expectedSubdirectory); expect(combinedSubdirectory).to.equal(expectedSubdirectoryName);
});
});
}); });
it('forms full path correctly', async () => { it('forms full path correctly', async () => {
// arrange // arrange
const directoryType: DirectoryType = 'script-runs';
const pathSegmentSeparator = '/'; const pathSegmentSeparator = '/';
const baseDirectory = 'base-directory'; 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() const context = new PersistentDirectoryProviderTestSetup()
.withSystem(new SystemOperationsStub() .withFileSystem(fileSystemStub)
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)) .withDirectoryType(directoryType);
.withOperatingSystem(
new OperatingSystemOpsStub().withUserDirectoryResult(baseDirectory),
));
// act // act
const { success, directoryAbsolutePath } = await context.provideScriptDirectory(); const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
expect(success).to.equal(true);
// assert
expectTrue(success);
expect(directoryAbsolutePath).to.equal(expectedDirectory); expect(directoryAbsolutePath).to.equal(expectedDirectory);
}); });
}); });
@@ -85,16 +95,16 @@ describe('PersistentDirectoryProvider', () => {
it('creates directory with recursion', async () => { it('creates directory with recursion', async () => {
// arrange // arrange
const expectedIsRecursive = true; const expectedIsRecursive = true;
const filesystem = new FileSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new PersistentDirectoryProviderTestSetup() const context = new PersistentDirectoryProviderTestSetup()
.withSystem(new SystemOperationsStub().withFileSystem(filesystem)); .withFileSystem(fileSystemStub);
// act // act
const { success, directoryAbsolutePath } = await context.provideScriptDirectory(); const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
// assert // assert
expectTrue(success); 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); expect(calls.length).to.equal(1);
const [actualPath, actualIsRecursive] = calls[0].args; const [actualPath, actualIsRecursive] = calls[0].args;
expect(actualPath).to.equal(directoryAbsolutePath); expect(actualPath).to.equal(directoryAbsolutePath);
@@ -104,7 +114,7 @@ describe('PersistentDirectoryProvider', () => {
describe('error handling', () => { describe('error handling', () => {
const testScenarios: ReadonlyArray<{ const testScenarios: ReadonlyArray<{
readonly description: string; readonly description: string;
readonly expectedErrorType: CodeRunErrorType; readonly expectedErrorType: DirectoryCreationErrorType;
readonly expectedErrorMessage: string; readonly expectedErrorMessage: string;
buildFaultyContext( buildFaultyContext(
setup: PersistentDirectoryProviderTestSetup, setup: PersistentDirectoryProviderTestSetup,
@@ -113,40 +123,38 @@ describe('PersistentDirectoryProvider', () => {
}> = [ }> = [
{ {
description: 'path combination failure', description: 'path combination failure',
expectedErrorType: 'DirectoryCreationError', expectedErrorType: 'PathConstructionError',
expectedErrorMessage: 'Error when combining paths', expectedErrorMessage: 'Error when combining paths',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const locationStub = new LocationOpsStub(); const fileSystemStub = new FileSystemOperationsStub();
locationStub.combinePaths = () => { fileSystemStub.combinePaths = () => {
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub)); return setup.withFileSystem(fileSystemStub);
}, },
}, },
{ {
description: 'user data retrieval failure', description: 'user data retrieval failure',
expectedErrorType: 'DirectoryCreationError', expectedErrorType: 'UserDataFolderRetrievalError',
expectedErrorMessage: 'Error when locating user data directory', expectedErrorMessage: 'Error when locating user data directory',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const operatingSystemStub = new OperatingSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub();
operatingSystemStub.getUserDataDirectory = () => { fileSystemStub.getUserDataDirectory = () => {
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
return setup.withSystem( return setup.withFileSystem(fileSystemStub);
new SystemOperationsStub().withOperatingSystem(operatingSystemStub),
);
}, },
}, },
{ {
description: 'directory creation failure', description: 'directory creation failure',
expectedErrorType: 'DirectoryCreationError', expectedErrorType: 'DirectoryWriteError',
expectedErrorMessage: 'Error when creating directory', expectedErrorMessage: 'Error when creating directory',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOpsStub(); const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.createDirectory = () => { fileSystemStub.createDirectory = () => {
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub)); return setup.withFileSystem(fileSystemStub);
}, },
}, },
]; ];
@@ -190,12 +198,14 @@ describe('PersistentDirectoryProvider', () => {
}); });
class PersistentDirectoryProviderTestSetup { class PersistentDirectoryProviderTestSetup {
private system: SystemOperations = new SystemOperationsStub(); private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
private logger: Logger = new LoggerStub(); private logger: Logger = new LoggerStub();
public withSystem(system: SystemOperations): this { private directoryType: DirectoryType = 'script-runs';
this.system = system;
public withFileSystem(fileSystem: FileSystemOperations): this {
this.fileSystem = fileSystem;
return this; return this;
} }
@@ -204,8 +214,13 @@ class PersistentDirectoryProviderTestSetup {
return this; return this;
} }
public provideScriptDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> { public withDirectoryType(directoryType: DirectoryType): this {
const provider = new PersistentDirectoryProvider(this.system, this.logger); this.directoryType = directoryType;
return provider.provideScriptDirectory(); 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 { describe, it, expect } from 'vitest';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import type { FunctionKeys } from '@/TypeHelpers'; import type { FunctionKeys } from '@/TypeHelpers';
import { sequenceEqual } from '@/application/Common/Array'; 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 { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { type FileReadWriteOperations, NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { FileReadWriteOperationsStub } from './FileReadWriteOperationsStub'; import { FileSystemOperationsStub } from '@tests/unit/shared/Stubs/FileSystemOperationsStub';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
describe('NodeReadbackFileWriter', () => { describe('NodeReadbackFileWriter', () => {
describe('writeAndVerifyFile', () => { describe('writeAndVerifyFile', () => {
@@ -26,7 +26,7 @@ describe('NodeReadbackFileWriter', () => {
it('writes to specified path', async () => { it('writes to specified path', async () => {
// arrange // arrange
const expectedFilePath = 'test.txt'; const expectedFilePath = 'test.txt';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFilePath(expectedFilePath) .withFilePath(expectedFilePath)
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -43,7 +43,7 @@ describe('NodeReadbackFileWriter', () => {
it('writes specified contents', async () => { it('writes specified contents', async () => {
// arrange // arrange
const expectedFileContents = 'expected file contents'; const expectedFileContents = 'expected file contents';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub) .withFileSystem(fileSystemStub)
.withFileContents(expectedFileContents); .withFileContents(expectedFileContents);
@@ -60,7 +60,7 @@ describe('NodeReadbackFileWriter', () => {
it('uses correct encoding', async () => { it('uses correct encoding', async () => {
// arrange // arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8'; const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -78,7 +78,7 @@ describe('NodeReadbackFileWriter', () => {
it('checks correct path', async () => { it('checks correct path', async () => {
// arrange // arrange
const expectedFilePath = 'test-file-path'; const expectedFilePath = 'test-file-path';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub) .withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath); .withFilePath(expectedFilePath);
@@ -87,33 +87,17 @@ describe('NodeReadbackFileWriter', () => {
await context.writeAndVerifyFile(); await context.writeAndVerifyFile();
// assert // 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); expect(accessCalls).to.have.lengthOf(1);
const [actualFilePath] = accessCalls[0].args; const [actualFilePath] = accessCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath); 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', () => { describe('content verification', () => {
it('reads from correct path', async () => { it('reads from correct path', async () => {
// arrange // arrange
const expectedFilePath = 'expected-file-path.txt'; const expectedFilePath = 'expected-file-path.txt';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub) .withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath); .withFilePath(expectedFilePath);
@@ -130,7 +114,7 @@ describe('NodeReadbackFileWriter', () => {
it('uses correct encoding', async () => { it('uses correct encoding', async () => {
// arrange // arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8'; const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -146,12 +130,12 @@ describe('NodeReadbackFileWriter', () => {
}); });
it('executes file system operations in correct sequence', async () => { it('executes file system operations in correct sequence', async () => {
// arrange // arrange
const expectedOrder: ReadonlyArray<FunctionKeys<FileReadWriteOperations>> = [ const expectedOrder: ReadonlyArray<FunctionKeys<FileSystemOperations>> = [
'writeFile', 'writeFile',
'access', 'isFileAvailable',
'readFile', 'readFile',
]; ];
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
const context = new NodeReadbackFileWriterTestSetup() const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -178,19 +162,30 @@ describe('NodeReadbackFileWriter', () => {
expectedErrorType: 'WriteOperationFailed', expectedErrorType: 'WriteOperationFailed',
expectedErrorMessage: 'Error when writing file', expectedErrorMessage: 'Error when writing file',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.writeFile = () => Promise.reject(errorMessage); fileSystemStub.writeFile = () => Promise.reject(errorMessage);
return setup return setup
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
}, },
}, },
{ {
description: 'existence verification error', description: 'existence verification throws error',
expectedErrorType: 'FileExistenceVerificationFailed', expectedErrorType: 'FileExistenceVerificationFailed',
expectedErrorMessage: 'Access denied', expectedErrorMessage: 'Access denied',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.access = () => Promise.reject(errorMessage); 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 return setup
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
}, },
@@ -200,7 +195,7 @@ describe('NodeReadbackFileWriter', () => {
expectedErrorType: 'ReadVerificationFailed', expectedErrorType: 'ReadVerificationFailed',
expectedErrorMessage: 'Read error', expectedErrorMessage: 'Read error',
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.readFile = () => Promise.reject(errorMessage); fileSystemStub.readFile = () => Promise.reject(errorMessage);
return setup return setup
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -211,7 +206,7 @@ describe('NodeReadbackFileWriter', () => {
expectedErrorType: 'ContentVerificationFailed', expectedErrorType: 'ContentVerificationFailed',
expectedErrorMessage: 'The contents of the written file do not match the expected contents.', expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
buildFaultyContext: (setup) => { buildFaultyContext: (setup) => {
const fileSystemStub = new FileReadWriteOperationsStub(); const fileSystemStub = new FileSystemOperationsStub();
fileSystemStub.readFile = () => Promise.resolve('different contents'); fileSystemStub.readFile = () => Promise.resolve('different contents');
return setup return setup
.withFileSystem(fileSystemStub); .withFileSystem(fileSystemStub);
@@ -260,7 +255,7 @@ describe('NodeReadbackFileWriter', () => {
class NodeReadbackFileWriterTestSetup { class NodeReadbackFileWriterTestSetup {
private logger: Logger = new LoggerStub(); private logger: Logger = new LoggerStub();
private fileSystem: FileReadWriteOperations = new FileReadWriteOperationsStub(); private fileSystem: FileSystemOperations = new FileSystemOperationsStub();
private filePath = '/test/file/path.txt'; private filePath = '/test/file/path.txt';
@@ -271,7 +266,7 @@ class NodeReadbackFileWriterTestSetup {
return this; return this;
} }
public withFileSystem(fileSystem: FileReadWriteOperations): this { public withFileSystem(fileSystem: FileSystemOperations): this {
this.fileSystem = fileSystem; this.fileSystem = fileSystem;
return this; 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 { describe, it, expect } from 'vitest';
import type { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { ScriptEnvironmentDiagnosticsCollector } from '@/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector'; import { ScriptEnvironmentDiagnosticsCollector } from '@/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; 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 { OperatingSystem } from '@/domain/OperatingSystem';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
describe('ScriptEnvironmentDiagnosticsCollector', () => { describe('ScriptEnvironmentDiagnosticsCollector', () => {
it('collects operating system path correctly', async () => { it('collects operating system path correctly', async () => {
@@ -26,8 +26,8 @@ describe('ScriptEnvironmentDiagnosticsCollector', () => {
it('collects path correctly', async () => { it('collects path correctly', async () => {
// arrange // arrange
const expectedScriptsPath = '/expected/scripts/path'; const expectedScriptsPath = '/expected/scripts/path';
const directoryProvider = new ScriptDirectoryProviderStub() const directoryProvider = new ApplicationDirectoryProviderStub()
.withDirectoryPath(expectedScriptsPath); .withDirectoryPath('script-runs', expectedScriptsPath);
const collector = new CollectorBuilder() const collector = new CollectorBuilder()
.withScriptDirectoryProvider(directoryProvider) .withScriptDirectoryProvider(directoryProvider)
.build(); .build();
@@ -42,7 +42,7 @@ describe('ScriptEnvironmentDiagnosticsCollector', () => {
}); });
class CollectorBuilder { class CollectorBuilder {
private directoryProvider: ScriptDirectoryProvider = new ScriptDirectoryProviderStub(); private directoryProvider: ApplicationDirectoryProvider = new ApplicationDirectoryProviderStub();
private environment: RuntimeEnvironment = new RuntimeEnvironmentStub(); private environment: RuntimeEnvironment = new RuntimeEnvironmentStub();
@@ -51,7 +51,7 @@ class CollectorBuilder {
return this; return this;
} }
public withScriptDirectoryProvider(directoryProvider: ScriptDirectoryProvider): this { public withScriptDirectoryProvider(directoryProvider: ApplicationDirectoryProvider): this {
this.directoryProvider = directoryProvider; this.directoryProvider = directoryProvider;
return this; 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; error = err;
} }
if (!error) { 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; 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'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ReadbackFileWriterStub 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 { import type {
CommandOps, CommandOps,
FileSystemOps,
OperatingSystemOps,
LocationOps,
SystemOperations, SystemOperations,
} from '@/infrastructure/CodeRunner/System/SystemOperations'; } from '@/infrastructure/CodeRunner/System/SystemOperations';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { CommandOpsStub } from './CommandOpsStub'; import { CommandOpsStub } from './CommandOpsStub';
import { FileSystemOpsStub } from './FileSystemOpsStub'; import { FileSystemOperationsStub } from './FileSystemOperationsStub';
import { LocationOpsStub } from './LocationOpsStub';
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
export class SystemOperationsStub implements SystemOperations { export class SystemOperationsStub implements SystemOperations {
public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub(); public fileSystem: FileSystemOperations = new FileSystemOperationsStub();
public location: LocationOps = new LocationOpsStub();
public fileSystem: FileSystemOps = new FileSystemOpsStub();
public command: CommandOps = new CommandOpsStub(); public command: CommandOps = new CommandOpsStub();
public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this { public withFileSystem(fileSystem: FileSystemOperations): this {
this.operatingSystem = operatingSystemOps;
return this;
}
public withLocation(location: LocationOps): this {
this.location = location;
return this;
}
public withFileSystem(fileSystem: FileSystemOps): this {
this.fileSystem = fileSystem; this.fileSystem = fileSystem;
return this; return this;
} }