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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal file
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
41
tests/unit/shared/Stubs/ApplicationDirectoryProviderStub.ts
Normal file
41
tests/unit/shared/Stubs/ApplicationDirectoryProviderStub.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/unit/shared/Stubs/FileSystemAccessorWithRetryStub.ts
Normal file
21
tests/unit/shared/Stubs/FileSystemAccessorWithRetryStub.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
161
tests/unit/shared/Stubs/FileSystemOperationsStub.ts
Normal file
161
tests/unit/shared/Stubs/FileSystemOperationsStub.ts
Normal 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('|');
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('|');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user