Show save/execution error dialogs on desktop #264
This commit introduces system-native error dialogs on desktop application for code save or execution failures, addressing user confusion described in issue #264. This commit adds informative feedback when script execution or saving fails. Changes: - Implement support for system-native error dialogs. - Refactor `CodeRunner` and `Dialog` interfaces and their implementations to improve error handling and provide better type safety. - Introduce structured error handling, allowing UI to display detailed error messages. - Replace error throwing with an error object interface for controlled handling. This ensures that errors are propagated to the renderer process without being limited by Electron's error object serialization limitations as detailed in electron/electron#24427. - Add logging for dialog actions to aid in troubleshooting. - Rename `fileName` to `defaultFilename` in `saveFile` functions to clarify its purpose. - Centralize message assertion in `LoggerStub` for consistency. - Introduce `expectTrue` in tests for clearer boolean assertions. - Standardize `filename` usage across the codebase. - Enhance existing test names and organization for clarity. - Update related documentation.
This commit is contained in:
@@ -137,7 +137,7 @@ For a detailed comparison of features between the desktop and web versions of pr
|
|||||||
- **Transparent**. Have full visibility into what the tweaks do as you enable them.
|
- **Transparent**. Have full visibility into what the tweaks do as you enable them.
|
||||||
- **Reversible**. Revert if something feels wrong.
|
- **Reversible**. Revert if something feels wrong.
|
||||||
- **Accessible**. No need to run any compiled software on your computer with web version.
|
- **Accessible**. No need to run any compiled software on your computer with web version.
|
||||||
- **Secure**: Security is a top priority at privacy.sexy with comprehensive safeguards in place. [Learn more](./SECURITY.md).
|
- **Secure**: Security is a top priority at privacy.sexy with [comprehensive safeguards](./SECURITY.md#application-security) in place.
|
||||||
- **Open**. What you see as code in this repository is what you get. The application itself, its infrastructure and deployments are open-source and automated thanks to [bump-everywhere](https://github.com/undergroundwires/bump-everywhere).
|
- **Open**. What you see as code in this repository is what you get. The application itself, its infrastructure and deployments are open-source and automated thanks to [bump-everywhere](https://github.com/undergroundwires/bump-everywhere).
|
||||||
- **Tested**. A lot of tests. Automated and manual. Community-testing and verification. Stability improvements comes before new features.
|
- **Tested**. A lot of tests. Automated and manual. Community-testing and verification. Stability improvements comes before new features.
|
||||||
- **Extensible**. Effortlessly [extend scripts](./CONTRIBUTING.md#extend-scripts) with a custom designed [templating language](./docs/templating.md).
|
- **Extensible**. Effortlessly [extend scripts](./CONTRIBUTING.md#extend-scripts) with a custom designed [templating language](./docs/templating.md).
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ This table highlights differences between the desktop and web versions of `priva
|
|||||||
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
|
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
|
||||||
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
|
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
|
||||||
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
|
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
|
||||||
|
| [Error handling](#error-handling) | 🟢 Advanced | 🟡 Limited |
|
||||||
|
| [Native dialogs](#error-handling) | 🟢 Available | 🔴 Not available |
|
||||||
|
|
||||||
## Feature descriptions
|
## Feature descriptions
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ Log file locations vary by operating system:
|
|||||||
The desktop version of privacy.sexy enables direct script execution, providing a seamless and integrated experience.
|
The desktop version of privacy.sexy enables direct script execution, providing a seamless and integrated experience.
|
||||||
This direct execution capability isn't available in the web version due to inherent browser restrictions.
|
This direct execution capability isn't available in the web version due to inherent browser restrictions.
|
||||||
|
|
||||||
**Logging and storage:**
|
**Script execution history:**
|
||||||
|
|
||||||
For enhanced auditability and easier troubleshooting, the desktop version keeps a record of executed scripts in designated directories.
|
For enhanced auditability and easier troubleshooting, the desktop version keeps a record of executed scripts in designated directories.
|
||||||
These locations vary based on the operating system:
|
These locations vary based on the operating system:
|
||||||
@@ -62,7 +64,13 @@ These locations vary based on the operating system:
|
|||||||
- Linux: `$HOME/.config/privacy.sexy/runs`
|
- Linux: `$HOME/.config/privacy.sexy/runs`
|
||||||
- Windows: `%APPDATA%\privacy.sexy\runs`
|
- Windows: `%APPDATA%\privacy.sexy\runs`
|
||||||
|
|
||||||
**Native file system dialogs:**
|
### Error handling
|
||||||
|
|
||||||
The desktop version uses native system file save dialogs, offering more features and reliability compared to the browser's file system dialogs.
|
The desktop version of privacy.sexy features advanced error handling capabilities.
|
||||||
|
It employs robust and reliable execution strategies, including self-healing mechanisms, and provides guidance and troubleshooting information to resolve issues effectively.
|
||||||
|
In contrast, the web version has more basic error handling due to browser limitations and the nature of web applications.
|
||||||
|
|
||||||
|
### Native dialogs
|
||||||
|
|
||||||
|
The desktop version uses native dialogs, offering more features and reliability compared to the browser's file system dialogs.
|
||||||
These native dialogs provide a more integrated and user-friendly experience, aligning with the operating system's standard interface and functionalities.
|
These native dialogs provide a more integrated and user-friendly experience, aligning with the operating system's standard interface and functionalities.
|
||||||
|
|||||||
@@ -2,5 +2,35 @@ export interface CodeRunner {
|
|||||||
runCode(
|
runCode(
|
||||||
code: string,
|
code: string,
|
||||||
fileExtension: string,
|
fileExtension: string,
|
||||||
): Promise<void>;
|
): Promise<CodeRunOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CodeRunErrorType =
|
||||||
|
| 'FileWriteError'
|
||||||
|
| 'FilePathGenerationError'
|
||||||
|
| 'UnsupportedOperatingSystem'
|
||||||
|
| 'FileExecutionError'
|
||||||
|
| 'DirectoryCreationError'
|
||||||
|
| 'UnexpectedError';
|
||||||
|
|
||||||
|
export type CodeRunOutcome = SuccessfulCodeRun | FailedCodeRun;
|
||||||
|
|
||||||
|
interface CodeRunStatus {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly error?: CodeRunError;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulCodeRun extends CodeRunStatus {
|
||||||
|
readonly success: true;
|
||||||
|
readonly error?: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedCodeRun extends CodeRunStatus {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeRunError {
|
||||||
|
readonly type: CodeRunErrorType;
|
||||||
|
readonly message: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const ScriptFileName = 'privacy-script' as const;
|
|
||||||
1
src/application/CodeRunner/ScriptFilename.ts
Normal file
1
src/application/CodeRunner/ScriptFilename.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const ScriptFilename = 'privacy-script' as const;
|
||||||
@@ -1,40 +1,116 @@
|
|||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||||
|
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
import { SystemOperations } from '../../System/SystemOperations';
|
import { SystemOperations } from '../../System/SystemOperations';
|
||||||
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
|
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
|
||||||
import { ScriptDirectoryProvider } from './ScriptDirectoryProvider';
|
import { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider';
|
||||||
|
|
||||||
export const ExecutionSubdirectory = 'runs';
|
export const ExecutionSubdirectory = 'runs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a dedicated directory for script execution.
|
||||||
|
* Benefits of using a persistent directory:
|
||||||
|
* - Antivirus Exclusions: Easier antivirus configuration.
|
||||||
|
* - Auditability: Stores script execution history for troubleshooting.
|
||||||
|
* - Reliability: Avoids issues with directory clean-ups during execution,
|
||||||
|
* seen in Windows Pro Azure VMs when stored on Windows temporary directory.
|
||||||
|
*/
|
||||||
export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||||
private readonly logger: Logger = ElectronLogger,
|
private readonly logger: Logger = ElectronLogger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async provideScriptDirectory(): Promise<string> {
|
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||||
const scriptsDirectory = this.system.location.combinePaths(
|
const {
|
||||||
/*
|
success: isPathConstructed,
|
||||||
Switched from temporary to persistent directory for script storage for improved reliability.
|
error: pathConstructionError,
|
||||||
|
directoryPath,
|
||||||
Temporary directories in some environments, such certain Windows Pro Azure VMs, showed
|
} = this.constructScriptDirectoryPath();
|
||||||
issues where scripts were interrupted due to directory cleanup during script execution.
|
if (!isPathConstructed) {
|
||||||
This was observed with system temp directories (e.g., `%LOCALAPPDATA%\Temp`).
|
return {
|
||||||
|
success: false,
|
||||||
Persistent directories offer better stability during long executions and aid in auditability
|
error: pathConstructionError,
|
||||||
and troubleshooting.
|
};
|
||||||
*/
|
|
||||||
this.system.operatingSystem.getUserDataDirectory(),
|
|
||||||
ExecutionSubdirectory,
|
|
||||||
);
|
|
||||||
this.logger.info(`Attempting to create script directory at path: ${scriptsDirectory}`);
|
|
||||||
try {
|
|
||||||
await this.system.fileSystem.createDirectory(scriptsDirectory, true);
|
|
||||||
this.logger.info(`Script directory successfully created at: ${scriptsDirectory}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Error creating script directory at ${scriptsDirectory}: ${error.message}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return scriptsDirectory;
|
const {
|
||||||
|
success: isDirectoryCreated,
|
||||||
|
error: directoryCreationError,
|
||||||
|
} = await this.createDirectory(directoryPath);
|
||||||
|
if (!isDirectoryCreated) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: directoryCreationError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
directoryAbsolutePath: directoryPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
|
||||||
|
try {
|
||||||
|
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
|
||||||
|
await this.system.fileSystem.createDirectory(directoryPath, true);
|
||||||
|
this.logger.info(`Script directory successfully created at: ${directoryPath}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'DirectoryCreationError'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome {
|
||||||
|
try {
|
||||||
|
const parentDirectory = this.system.operatingSystem.getUserDataDirectory();
|
||||||
|
const scriptDirectory = this.system.location.combinePaths(
|
||||||
|
parentDirectory,
|
||||||
|
ExecutionSubdirectory,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
directoryPath: scriptDirectory,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'DirectoryCreationError'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleException(
|
||||||
|
exception: Error,
|
||||||
|
errorType: CodeRunErrorType,
|
||||||
|
): CodeRunError {
|
||||||
|
const errorMessage = 'Error during script directory creation';
|
||||||
|
this.logger.error(errorType, errorMessage, exception);
|
||||||
|
return {
|
||||||
|
type: errorType,
|
||||||
|
message: `${errorMessage}: ${exception.message}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DirectoryPathConstructionOutcome = {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
readonly directoryPath?: undefined;
|
||||||
|
} | {
|
||||||
|
readonly success: true;
|
||||||
|
readonly directoryPath: string;
|
||||||
|
readonly error?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DirectoryPathCreationOutcome = {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
} | {
|
||||||
|
readonly success: true;
|
||||||
|
readonly error?: undefined;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
export interface ScriptDirectoryProvider {
|
export interface ScriptDirectoryProvider {
|
||||||
provideScriptDirectory(): Promise<string>;
|
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,5 +1,5 @@
|
|||||||
import { ScriptFileNameParts } from '../ScriptFileCreator';
|
import { ScriptFilenameParts } from '../ScriptFileCreator';
|
||||||
|
|
||||||
export interface FilenameGenerator {
|
export interface FilenameGenerator {
|
||||||
generateFilename(scriptFileNameParts: ScriptFileNameParts): string;
|
generateFilename(scriptFilenameParts: ScriptFilenameParts): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ScriptFileNameParts } from '../ScriptFileCreator';
|
import { ScriptFilenameParts } from '../ScriptFileCreator';
|
||||||
import { FilenameGenerator } from './FilenameGenerator';
|
import { FilenameGenerator } from './FilenameGenerator';
|
||||||
|
|
||||||
export class TimestampedFilenameGenerator implements FilenameGenerator {
|
export class TimestampedFilenameGenerator implements FilenameGenerator {
|
||||||
public generateFilename(
|
public generateFilename(
|
||||||
scriptFileNameParts: ScriptFileNameParts,
|
scriptFilenameParts: ScriptFilenameParts,
|
||||||
date = new Date(),
|
date = new Date(),
|
||||||
): string {
|
): string {
|
||||||
validateScriptFileNameParts(scriptFileNameParts);
|
validateScriptFilenameParts(scriptFilenameParts);
|
||||||
const baseFileName = `${createTimeStampForFile(date)}-${scriptFileNameParts.scriptName}`;
|
const baseFilename = `${createTimeStampForFile(date)}-${scriptFilenameParts.scriptName}`;
|
||||||
return scriptFileNameParts.scriptFileExtension ? `${baseFileName}.${scriptFileNameParts.scriptFileExtension}` : baseFileName;
|
return scriptFilenameParts.scriptFileExtension ? `${baseFilename}.${scriptFilenameParts.scriptFileExtension}` : baseFilename;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ function createTimeStampForFile(date: Date): string {
|
|||||||
.replace(/\..+/, '');
|
.replace(/\..+/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateScriptFileNameParts(scriptFileNameParts: ScriptFileNameParts) {
|
function validateScriptFilenameParts(scriptFilenameParts: ScriptFilenameParts) {
|
||||||
if (!scriptFileNameParts.scriptName) {
|
if (!scriptFilenameParts.scriptName) {
|
||||||
throw new Error('Script name is required but not provided.');
|
throw new Error('Script name is required but not provided.');
|
||||||
}
|
}
|
||||||
if (scriptFileNameParts.scriptFileExtension?.startsWith('.')) {
|
if (scriptFilenameParts.scriptFileExtension?.startsWith('.')) {
|
||||||
throw new Error('File extension should not start with a dot.');
|
throw new Error('File extension should not start with a dot.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
import { SystemOperations } from '../System/SystemOperations';
|
import { SystemOperations } from '../System/SystemOperations';
|
||||||
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
|
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
|
||||||
import { FilenameGenerator } from './Filename/FilenameGenerator';
|
import { FilenameGenerator } from './Filename/FilenameGenerator';
|
||||||
import { ScriptFileNameParts, ScriptFileCreator } from './ScriptFileCreator';
|
import { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
|
||||||
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
|
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
|
||||||
import { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
|
import { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
|
||||||
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
|
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
|
||||||
@@ -18,23 +19,99 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
|
|||||||
|
|
||||||
public async createScriptFile(
|
public async createScriptFile(
|
||||||
contents: string,
|
contents: string,
|
||||||
scriptFileNameParts: ScriptFileNameParts,
|
scriptFilenameParts: ScriptFilenameParts,
|
||||||
): Promise<string> {
|
): Promise<ScriptFileCreationOutcome> {
|
||||||
const filePath = await this.provideFilePath(scriptFileNameParts);
|
const {
|
||||||
await this.createFile(filePath, contents);
|
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
|
||||||
return filePath;
|
} = await this.directoryProvider.provideScriptDirectory();
|
||||||
|
if (!isDirectoryCreated) {
|
||||||
|
return createFailure(directoryCreationError);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
success: isFilePathConstructed, error: filePathGenerationError, filePath,
|
||||||
|
} = this.constructFilePath(scriptFilenameParts, directoryAbsolutePath);
|
||||||
|
if (!isFilePathConstructed) {
|
||||||
|
return createFailure(filePathGenerationError);
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
success: isFileCreated, error: fileCreationError,
|
||||||
|
} = await this.writeFile(filePath, contents);
|
||||||
|
if (!isFileCreated) {
|
||||||
|
return createFailure(fileCreationError);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
scriptFileAbsolutePath: filePath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async provideFilePath(scriptFileNameParts: ScriptFileNameParts): Promise<string> {
|
private constructFilePath(
|
||||||
const filename = this.filenameGenerator.generateFilename(scriptFileNameParts);
|
scriptFilenameParts: ScriptFilenameParts,
|
||||||
const directoryPath = await this.directoryProvider.provideScriptDirectory();
|
directoryPath: string,
|
||||||
const filePath = this.system.location.combinePaths(directoryPath, filename);
|
): FilePathConstructionOutcome {
|
||||||
return filePath;
|
try {
|
||||||
|
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
|
||||||
|
const filePath = this.system.location.combinePaths(directoryPath, filename);
|
||||||
|
return { success: true, filePath };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'FilePathGenerationError'),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createFile(filePath: string, contents: string): Promise<void> {
|
private async writeFile(
|
||||||
this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`);
|
filePath: string,
|
||||||
await this.system.fileSystem.writeToFile(filePath, contents);
|
contents: string,
|
||||||
this.logger.info(`File created successfully at ${filePath}`);
|
): Promise<FileWriteOutcome> {
|
||||||
|
try {
|
||||||
|
this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`);
|
||||||
|
await this.system.fileSystem.writeToFile(filePath, contents);
|
||||||
|
this.logger.info(`File created successfully at ${filePath}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'FileWriteError'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleException(
|
||||||
|
exception: Error,
|
||||||
|
errorType: CodeRunErrorType,
|
||||||
|
): CodeRunError {
|
||||||
|
const errorMessage = 'Error during script file operation';
|
||||||
|
this.logger.error(errorType, errorMessage, exception);
|
||||||
|
return {
|
||||||
|
type: errorType,
|
||||||
|
message: `${errorMessage}: ${exception.message}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFailure(error: CodeRunError): ScriptFileCreationOutcome {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileWriteOutcome = {
|
||||||
|
readonly success: true;
|
||||||
|
readonly error?: undefined;
|
||||||
|
} | {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FilePathConstructionOutcome = {
|
||||||
|
readonly success: true;
|
||||||
|
readonly filePath: string;
|
||||||
|
readonly error?: undefined;
|
||||||
|
} | {
|
||||||
|
readonly success: false;
|
||||||
|
readonly filePath?: undefined;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
|
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
export interface ScriptFileCreator {
|
export interface ScriptFileCreator {
|
||||||
createScriptFile(
|
createScriptFile(
|
||||||
contents: string,
|
contents: string,
|
||||||
scriptFileNameParts: ScriptFileNameParts,
|
scriptFilenameParts: ScriptFilenameParts,
|
||||||
): Promise<string>;
|
): Promise<ScriptFileCreationOutcome>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptFileNameParts {
|
export interface ScriptFilenameParts {
|
||||||
readonly scriptName: string;
|
readonly scriptName: string;
|
||||||
readonly scriptFileExtension: string | undefined;
|
readonly scriptFileExtension: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScriptFileCreationOutcome = SuccessfulScriptCreation | FailedScriptCreation;
|
||||||
|
|
||||||
|
interface ScriptFileCreationStatus {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly error?: CodeRunError;
|
||||||
|
readonly scriptFileAbsolutePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulScriptCreation extends ScriptFileCreationStatus {
|
||||||
|
readonly success: true;
|
||||||
|
readonly scriptFileAbsolutePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FailedScriptCreation extends ScriptFileCreationStatus {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,22 @@
|
|||||||
|
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
export interface ScriptFileExecutor {
|
export interface ScriptFileExecutor {
|
||||||
executeScriptFile(filePath: string): Promise<void>;
|
executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScriptFileExecutionOutcome = SuccessfulScriptFileExecution | FailedScriptFileExecution;
|
||||||
|
|
||||||
|
interface ScriptFileExecutionStatus {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly error?: CodeRunError;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulScriptFileExecution extends ScriptFileExecutionStatus {
|
||||||
|
readonly success: true;
|
||||||
|
readonly error?: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedScriptFileExecution extends ScriptFileExecutionStatus {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: CodeRunError;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
|||||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||||
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
||||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||||
import { ScriptFileExecutor } from './ScriptFileExecutor';
|
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
import { isString } from '@/TypeHelpers';
|
||||||
|
import { FailedScriptFileExecution, ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
|
||||||
|
|
||||||
export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -14,38 +16,77 @@ export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
|||||||
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
public async executeScriptFile(filePath: string): Promise<void> {
|
public async executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||||
const { os } = this.environment;
|
const { os } = this.environment;
|
||||||
if (os === undefined) {
|
if (os === undefined) {
|
||||||
throw new Error('Unknown operating system');
|
return this.handleError('UnsupportedOperatingSystem', 'Operating system could not be identified from environment.');
|
||||||
}
|
}
|
||||||
await this.setFileExecutablePermissions(filePath);
|
const filePermissionsResult = await this.setFileExecutablePermissions(filePath);
|
||||||
await this.runFileWithRunner(filePath, os);
|
if (!filePermissionsResult.success) {
|
||||||
|
return filePermissionsResult;
|
||||||
|
}
|
||||||
|
const scriptExecutionResult = await this.runFileWithRunner(filePath, os);
|
||||||
|
if (!scriptExecutionResult.success) {
|
||||||
|
return scriptExecutionResult;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setFileExecutablePermissions(filePath: string): Promise<void> {
|
private async setFileExecutablePermissions(
|
||||||
|
filePath: string,
|
||||||
|
): Promise<ScriptFileExecutionOutcome> {
|
||||||
/*
|
/*
|
||||||
This is required on macOS and Linux otherwise the terminal emulators will refuse to
|
This is required on macOS and Linux otherwise the terminal emulators will refuse to
|
||||||
execute the script. It's not needed on Windows.
|
execute the script. It's not needed on Windows.
|
||||||
*/
|
*/
|
||||||
this.logger.info(`Setting execution permissions for file at ${filePath}`);
|
try {
|
||||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
this.logger.info(`Setting execution permissions for file at ${filePath}`);
|
||||||
this.logger.info(`Execution permissions set successfully for ${filePath}`);
|
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||||
|
this.logger.info(`Execution permissions set successfully for ${filePath}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError('FileExecutionError', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runFileWithRunner(filePath: string, os: OperatingSystem): Promise<void> {
|
private async runFileWithRunner(
|
||||||
|
filePath: string,
|
||||||
|
os: OperatingSystem,
|
||||||
|
): Promise<ScriptFileExecutionOutcome> {
|
||||||
this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`);
|
this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`);
|
||||||
const runner = TerminalRunners[os];
|
const runner = TerminalRunners[os];
|
||||||
if (!runner) {
|
if (!runner) {
|
||||||
throw new Error(`Unsupported operating system: ${OperatingSystem[os]}`);
|
return this.handleError('UnsupportedOperatingSystem', `Unsupported operating system: ${OperatingSystem[os]}`);
|
||||||
}
|
}
|
||||||
const context: TerminalExecutionContext = {
|
const context: TerminalExecutionContext = {
|
||||||
scriptFilePath: filePath,
|
scriptFilePath: filePath,
|
||||||
commandOps: this.system.command,
|
commandOps: this.system.command,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
};
|
};
|
||||||
await runner(context);
|
try {
|
||||||
this.logger.info('Command script file successfully.');
|
await runner(context);
|
||||||
|
this.logger.info('Command script file successfully.');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return this.handleError('FileExecutionError', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(
|
||||||
|
type: CodeRunErrorType,
|
||||||
|
error: Error | string,
|
||||||
|
): FailedScriptFileExecution {
|
||||||
|
const errorMessage = 'Error during script file execution';
|
||||||
|
this.logger.error([type, errorMessage, ...(error ? [error] : [])]);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
type,
|
||||||
|
message: `${errorMessage}: ${isString(error) ? error : errorMessage}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
|
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
import {
|
||||||
|
CodeRunError, CodeRunOutcome, CodeRunner, FailedCodeRun,
|
||||||
|
} from '@/application/CodeRunner/CodeRunner';
|
||||||
import { ElectronLogger } from '../Log/ElectronLogger';
|
import { ElectronLogger } from '../Log/ElectronLogger';
|
||||||
import { ScriptFileExecutor } from './Execution/ScriptFileExecutor';
|
import { ScriptFileExecutor } from './Execution/ScriptFileExecutor';
|
||||||
import { ScriptFileCreator } from './Creation/ScriptFileCreator';
|
import { ScriptFileCreator } from './Creation/ScriptFileCreator';
|
||||||
@@ -18,18 +20,38 @@ export class ScriptFileCodeRunner implements CodeRunner {
|
|||||||
public async runCode(
|
public async runCode(
|
||||||
code: string,
|
code: string,
|
||||||
fileExtension: string,
|
fileExtension: string,
|
||||||
): Promise<void> {
|
): Promise<CodeRunOutcome> {
|
||||||
this.logger.info('Initiating script running process.');
|
this.logger.info('Initiating script running process.');
|
||||||
try {
|
const {
|
||||||
const scriptFilePath = await this.scriptFileCreator.createScriptFile(code, {
|
success: isFileCreated, scriptFileAbsolutePath, error: fileCreationError,
|
||||||
scriptName: ScriptFileName,
|
} = await this.scriptFileCreator.createScriptFile(code, {
|
||||||
scriptFileExtension: fileExtension,
|
scriptName: ScriptFilename,
|
||||||
});
|
scriptFileExtension: fileExtension,
|
||||||
await this.scriptFileExecutor.executeScriptFile(scriptFilePath);
|
});
|
||||||
this.logger.info(`Successfully ran script at ${scriptFilePath}`);
|
if (!isFileCreated) {
|
||||||
} catch (error) {
|
return createFailure(fileCreationError);
|
||||||
this.logger.error(`Error running script: ${error.message}`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
const {
|
||||||
|
success: isFileSuccessfullyExecuted,
|
||||||
|
error: fileExecutionError,
|
||||||
|
} = await this.scriptFileExecutor.executeScriptFile(
|
||||||
|
scriptFileAbsolutePath,
|
||||||
|
);
|
||||||
|
if (!isFileSuccessfullyExecuted) {
|
||||||
|
return createFailure(fileExecutionError);
|
||||||
|
}
|
||||||
|
this.logger.info(`Successfully ran script at ${scriptFileAbsolutePath}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFailure(
|
||||||
|
error: CodeRunError,
|
||||||
|
): FailedCodeRun {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
import { FileSaverDialog } from './FileSaverDialog';
|
import { FileSaverDialog } from './FileSaverDialog';
|
||||||
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
||||||
|
|
||||||
export class BrowserDialog implements Dialog {
|
export class BrowserDialog implements Dialog {
|
||||||
constructor(private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog()) {
|
constructor(
|
||||||
|
private readonly window: WindowDialogAccessor = globalThis.window,
|
||||||
|
private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog(),
|
||||||
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public showError(title: string, message: string): void {
|
||||||
|
this.window.alert(`❌ ${title}\n\n${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
public saveFile(
|
public saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
): Promise<void> {
|
): Promise<SaveFileOutcome> {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
this.saveFileDialog.saveFile(fileContents, fileName, type),
|
this.saveFileDialog.saveFile(fileContents, defaultFilename, type),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WindowDialogAccessor {
|
||||||
|
readonly alert: typeof window.alert;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
|
|
||||||
export interface BrowserSaveFileDialog {
|
export interface BrowserSaveFileDialog {
|
||||||
saveFile(
|
saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
fileType: FileType,
|
fileType: FileType,
|
||||||
): void;
|
): SaveFileOutcome;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import fileSaver from 'file-saver';
|
import fileSaver from 'file-saver';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
|
||||||
|
|
||||||
export type SaveAsFunction = (data: Blob, filename?: string) => void;
|
export type SaveAsFunction = (data: Blob, filename?: string) => void;
|
||||||
@@ -14,17 +14,20 @@ export class FileSaverDialog implements BrowserSaveFileDialog {
|
|||||||
|
|
||||||
public saveFile(
|
public saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
fileType: FileType,
|
fileType: FileType,
|
||||||
): void {
|
): SaveFileOutcome {
|
||||||
const mimeType = MimeTypes[fileType];
|
const mimeType = MimeTypes[fileType];
|
||||||
this.saveBlob(fileContents, mimeType, fileName);
|
this.saveBlob(fileContents, mimeType, defaultFilename);
|
||||||
|
return {
|
||||||
|
success: true, // Exceptions are handled internally
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveBlob(file: BlobPart, mimeType: string, fileName: string): void {
|
private saveBlob(file: BlobPart, mimeType: string, defaultFilename: string): void {
|
||||||
try {
|
try {
|
||||||
const blob = new Blob([file], { type: mimeType });
|
const blob = new Blob([file], { type: mimeType });
|
||||||
this.fileSaverSaveAs(blob, fileName);
|
this.fileSaverSaveAs(blob, defaultFilename);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.windowOpen(`data:${mimeType},${encodeURIComponent(file.toString())}`, '_blank', '');
|
this.windowOpen(`data:${mimeType},${encodeURIComponent(file.toString())}`, '_blank', '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
import { dialog } from 'electron/main';
|
||||||
|
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
import { NodeElectronSaveFileDialog } from './NodeElectronSaveFileDialog';
|
import { NodeElectronSaveFileDialog } from './NodeElectronSaveFileDialog';
|
||||||
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
||||||
|
|
||||||
export class ElectronDialog implements Dialog {
|
export class ElectronDialog implements Dialog {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fileSaveDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
|
private readonly saveFileDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
|
||||||
|
private readonly electron: ElectronDialogAccessor = {
|
||||||
|
showErrorBox: dialog.showErrorBox.bind(dialog),
|
||||||
|
},
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
public async saveFile(
|
public saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
): Promise<void> {
|
): Promise<SaveFileOutcome> {
|
||||||
await this.fileSaveDialog.saveFile(fileContents, fileName, type);
|
return this.saveFileDialog.saveFile(fileContents, defaultFilename, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public showError(title: string, message: string): void {
|
||||||
|
this.electron.showErrorBox(title, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElectronDialogAccessor {
|
||||||
|
readonly showErrorBox: typeof dialog.showErrorBox;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
|
|
||||||
export interface ElectronSaveFileDialog {
|
export interface ElectronSaveFileDialog {
|
||||||
saveFile(
|
saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
): Promise<void>;
|
): Promise<SaveFileOutcome>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,11 @@ import { writeFile } from 'node:fs/promises';
|
|||||||
import { app, dialog } from 'electron/main';
|
import { app, dialog } from 'electron/main';
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import {
|
||||||
|
FileType, SaveFileError, SaveFileErrorType, SaveFileOutcome,
|
||||||
|
} from '@/presentation/common/Dialog';
|
||||||
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
||||||
|
|
||||||
export interface ElectronFileDialogOperations {
|
|
||||||
getUserDownloadsPath(): string;
|
|
||||||
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeFileOperations {
|
|
||||||
readonly join: typeof join;
|
|
||||||
writeFile(file: string, data: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger = ElectronLogger,
|
private readonly logger: Logger = ElectronLogger,
|
||||||
@@ -31,44 +23,123 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
|||||||
|
|
||||||
public async saveFile(
|
public async saveFile(
|
||||||
fileContents: string,
|
fileContents: string,
|
||||||
fileName: string,
|
defaultFilename: string,
|
||||||
type: FileType,
|
type: FileType,
|
||||||
): Promise<void> {
|
): Promise<SaveFileOutcome> {
|
||||||
const userSelectedFilePath = await this.showSaveFileDialog(fileName, type);
|
const {
|
||||||
if (!userSelectedFilePath) {
|
success: isPathConstructed,
|
||||||
this.logger.info(`File save cancelled by user: ${fileName}`);
|
filePath: defaultFilePath,
|
||||||
return;
|
error: pathConstructionError,
|
||||||
|
} = this.constructDefaultFilePath(defaultFilename);
|
||||||
|
if (!isPathConstructed) {
|
||||||
|
return { success: false, error: pathConstructionError };
|
||||||
}
|
}
|
||||||
await this.writeFile(userSelectedFilePath, fileContents);
|
const fileDialog = await this.showSaveFileDialog(defaultFilename, defaultFilePath, type);
|
||||||
|
if (!fileDialog.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: fileDialog.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (fileDialog.canceled) {
|
||||||
|
this.logger.info(`File save cancelled by user: ${defaultFilename}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await this.writeFile(fileDialog.filePath, fileContents);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeFile(filePath: string, fileContents: string): Promise<void> {
|
private async writeFile(
|
||||||
|
filePath: string,
|
||||||
|
fileContents: string,
|
||||||
|
): Promise<SaveFileOutcome> {
|
||||||
try {
|
try {
|
||||||
this.logger.info(`Saving file: ${filePath}`);
|
this.logger.info(`Saving file: ${filePath}`);
|
||||||
await this.node.writeFile(filePath, fileContents);
|
await this.node.writeFile(filePath, fileContents);
|
||||||
this.logger.info(`File saved: ${filePath}`);
|
this.logger.info(`File saved: ${filePath}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error saving file: ${error.message}`);
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'FileCreationError'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showSaveFileDialog(fileName: string, type: FileType): Promise<string | undefined> {
|
private async showSaveFileDialog(
|
||||||
const downloadsFolder = this.electron.getUserDownloadsPath();
|
defaultFilename: string,
|
||||||
const defaultFilePath = this.node.join(downloadsFolder, fileName);
|
defaultFilePath: string,
|
||||||
const dialogResult = await this.electron.showSaveDialog({
|
type: FileType,
|
||||||
title: fileName,
|
): Promise<SaveDialogOutcome> {
|
||||||
defaultPath: defaultFilePath,
|
try {
|
||||||
filters: getDialogFileFilters(type),
|
const dialogResult = await this.electron.showSaveDialog({
|
||||||
properties: [
|
title: defaultFilename,
|
||||||
'createDirectory', // Enables directory creation on macOS.
|
defaultPath: defaultFilePath,
|
||||||
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
|
filters: getDialogFileFilters(type),
|
||||||
],
|
properties: [
|
||||||
});
|
'createDirectory', // Enables directory creation on macOS.
|
||||||
if (dialogResult.canceled) {
|
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
|
||||||
return undefined;
|
],
|
||||||
|
});
|
||||||
|
if (dialogResult.canceled) {
|
||||||
|
return { success: true, canceled: true };
|
||||||
|
}
|
||||||
|
if (!dialogResult.filePath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: { type: 'DialogDisplayError', message: 'Unexpected Error: File path is undefined after save dialog completion.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, filePath: dialogResult.filePath };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(error, 'DialogDisplayError'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return dialogResult.filePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private constructDefaultFilePath(defaultFilename: string): DefaultFilePathConstructionOutcome {
|
||||||
|
try {
|
||||||
|
const downloadsFolder = this.electron.getUserDownloadsPath();
|
||||||
|
const defaultFilePath = this.node.join(downloadsFolder, defaultFilename);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath: defaultFilePath,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.handleException(err, 'DialogDisplayError'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleException(
|
||||||
|
exception: Error,
|
||||||
|
errorType: SaveFileErrorType,
|
||||||
|
): SaveFileError {
|
||||||
|
const errorMessage = 'Error during saving script file.';
|
||||||
|
this.logger.error(errorType, errorMessage, exception);
|
||||||
|
return {
|
||||||
|
type: errorType,
|
||||||
|
message: `${errorMessage}: ${exception.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectronFileDialogOperations {
|
||||||
|
getUserDownloadsPath(): string;
|
||||||
|
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeFileOperations {
|
||||||
|
readonly join: typeof join;
|
||||||
|
writeFile(file: string, data: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
|
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
|
||||||
@@ -96,3 +167,12 @@ const FileTypeSpecificFilters: Record<FileType, Electron.FileFilter[]> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SaveDialogOutcome =
|
||||||
|
| { readonly success: true; readonly filePath: string; readonly canceled?: false }
|
||||||
|
| { readonly success: true; readonly canceled: true }
|
||||||
|
| { readonly success: false; readonly error: SaveFileError; readonly canceled?: false };
|
||||||
|
|
||||||
|
type DefaultFilePathConstructionOutcome =
|
||||||
|
| { readonly success: true; readonly filePath: string; readonly error?: undefined; }
|
||||||
|
| { readonly success: false; readonly filePath?: undefined; readonly error: SaveFileError; };
|
||||||
|
|||||||
36
src/infrastructure/Dialog/LoggingDialogDecorator.ts
Normal file
36
src/infrastructure/Dialog/LoggingDialogDecorator.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||||
|
|
||||||
|
export function decorateWithLogging(
|
||||||
|
dialog: Dialog,
|
||||||
|
logger: Logger,
|
||||||
|
): Dialog {
|
||||||
|
return new LoggingDialogDecorator(dialog, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggingDialogDecorator implements Dialog {
|
||||||
|
constructor(
|
||||||
|
private readonly dialog: Dialog,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public async saveFile(
|
||||||
|
fileContents: string,
|
||||||
|
defaultFilename: string,
|
||||||
|
fileType: FileType,
|
||||||
|
) {
|
||||||
|
this.logger.info(`Opening save file dialog with default filename: ${defaultFilename}.`);
|
||||||
|
const dialogResult = await this.dialog.saveFile(fileContents, defaultFilename, fileType);
|
||||||
|
if (dialogResult.success) {
|
||||||
|
this.logger.info('File saving process completed successfully.');
|
||||||
|
} else {
|
||||||
|
this.logger.error('Error encountered while saving the file.', dialogResult.error);
|
||||||
|
}
|
||||||
|
return dialogResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public showError(title: string, message: string) {
|
||||||
|
this.logger.error(`Showing error dialog: ${title} - ${message}`);
|
||||||
|
this.dialog.showError(title, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,35 @@
|
|||||||
export interface Dialog {
|
export interface Dialog {
|
||||||
saveFile(fileContents: string, fileName: string, type: FileType): Promise<void>;
|
showError(title: string, message: string): void;
|
||||||
|
saveFile(fileContents: string, defaultFilename: string, type: FileType): Promise<SaveFileOutcome>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileType {
|
export enum FileType {
|
||||||
BatchFile,
|
BatchFile,
|
||||||
ShellScript,
|
ShellScript,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SaveFileOutcome = SuccessfulSaveFile | FailedSaveFile;
|
||||||
|
|
||||||
|
interface SaveFileStatus {
|
||||||
|
readonly success: boolean;
|
||||||
|
readonly error?: SaveFileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulSaveFile extends SaveFileStatus {
|
||||||
|
readonly success: true;
|
||||||
|
readonly error?: SaveFileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FailedSaveFile extends SaveFileStatus {
|
||||||
|
readonly success: false;
|
||||||
|
readonly error: SaveFileError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveFileError {
|
||||||
|
readonly type: SaveFileErrorType;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaveFileErrorType =
|
||||||
|
| 'FileCreationError'
|
||||||
|
| 'DialogDisplayError';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
v-if="canRun"
|
v-if="canRun"
|
||||||
text="Run"
|
text="Run"
|
||||||
icon-name="play"
|
icon-name="play"
|
||||||
@click="executeCode"
|
@click="runCode"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import { defineComponent, computed } from 'vue';
|
import { defineComponent, computed } from 'vue';
|
||||||
import { injectKey } from '@/presentation/injectionSymbols';
|
import { injectKey } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
import { Dialog } from '@/presentation/common/Dialog';
|
||||||
import IconButton from './IconButton.vue';
|
import IconButton from './IconButton.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@@ -21,6 +22,7 @@ export default defineComponent({
|
|||||||
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
|
||||||
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
|
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
|
||||||
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
|
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
|
||||||
|
const { dialog } = injectKey((keys) => keys.useDialog);
|
||||||
|
|
||||||
const canRun = computed<boolean>(() => getCanRunState(
|
const canRun = computed<boolean>(() => getCanRunState(
|
||||||
currentState.value.os,
|
currentState.value.os,
|
||||||
@@ -28,17 +30,20 @@ export default defineComponent({
|
|||||||
os,
|
os,
|
||||||
));
|
));
|
||||||
|
|
||||||
async function executeCode() {
|
async function runCode() {
|
||||||
if (!codeRunner) { throw new Error('missing code runner'); }
|
if (!codeRunner) { throw new Error('missing code runner'); }
|
||||||
await codeRunner.runCode(
|
const { success, error } = await codeRunner.runCode(
|
||||||
currentContext.state.code.current,
|
currentContext.state.code.current,
|
||||||
currentContext.state.collection.scripting.fileExtension,
|
currentContext.state.collection.scripting.fileExtension,
|
||||||
);
|
);
|
||||||
|
if (!success) {
|
||||||
|
showScriptRunError(dialog, `${error.type}: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canRun,
|
canRun,
|
||||||
executeCode,
|
runCode,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -51,4 +56,24 @@ function getCanRunState(
|
|||||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||||
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
|
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showScriptRunError(dialog: Dialog, technicalDetails: string) {
|
||||||
|
dialog.showError(
|
||||||
|
'Error Running Script',
|
||||||
|
[
|
||||||
|
'We encountered an issue while running the script.',
|
||||||
|
'This could be due to a variety of factors such as system permissions, resource constraints, or security software interventions.',
|
||||||
|
'\n',
|
||||||
|
'Here are some steps you can take:',
|
||||||
|
'- Confirm that you have the necessary permissions to execute scripts on your system.',
|
||||||
|
'- Check if there is sufficient disk space and system resources available.',
|
||||||
|
'- Antivirus or security software can sometimes mistakenly block script execution. If you suspect this, verify your security settings, or temporarily disable the security software to see if that resolves the issue.',
|
||||||
|
'- If possible, try running a different script to determine if the issue is specific to a particular script.',
|
||||||
|
'- Should the problem persist, reach out to the community for further assistance.',
|
||||||
|
'\n',
|
||||||
|
'For your reference, here are the technical details of the error:',
|
||||||
|
technicalDetails,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import { injectKey } from '@/presentation/injectionSymbols';
|
|||||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||||
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
|
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { Dialog, FileType } from '@/presentation/common/Dialog';
|
||||||
import IconButton from '../IconButton.vue';
|
import IconButton from '../IconButton.vue';
|
||||||
import InstructionList from './Instructions/InstructionList.vue';
|
import InstructionList from './Instructions/InstructionList.vue';
|
||||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||||
@@ -38,25 +38,28 @@ export default defineComponent({
|
|||||||
const { dialog } = injectKey((keys) => keys.useDialog);
|
const { dialog } = injectKey((keys) => keys.useDialog);
|
||||||
|
|
||||||
const areInstructionsVisible = ref(false);
|
const areInstructionsVisible = ref(false);
|
||||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
const filename = computed<string>(() => buildFilename(currentState.value.collection.scripting));
|
||||||
const instructions = computed<IInstructionListData | undefined>(() => getInstructions(
|
const instructions = computed<IInstructionListData | undefined>(() => getInstructions(
|
||||||
currentState.value.collection.os,
|
currentState.value.collection.os,
|
||||||
fileName.value,
|
filename.value,
|
||||||
));
|
));
|
||||||
|
|
||||||
async function saveCode() {
|
async function saveCode() {
|
||||||
await dialog.saveFile(
|
const { success, error } = await dialog.saveFile(
|
||||||
currentState.value.code.current,
|
currentState.value.code.current,
|
||||||
fileName.value,
|
filename.value,
|
||||||
getType(currentState.value.collection.scripting.language),
|
getType(currentState.value.collection.scripting.language),
|
||||||
);
|
);
|
||||||
|
if (!success) {
|
||||||
|
showScriptSaveError(dialog, `${error.type}: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
areInstructionsVisible.value = true;
|
areInstructionsVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRunningAsDesktopApplication,
|
isRunningAsDesktopApplication,
|
||||||
instructions,
|
instructions,
|
||||||
fileName,
|
|
||||||
areInstructionsVisible,
|
areInstructionsVisible,
|
||||||
saveCode,
|
saveCode,
|
||||||
};
|
};
|
||||||
@@ -74,10 +77,30 @@ function getType(language: ScriptingLanguage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFileName(scripting: IScriptingDefinition) {
|
function buildFilename(scripting: IScriptingDefinition) {
|
||||||
if (scripting.fileExtension) {
|
if (scripting.fileExtension) {
|
||||||
return `${ScriptFileName}.${scripting.fileExtension}`;
|
return `${ScriptFilename}.${scripting.fileExtension}`;
|
||||||
}
|
}
|
||||||
return ScriptFileName;
|
return ScriptFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showScriptSaveError(dialog: Dialog, technicalDetails: string) {
|
||||||
|
dialog.showError(
|
||||||
|
'Error Saving Script',
|
||||||
|
[
|
||||||
|
'An error occurred while saving the script.',
|
||||||
|
'This issue may arise from insufficient permissions, limited disk space, or interference from security software.',
|
||||||
|
'\n',
|
||||||
|
'To address this:',
|
||||||
|
'- Verify your permissions for the selected save directory.',
|
||||||
|
'- Check available disk space.',
|
||||||
|
'- Review your antivirus or security settings; adding an exclusion for privacy.sexy might be necessary.',
|
||||||
|
'- Try saving the script to a different location or modifying your selection.',
|
||||||
|
'- If the problem persists, reach out to the community for further assistance.',
|
||||||
|
'\n',
|
||||||
|
'Technical Details:',
|
||||||
|
technicalDetails,
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||||
import { Dialog } from '@/presentation/common/Dialog';
|
import { Dialog } from '@/presentation/common/Dialog';
|
||||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||||
|
import { decorateWithLogging } from '@/infrastructure/Dialog/LoggingDialogDecorator';
|
||||||
|
import { ClientLoggerFactory } from '../Log/ClientLoggerFactory';
|
||||||
|
|
||||||
export function determineDialogBasedOnEnvironment(
|
export function createEnvironmentSpecificLoggedDialog(
|
||||||
|
environment: RuntimeEnvironment,
|
||||||
|
dialogLoggingDecorator: DialogLoggingDecorator = ClientLoggingDecorator,
|
||||||
|
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
|
||||||
|
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
|
||||||
|
): Dialog {
|
||||||
|
const dialog = determineDialogBasedOnEnvironment(
|
||||||
|
environment,
|
||||||
|
windowInjectedDialogFactory,
|
||||||
|
browserDialogFactory,
|
||||||
|
);
|
||||||
|
const loggingDialog = dialogLoggingDecorator(dialog);
|
||||||
|
return loggingDialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function determineDialogBasedOnEnvironment(
|
||||||
environment: RuntimeEnvironment,
|
environment: RuntimeEnvironment,
|
||||||
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
|
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
|
||||||
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
|
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
|
||||||
@@ -10,16 +27,23 @@ export function determineDialogBasedOnEnvironment(
|
|||||||
if (!environment.isRunningAsDesktopApplication) {
|
if (!environment.isRunningAsDesktopApplication) {
|
||||||
return browserDialogFactory();
|
return browserDialogFactory();
|
||||||
}
|
}
|
||||||
const dialog = windowInjectedDialogFactory();
|
const windowDialog = windowInjectedDialogFactory();
|
||||||
if (!dialog) {
|
if (!windowDialog) {
|
||||||
throw new Error([
|
throw new Error([
|
||||||
'The Dialog API could not be retrieved from the window object.',
|
'Failed to retrieve Dialog API from window object in desktop environment.',
|
||||||
'This may indicate that the Dialog API is either not implemented or not correctly exposed in the current desktop environment.',
|
'This may indicate that the Dialog API is either not implemented or not correctly exposed in the current desktop environment.',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
return dialog;
|
return windowDialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WindowDialogCreationFunction = () => Dialog | undefined;
|
export type WindowDialogCreationFunction = () => Dialog | undefined;
|
||||||
|
|
||||||
export type BrowserDialogCreationFunction = () => Dialog;
|
export type BrowserDialogCreationFunction = () => Dialog;
|
||||||
|
|
||||||
|
export type DialogLoggingDecorator = (dialog: Dialog) => Dialog;
|
||||||
|
|
||||||
|
const ClientLoggingDecorator: DialogLoggingDecorator = (dialog) => decorateWithLogging(
|
||||||
|
dialog,
|
||||||
|
ClientLoggerFactory.Current.logger,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Dialog } from '@/presentation/common/Dialog';
|
import { Dialog } from '@/presentation/common/Dialog';
|
||||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||||
import { determineDialogBasedOnEnvironment } from './ClientDialogFactory';
|
import { createEnvironmentSpecificLoggedDialog } from './ClientDialogFactory';
|
||||||
|
|
||||||
export function useDialog(
|
export function useDialog(
|
||||||
factory: DialogFactory = () => determineDialogBasedOnEnvironment(CurrentEnvironment),
|
factory: DialogFactory = () => createEnvironmentSpecificLoggedDialog(CurrentEnvironment),
|
||||||
) {
|
) {
|
||||||
const dialog = factory();
|
const dialog = factory();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IpcChannel } from './IpcChannel';
|
|||||||
|
|
||||||
export const IpcChannelDefinitions = {
|
export const IpcChannelDefinitions = {
|
||||||
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
|
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
|
||||||
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['saveFile']),
|
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['showError', 'saveFile']),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;
|
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { describe, it } from 'vitest';
|
|||||||
import { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
import { 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 { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
|
||||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
||||||
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
|
||||||
describe('ScriptFileCodeRunner', () => {
|
describe('ScriptFileCodeRunner', () => {
|
||||||
it('executes simple script correctly', async ({ skip }) => {
|
it('executes simple script correctly', async ({ skip }) => {
|
||||||
@@ -23,9 +23,12 @@ describe('ScriptFileCodeRunner', () => {
|
|||||||
const codeRunner = createCodeRunner(temporaryDirectoryProvider);
|
const codeRunner = createCodeRunner(temporaryDirectoryProvider);
|
||||||
const args = getPlatformSpecificArguments(currentOperatingSystem);
|
const args = getPlatformSpecificArguments(currentOperatingSystem);
|
||||||
// act
|
// act
|
||||||
const act = () => codeRunner.runCode(...args);
|
const { success, error } = await codeRunner.runCode(...args);
|
||||||
// assert
|
// assert
|
||||||
await expectDoesNotThrowAsync(act);
|
expect(success).to.equal(true, formatAssertionMessage([
|
||||||
|
'Failed to successfully execute the script.',
|
||||||
|
'Details:', JSON.stringify(error),
|
||||||
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +91,10 @@ function createTemporaryDirectoryProvider(): ScriptDirectoryProvider {
|
|||||||
provideScriptDirectory: async () => {
|
provideScriptDirectory: 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 temporaryDirectoryFullPath;
|
return {
|
||||||
|
success: true,
|
||||||
|
directoryAbsolutePath: temporaryDirectoryFullPath,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
18
tests/shared/Assertions/ExpectTrue.ts
Normal file
18
tests/shared/Assertions/ExpectTrue.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Asserts that the provided boolean value is true.
|
||||||
|
*
|
||||||
|
* Useful when TypeScript's control flow analysis does not recognize standard
|
||||||
|
* assertions, ensuring `value` is treated as true in subsequent code. This helps
|
||||||
|
* prevent type errors and improves code safety and clarity. An optional custom
|
||||||
|
* error message can be provided for more detailed assertion failures.
|
||||||
|
*/
|
||||||
|
export function expectTrue(value: boolean, errorMessage?: string): asserts value is true {
|
||||||
|
if (value !== true) {
|
||||||
|
throw new Error([
|
||||||
|
`Assertion failed: Expected true, received ${value.toString()}.`,
|
||||||
|
'Assertion failed: expected value is not true.',
|
||||||
|
...(typeof value !== 'boolean' ? [`Received type: ${typeof value}`] : []),
|
||||||
|
...(errorMessage ? [errorMessage] : []),
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,13 @@ import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystem
|
|||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||||
|
|
||||||
describe('PersistentDirectoryProvider', () => {
|
describe('PersistentDirectoryProvider', () => {
|
||||||
describe('createDirectory', () => {
|
describe('createDirectory', () => {
|
||||||
describe('path generation', () => {
|
describe('path construction', () => {
|
||||||
it('uses user directory as base', 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/';
|
||||||
@@ -26,17 +27,18 @@ describe('PersistentDirectoryProvider', () => {
|
|||||||
.withLocation(locationOps));
|
.withLocation(locationOps));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualDirectoryResult = await context.createDirectory();
|
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const actualBaseDirectory = actualDirectoryResult.split(pathSegmentSeparator)[0];
|
expectTrue(success);
|
||||||
|
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 = locationOps.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('appends execution subdirectory', async () => {
|
it('includes execution subdirectory in path', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedSubdirectory = ExecutionSubdirectory;
|
const expectedSubdirectory = ExecutionSubdirectory;
|
||||||
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
|
||||||
@@ -46,10 +48,11 @@ describe('PersistentDirectoryProvider', () => {
|
|||||||
.withLocation(locationOps));
|
.withLocation(locationOps));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualDirectoryResult = await context.createDirectory();
|
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const actualSubdirectory = actualDirectoryResult
|
expectTrue(success);
|
||||||
|
const actualSubdirectory = directoryAbsolutePath
|
||||||
.split(pathSegmentSeparator)
|
.split(pathSegmentSeparator)
|
||||||
.pop();
|
.pop();
|
||||||
expect(actualSubdirectory).to.equal(expectedSubdirectory);
|
expect(actualSubdirectory).to.equal(expectedSubdirectory);
|
||||||
@@ -58,7 +61,7 @@ describe('PersistentDirectoryProvider', () => {
|
|||||||
const [,combinedSubdirectory] = calls[0].args;
|
const [,combinedSubdirectory] = calls[0].args;
|
||||||
expect(combinedSubdirectory).to.equal(expectedSubdirectory);
|
expect(combinedSubdirectory).to.equal(expectedSubdirectory);
|
||||||
});
|
});
|
||||||
it('correctly forms the full path', async () => {
|
it('forms full path correctly', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const pathSegmentSeparator = '/';
|
const pathSegmentSeparator = '/';
|
||||||
const baseDirectory = 'base-directory';
|
const baseDirectory = 'base-directory';
|
||||||
@@ -71,14 +74,15 @@ describe('PersistentDirectoryProvider', () => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualDirectory = await context.createDirectory();
|
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(actualDirectory).to.equal(expectedDirectory);
|
expectTrue(success);
|
||||||
|
expect(directoryAbsolutePath).to.equal(expectedDirectory);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('directory creation', () => {
|
describe('directory creation', () => {
|
||||||
it('creates directory recursively', async () => {
|
it('creates directory with recursion', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedIsRecursive = true;
|
const expectedIsRecursive = true;
|
||||||
const filesystem = new FileSystemOpsStub();
|
const filesystem = new FileSystemOpsStub();
|
||||||
@@ -86,48 +90,100 @@ describe('PersistentDirectoryProvider', () => {
|
|||||||
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
|
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const expectedDir = await context.createDirectory();
|
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
|
expectTrue(success);
|
||||||
const calls = filesystem.callHistory.filter((call) => call.methodName === 'createDirectory');
|
const calls = filesystem.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(expectedDir);
|
expect(actualPath).to.equal(directoryAbsolutePath);
|
||||||
expect(actualIsRecursive).to.equal(expectedIsRecursive);
|
expect(actualIsRecursive).to.equal(expectedIsRecursive);
|
||||||
});
|
});
|
||||||
it('logs error when creation fails', async () => {
|
});
|
||||||
// arrange
|
describe('error handling', () => {
|
||||||
const logger = new LoggerStub();
|
const testScenarios: ReadonlyArray<{
|
||||||
const filesystem = new FileSystemOpsStub();
|
readonly description: string;
|
||||||
filesystem.createDirectory = () => { throw new Error(); };
|
readonly expectedErrorType: CodeRunErrorType;
|
||||||
const context = new PersistentDirectoryProviderTestSetup()
|
readonly expectedErrorMessage: string;
|
||||||
.withLogger(logger)
|
buildFaultyContext(
|
||||||
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
|
setup: PersistentDirectoryProviderTestSetup,
|
||||||
|
errorMessage: string,
|
||||||
|
): PersistentDirectoryProviderTestSetup;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'path combination failure',
|
||||||
|
expectedErrorType: 'DirectoryCreationError',
|
||||||
|
expectedErrorMessage: 'Error when combining paths',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const locationStub = new LocationOpsStub();
|
||||||
|
locationStub.combinePaths = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'user data retrieval failure',
|
||||||
|
expectedErrorType: 'DirectoryCreationError',
|
||||||
|
expectedErrorMessage: 'Error when locating user data directory',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const operatingSystemStub = new OperatingSystemOpsStub();
|
||||||
|
operatingSystemStub.getUserDataDirectory = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withSystem(
|
||||||
|
new SystemOperationsStub().withOperatingSystem(operatingSystemStub),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'directory creation failure',
|
||||||
|
expectedErrorType: 'DirectoryCreationError',
|
||||||
|
expectedErrorMessage: 'Error when creating directory',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const fileSystemStub = new FileSystemOpsStub();
|
||||||
|
fileSystemStub.createDirectory = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||||
|
}) => {
|
||||||
|
it(`handles error - ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new PersistentDirectoryProviderTestSetup(),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
try {
|
const { success, error } = await context.provideScriptDirectory();
|
||||||
await context.createDirectory();
|
|
||||||
} catch {
|
|
||||||
// swallow
|
|
||||||
}
|
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const errorCall = logger.callHistory.find((c) => c.methodName === 'error');
|
expect(success).to.equal(false);
|
||||||
expectExists(errorCall);
|
expectExists(error);
|
||||||
});
|
expect(error.message).to.include(expectedErrorMessage);
|
||||||
it('throws error on creation failure', async () => {
|
expect(error.type).to.equal(expectedErrorType);
|
||||||
// arrange
|
});
|
||||||
const expectedError = 'expected file system error';
|
it(`logs error: ${description}`, async () => {
|
||||||
const filesystem = new FileSystemOpsStub();
|
// arrange
|
||||||
filesystem.createDirectory = () => { throw new Error(expectedError); };
|
const loggerStub = new LoggerStub();
|
||||||
const context = new PersistentDirectoryProviderTestSetup()
|
const context = buildFaultyContext(
|
||||||
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
|
new PersistentDirectoryProviderTestSetup()
|
||||||
|
.withLogger(loggerStub),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const act = () => context.createDirectory();
|
await context.provideScriptDirectory();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
await expectThrowsAsync(act, expectedError);
|
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -148,7 +204,7 @@ class PersistentDirectoryProviderTestSetup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> {
|
public provideScriptDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> {
|
||||||
const provider = new PersistentDirectoryProvider(this.system, this.logger);
|
const provider = new PersistentDirectoryProvider(this.system, this.logger);
|
||||||
return provider.provideScriptDirectory();
|
return provider.provideScriptDirectory();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('TimestampedFilenameGenerator', () => {
|
|||||||
const filename = generateFilenamePartsForTesting({ date });
|
const filename = generateFilenamePartsForTesting({ date });
|
||||||
// assert
|
// assert
|
||||||
expect(filename.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[
|
expect(filename.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[
|
||||||
`Generated file name: ${filename.generatedFilename}`
|
`Generated filename: ${filename.generatedFilename}`
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
describe('extension', () => {
|
describe('extension', () => {
|
||||||
@@ -49,7 +49,7 @@ describe('TimestampedFilenameGenerator', () => {
|
|||||||
const filename = generateFilenamePartsForTesting({ extension: expectedExtension });
|
const filename = generateFilenamePartsForTesting({ extension: expectedExtension });
|
||||||
// assert
|
// assert
|
||||||
expect(filename.extension).to.equal(expectedExtension, formatAssertionMessage[
|
expect(filename.extension).to.equal(expectedExtension, formatAssertionMessage[
|
||||||
`Generated file name: ${filename.generatedFilename}`
|
`Generated filename: ${filename.generatedFilename}`
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
describe('handles absent extension', () => {
|
describe('handles absent extension', () => {
|
||||||
|
|||||||
@@ -11,19 +11,21 @@ import { FilenameGeneratorStub } from '@tests/unit/shared/Stubs/FilenameGenerato
|
|||||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||||
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||||
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
|
||||||
import { ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
import { 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 { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
describe('ScriptFileCreationOrchestrator', () => {
|
describe('ScriptFileCreationOrchestrator', () => {
|
||||||
describe('createScriptFile', () => {
|
describe('createScriptFile', () => {
|
||||||
describe('path generation', () => {
|
describe('path generation', () => {
|
||||||
it('generates correct directory path', async () => {
|
it('correctly generates directory path', async () => {
|
||||||
// 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 filesystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withSystemOperations(new SystemOperationsStub()
|
.withSystem(new SystemOperationsStub()
|
||||||
.withLocation(
|
.withLocation(
|
||||||
new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator),
|
new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator),
|
||||||
)
|
)
|
||||||
@@ -33,45 +35,47 @@ describe('ScriptFileCreationOrchestrator', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualFilePath = await context.createScriptFile();
|
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const actualDirectory = actualFilePath
|
expectTrue(success);
|
||||||
|
const actualDirectory = scriptFileAbsolutePath
|
||||||
.split(pathSegmentSeparator)
|
.split(pathSegmentSeparator)
|
||||||
.slice(0, -1)
|
.slice(0, -1)
|
||||||
.join(pathSegmentSeparator);
|
.join(pathSegmentSeparator);
|
||||||
expect(actualDirectory).to.equal(expectedScriptDirectory, formatAssertionMessage([
|
expect(actualDirectory).to.equal(expectedScriptDirectory, formatAssertionMessage([
|
||||||
`Actual file path: ${actualFilePath}`,
|
`Actual file path: ${scriptFileAbsolutePath}`,
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
it('generates correct file name', async () => {
|
it('correctly generates filename', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
|
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
|
||||||
const filesystem = new FileSystemOpsStub();
|
const filesystem = new FileSystemOpsStub();
|
||||||
const expectedFilename = 'expected-script-file-name';
|
const expectedFilename = 'expected-script-file-name';
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename))
|
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename))
|
||||||
.withSystemOperations(new SystemOperationsStub()
|
.withSystem(new SystemOperationsStub()
|
||||||
.withFileSystem(filesystem)
|
.withFileSystem(filesystem)
|
||||||
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)));
|
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualFilePath = await context.createScriptFile();
|
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const actualFileName = actualFilePath
|
expectTrue(success);
|
||||||
|
const actualFileName = scriptFileAbsolutePath
|
||||||
.split(pathSegmentSeparator)
|
.split(pathSegmentSeparator)
|
||||||
.pop();
|
.pop();
|
||||||
expect(actualFileName).to.equal(expectedFilename);
|
expect(actualFileName).to.equal(expectedFilename);
|
||||||
});
|
});
|
||||||
it('generates file name using specified parts', async () => {
|
it('uses specified parts to generate filename', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedParts: ScriptFileNameParts = {
|
const expectedParts: ScriptFilenameParts = {
|
||||||
scriptName: 'expected-script-name',
|
scriptName: 'expected-script-name',
|
||||||
scriptFileExtension: 'expected-script-file-extension',
|
scriptFileExtension: 'expected-script-file-extension',
|
||||||
};
|
};
|
||||||
const filenameGeneratorStub = new FilenameGeneratorStub();
|
const filenameGeneratorStub = new FilenameGeneratorStub();
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withFileNameParts(expectedParts)
|
.withFileNameParts(expectedParts)
|
||||||
.withFilenameGenerator(filenameGeneratorStub);
|
.withFilenameGenerator(filenameGeneratorStub);
|
||||||
|
|
||||||
@@ -79,58 +83,60 @@ describe('ScriptFileCreationOrchestrator', () => {
|
|||||||
await context.createScriptFile();
|
await context.createScriptFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const fileNameGenerationCalls = filenameGeneratorStub.callHistory.filter((c) => c.methodName === 'generateFilename');
|
const filenameGenerationCalls = filenameGeneratorStub.callHistory.filter((c) => c.methodName === 'generateFilename');
|
||||||
expect(fileNameGenerationCalls).to.have.lengthOf(1);
|
expect(filenameGenerationCalls).to.have.lengthOf(1);
|
||||||
const callArguments = fileNameGenerationCalls[0].args;
|
const callArguments = filenameGenerationCalls[0].args;
|
||||||
const [scriptNameFileParts] = callArguments;
|
const [scriptNameFileParts] = callArguments;
|
||||||
expectExists(scriptNameFileParts, `Call arguments: ${JSON.stringify(callArguments)}`);
|
expectExists(scriptNameFileParts, `Call arguments: ${JSON.stringify(callArguments)}`);
|
||||||
expect(scriptNameFileParts).to.equal(expectedParts);
|
expect(scriptNameFileParts).to.equal(expectedParts);
|
||||||
});
|
});
|
||||||
it('generates complete file path', async () => {
|
it('correctly generates complete file path', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedPath = 'expected-script-path';
|
const expectedPath = 'expected-script-path';
|
||||||
const fileName = 'file-name';
|
const filename = 'filename';
|
||||||
const directoryPath = 'directory-path';
|
const directoryPath = 'directory-path';
|
||||||
const filesystem = new FileSystemOpsStub();
|
const filesystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(fileName))
|
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(filename))
|
||||||
.withDirectoryProvider(new ScriptDirectoryProviderStub().withDirectoryPath(directoryPath))
|
.withDirectoryProvider(new ScriptDirectoryProviderStub().withDirectoryPath(directoryPath))
|
||||||
.withSystemOperations(new SystemOperationsStub()
|
.withSystem(new SystemOperationsStub()
|
||||||
.withFileSystem(filesystem)
|
.withFileSystem(filesystem)
|
||||||
.withLocation(
|
.withLocation(
|
||||||
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, fileName),
|
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, filename),
|
||||||
));
|
));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualFilePath = await context.createScriptFile();
|
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(actualFilePath).to.equal(expectedPath);
|
expectTrue(success);
|
||||||
|
expect(scriptFileAbsolutePath).to.equal(expectedPath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('file writing', () => {
|
describe('file writing', () => {
|
||||||
it('writes file to the generated path', async () => {
|
it('writes to generated file path', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const filesystem = new FileSystemOpsStub();
|
const filesystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withSystemOperations(new SystemOperationsStub()
|
.withSystem(new SystemOperationsStub()
|
||||||
.withFileSystem(filesystem));
|
.withFileSystem(filesystem));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const expectedPath = await context.createScriptFile();
|
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
|
expectTrue(success);
|
||||||
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
|
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
|
||||||
expect(calls.length).to.equal(1);
|
expect(calls.length).to.equal(1);
|
||||||
const [actualFilePath] = calls[0].args;
|
const [actualFilePath] = calls[0].args;
|
||||||
expect(actualFilePath).to.equal(expectedPath);
|
expect(actualFilePath).to.equal(scriptFileAbsolutePath);
|
||||||
});
|
});
|
||||||
it('writes provided script content to file', async () => {
|
it('writes script content to file', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedCode = 'expected-code';
|
const expectedCode = 'expected-code';
|
||||||
const filesystem = new FileSystemOpsStub();
|
const filesystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileCreationOrchestratorTestSetup()
|
const context = new ScriptFileCreatorTestSetup()
|
||||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(filesystem))
|
.withSystem(new SystemOperationsStub().withFileSystem(filesystem))
|
||||||
.withFileContents(expectedCode);
|
.withFileContents(expectedCode);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -143,10 +149,119 @@ describe('ScriptFileCreationOrchestrator', () => {
|
|||||||
expect(actualData).to.equal(expectedCode);
|
expect(actualData).to.equal(expectedCode);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('error handling', () => {
|
||||||
|
const testScenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly expectedErrorType: CodeRunErrorType;
|
||||||
|
readonly expectedErrorMessage: string;
|
||||||
|
readonly expectLogs: boolean;
|
||||||
|
buildFaultyContext(
|
||||||
|
setup: ScriptFileCreatorTestSetup,
|
||||||
|
errorMessage: string,
|
||||||
|
errorType: CodeRunErrorType,
|
||||||
|
): ScriptFileCreatorTestSetup;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'path combination failure',
|
||||||
|
expectedErrorType: 'FilePathGenerationError',
|
||||||
|
expectedErrorMessage: 'Error when combining paths',
|
||||||
|
expectLogs: true,
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const locationStub = new LocationOpsStub();
|
||||||
|
locationStub.combinePaths = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'file writing failure',
|
||||||
|
expectedErrorType: 'FileWriteError',
|
||||||
|
expectedErrorMessage: 'Error when writing to file',
|
||||||
|
expectLogs: true,
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const fileSystemStub = new FileSystemOpsStub();
|
||||||
|
fileSystemStub.writeToFile = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'filename generation failure',
|
||||||
|
expectedErrorType: 'FilePathGenerationError',
|
||||||
|
expectedErrorMessage: 'Error when writing to file',
|
||||||
|
expectLogs: true,
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const filenameGenerator = new FilenameGeneratorStub();
|
||||||
|
filenameGenerator.generateFilename = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup.withFilenameGenerator(filenameGenerator);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'script directory provision failure',
|
||||||
|
expectedErrorType: 'DirectoryCreationError',
|
||||||
|
expectedErrorMessage: 'Error when providing directory',
|
||||||
|
expectLogs: false,
|
||||||
|
buildFaultyContext: (setup, errorMessage, errorType) => {
|
||||||
|
const directoryProvider = new ScriptDirectoryProviderStub();
|
||||||
|
directoryProvider.provideScriptDirectory = () => Promise.resolve({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: errorMessage,
|
||||||
|
type: errorType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return setup.withDirectoryProvider(directoryProvider);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
|
||||||
|
}) => {
|
||||||
|
it(`handles error - ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new ScriptFileCreatorTestSetup(),
|
||||||
|
expectedErrorMessage,
|
||||||
|
expectedErrorType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { success, error } = await context.createScriptFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(success).to.equal(false);
|
||||||
|
expectExists(error);
|
||||||
|
expect(error.message).to.include(expectedErrorMessage);
|
||||||
|
expect(error.type).to.equal(expectedErrorType);
|
||||||
|
});
|
||||||
|
if (expectLogs) {
|
||||||
|
it(`logs error: ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new ScriptFileCreatorTestSetup()
|
||||||
|
.withLogger(loggerStub),
|
||||||
|
expectedErrorMessage,
|
||||||
|
expectedErrorType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// act
|
||||||
|
await context.createScriptFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class ScriptFileCreationOrchestratorTestSetup {
|
class ScriptFileCreatorTestSetup {
|
||||||
private system: SystemOperations = new SystemOperationsStub();
|
private system: SystemOperations = new SystemOperationsStub();
|
||||||
|
|
||||||
private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub();
|
private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub();
|
||||||
@@ -155,11 +270,11 @@ class ScriptFileCreationOrchestratorTestSetup {
|
|||||||
|
|
||||||
private logger: Logger = new LoggerStub();
|
private logger: Logger = new LoggerStub();
|
||||||
|
|
||||||
private fileContents = `[${ScriptFileCreationOrchestratorTestSetup.name}] script file contents`;
|
private fileContents = `[${ScriptFileCreatorTestSetup.name}] script file contents`;
|
||||||
|
|
||||||
private fileNameParts: ScriptFileNameParts = {
|
private filenameParts: ScriptFilenameParts = {
|
||||||
scriptName: `[${ScriptFileCreationOrchestratorTestSetup.name}] script name`,
|
scriptName: `[${ScriptFileCreatorTestSetup.name}] script name`,
|
||||||
scriptFileExtension: `[${ScriptFileCreationOrchestratorTestSetup.name}] file extension`,
|
scriptFileExtension: `[${ScriptFileCreatorTestSetup.name}] file extension`,
|
||||||
};
|
};
|
||||||
|
|
||||||
public withFileContents(fileContents: string): this {
|
public withFileContents(fileContents: string): this {
|
||||||
@@ -177,13 +292,18 @@ class ScriptFileCreationOrchestratorTestSetup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withSystemOperations(system: SystemOperations): this {
|
public withSystem(system: SystemOperations): this {
|
||||||
this.system = system;
|
this.system = system;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withFileNameParts(fileNameParts: ScriptFileNameParts): this {
|
public withFileNameParts(filenameParts: ScriptFilenameParts): this {
|
||||||
this.fileNameParts = fileNameParts;
|
this.filenameParts = filenameParts;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withLogger(logger: Logger): this {
|
||||||
|
this.logger = logger;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +314,6 @@ class ScriptFileCreationOrchestratorTestSetup {
|
|||||||
this.directoryProvider,
|
this.directoryProvider,
|
||||||
this.logger,
|
this.logger,
|
||||||
);
|
);
|
||||||
return creator.createScriptFile(this.fileContents, this.fileNameParts);
|
return creator.createScriptFile(this.fileContents, this.filenameParts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems';
|
import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems';
|
||||||
import { VisibleTerminalScriptExecutor } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
import { VisibleTerminalScriptExecutor } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
||||||
@@ -9,41 +8,12 @@ import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsS
|
|||||||
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
||||||
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||||
|
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
|
||||||
describe('VisibleTerminalScriptFileExecutor', () => {
|
describe('VisibleTerminalScriptFileExecutor', () => {
|
||||||
describe('executeScriptFile', () => {
|
describe('executeScriptFile', () => {
|
||||||
describe('throws error for invalid operating systems', () => {
|
|
||||||
const testScenarios: ReadonlyArray<{
|
|
||||||
readonly description: string;
|
|
||||||
readonly invalidOs?: OperatingSystem;
|
|
||||||
readonly expectedError: string;
|
|
||||||
}> = [
|
|
||||||
(() => {
|
|
||||||
const unsupportedOs = OperatingSystem.Android;
|
|
||||||
return {
|
|
||||||
description: 'unsupported OS',
|
|
||||||
invalidOs: unsupportedOs,
|
|
||||||
expectedError: `Unsupported operating system: ${OperatingSystem[unsupportedOs]}`,
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
{
|
|
||||||
description: 'undefined OS',
|
|
||||||
invalidOs: undefined,
|
|
||||||
expectedError: 'Unknown operating system',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
testScenarios.forEach(({ description, invalidOs, expectedError }) => {
|
|
||||||
it(description, async () => {
|
|
||||||
// arrange
|
|
||||||
const context = new ScriptFileTestSetup()
|
|
||||||
.withOs(invalidOs);
|
|
||||||
// act
|
|
||||||
const act = async () => { await context.executeScriptFile(); };
|
|
||||||
// assert
|
|
||||||
await expectThrowsAsync(act, expectedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('command execution', () => {
|
describe('command execution', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const testScenarios: Record<SupportedOperatingSystem, readonly {
|
const testScenarios: Record<SupportedOperatingSystem, readonly {
|
||||||
@@ -88,10 +58,10 @@ describe('VisibleTerminalScriptFileExecutor', () => {
|
|||||||
testScenarios[operatingSystem].forEach((
|
testScenarios[operatingSystem].forEach((
|
||||||
{ description, filePath, expectedCommand },
|
{ description, filePath, expectedCommand },
|
||||||
) => {
|
) => {
|
||||||
it(description, async () => {
|
it(`executes command - ${description}`, async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const command = new CommandOpsStub();
|
const command = new CommandOpsStub();
|
||||||
const context = new ScriptFileTestSetup()
|
const context = new ScriptFileExecutorTestSetup()
|
||||||
.withOs(operatingSystem)
|
.withOs(operatingSystem)
|
||||||
.withFilePath(filePath)
|
.withFilePath(filePath)
|
||||||
.withSystemOperations(new SystemOperationsStub().withCommand(command));
|
.withSystemOperations(new SystemOperationsStub().withCommand(command));
|
||||||
@@ -124,7 +94,7 @@ describe('VisibleTerminalScriptFileExecutor', () => {
|
|||||||
isExecutedAfterPermissions = isPermissionsSet;
|
isExecutedAfterPermissions = isPermissionsSet;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
const context = new ScriptFileTestSetup()
|
const context = new ScriptFileExecutorTestSetup()
|
||||||
.withSystemOperations(new SystemOperationsStub()
|
.withSystemOperations(new SystemOperationsStub()
|
||||||
.withFileSystem(fileSystemMock)
|
.withFileSystem(fileSystemMock)
|
||||||
.withCommand(commandMock));
|
.withCommand(commandMock));
|
||||||
@@ -139,7 +109,7 @@ describe('VisibleTerminalScriptFileExecutor', () => {
|
|||||||
// arrange
|
// arrange
|
||||||
const expectedMode = '755';
|
const expectedMode = '755';
|
||||||
const fileSystem = new FileSystemOpsStub();
|
const fileSystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileTestSetup()
|
const context = new ScriptFileExecutorTestSetup()
|
||||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
@@ -151,11 +121,11 @@ describe('VisibleTerminalScriptFileExecutor', () => {
|
|||||||
const [, actualMode] = calls[0].args;
|
const [, actualMode] = calls[0].args;
|
||||||
expect(actualMode).to.equal(expectedMode);
|
expect(actualMode).to.equal(expectedMode);
|
||||||
});
|
});
|
||||||
it('sets permissions on the correct file', async () => {
|
it('sets permissions for correct file', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedFilePath = 'expected-file-path';
|
const expectedFilePath = 'expected-file-path';
|
||||||
const fileSystem = new FileSystemOpsStub();
|
const fileSystem = new FileSystemOpsStub();
|
||||||
const context = new ScriptFileTestSetup()
|
const context = new ScriptFileExecutorTestSetup()
|
||||||
.withFilePath(expectedFilePath)
|
.withFilePath(expectedFilePath)
|
||||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||||
|
|
||||||
@@ -169,16 +139,121 @@ describe('VisibleTerminalScriptFileExecutor', () => {
|
|||||||
expect(actualFilePath).to.equal(expectedFilePath);
|
expect(actualFilePath).to.equal(expectedFilePath);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('indicates success on successful execution', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedSuccessResult = true;
|
||||||
|
const context = new ScriptFileExecutorTestSetup();
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { success: actualSuccessValue } = await context.executeScriptFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(actualSuccessValue).to.equal(expectedSuccessResult);
|
||||||
|
});
|
||||||
|
describe('error handling', () => {
|
||||||
|
const testScenarios: ReadonlyArray<{
|
||||||
|
readonly description: string;
|
||||||
|
readonly expectedErrorType: CodeRunErrorType;
|
||||||
|
readonly expectedErrorMessage: string;
|
||||||
|
buildFaultyContext(
|
||||||
|
setup: ScriptFileExecutorTestSetup,
|
||||||
|
errorMessage: string,
|
||||||
|
): ScriptFileExecutorTestSetup;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'unindentified os',
|
||||||
|
expectedErrorType: 'UnsupportedOperatingSystem',
|
||||||
|
expectedErrorMessage: 'Operating system could not be identified from environment',
|
||||||
|
buildFaultyContext: (setup) => {
|
||||||
|
return setup.withOs(undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'unsupported OS',
|
||||||
|
expectedErrorType: 'UnsupportedOperatingSystem',
|
||||||
|
expectedErrorMessage: `Unsupported operating system: ${OperatingSystem[OperatingSystem.Android]}`,
|
||||||
|
buildFaultyContext: (setup) => {
|
||||||
|
return setup.withOs(OperatingSystem.Android);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'file permissions failure',
|
||||||
|
expectedErrorType: 'FileExecutionError',
|
||||||
|
expectedErrorMessage: 'Error when setting file permissions',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const fileSystem = new FileSystemOpsStub();
|
||||||
|
fileSystem.setFilePermissions = () => Promise.reject(errorMessage);
|
||||||
|
return setup.withSystemOperations(
|
||||||
|
new SystemOperationsStub().withFileSystem(fileSystem),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'command failure',
|
||||||
|
expectedErrorType: 'FileExecutionError',
|
||||||
|
expectedErrorMessage: 'Error when setting file permissions',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const command = new CommandOpsStub();
|
||||||
|
command.exec = () => Promise.reject(errorMessage);
|
||||||
|
return setup.withSystemOperations(
|
||||||
|
new SystemOperationsStub().withCommand(command),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||||
|
}) => {
|
||||||
|
it(`handles error - ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new ScriptFileExecutorTestSetup(),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { success, error } = await context.executeScriptFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(success).to.equal(false);
|
||||||
|
expectExists(error);
|
||||||
|
expect(error.message).to.include(expectedErrorMessage);
|
||||||
|
expect(error.type).to.equal(expectedErrorType);
|
||||||
|
});
|
||||||
|
it(`logs error - ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new ScriptFileExecutorTestSetup()
|
||||||
|
.withLogger(loggerStub),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// act
|
||||||
|
await context.executeScriptFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
class ScriptFileTestSetup {
|
class ScriptFileExecutorTestSetup {
|
||||||
private os?: OperatingSystem = OperatingSystem.Windows;
|
private os?: OperatingSystem = OperatingSystem.Windows;
|
||||||
|
|
||||||
private filePath = `[${ScriptFileTestSetup.name}] file path`;
|
private filePath = `[${ScriptFileExecutorTestSetup.name}] file path`;
|
||||||
|
|
||||||
private system: SystemOperations = new SystemOperationsStub();
|
private system: SystemOperations = new SystemOperationsStub();
|
||||||
|
|
||||||
|
private logger: Logger = new LoggerStub();
|
||||||
|
|
||||||
|
public withLogger(logger: Logger): this {
|
||||||
|
this.logger = logger;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public withOs(os: OperatingSystem | undefined): this {
|
public withOs(os: OperatingSystem | undefined): this {
|
||||||
this.os = os;
|
this.os = os;
|
||||||
return this;
|
return this;
|
||||||
@@ -194,10 +269,9 @@ class ScriptFileTestSetup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public executeScriptFile(): Promise<void> {
|
public executeScriptFile() {
|
||||||
const environment = new RuntimeEnvironmentStub().withOs(this.os);
|
const environment = new RuntimeEnvironmentStub().withOs(this.os);
|
||||||
const logger = new LoggerStub();
|
const executor = new VisibleTerminalScriptExecutor(this.system, this.logger, environment);
|
||||||
const executor = new VisibleTerminalScriptExecutor(this.system, logger, environment);
|
|
||||||
return executor.executeScriptFile(this.filePath);
|
return executor.executeScriptFile(this.filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,142 +2,178 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
|
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
|
||||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
|
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||||
import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||||
import { ScriptFileExecutorStub } from '@tests/unit/shared/Stubs/ScriptFileExecutorStub';
|
import { ScriptFileExecutorStub } from '@tests/unit/shared/Stubs/ScriptFileExecutorStub';
|
||||||
import { ScriptFileCreator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
import { ScriptFileCreator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||||
import { ScriptFileCreatorStub } from '@tests/unit/shared/Stubs/ScriptFileCreatorStub';
|
import { ScriptFileCreatorStub } from '@tests/unit/shared/Stubs/ScriptFileCreatorStub';
|
||||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
describe('ScriptFileCodeRunner', () => {
|
describe('ScriptFileCodeRunner', () => {
|
||||||
describe('runCode', () => {
|
describe('runCode', () => {
|
||||||
it('executes script file correctly', async () => {
|
describe('creating file', () => {
|
||||||
// arrange
|
it('uses provided code', async () => {
|
||||||
const expectedFilePath = 'expected script path';
|
// arrange
|
||||||
const fileExecutor = new ScriptFileExecutorStub();
|
const expectedCode = 'expected code';
|
||||||
const context = new CodeRunnerTestSetup()
|
const fileCreator = new ScriptFileCreatorStub();
|
||||||
.withFileCreator(new ScriptFileCreatorStub().withCreatedFilePath(expectedFilePath))
|
const context = new CodeRunnerTestSetup()
|
||||||
.withFileExecutor(fileExecutor);
|
.withFileCreator(fileCreator)
|
||||||
|
.withCode(expectedCode);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.runCode();
|
await context.runCode();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const executeCalls = fileExecutor.callHistory.filter((call) => call.methodName === 'executeScriptFile');
|
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
||||||
expect(executeCalls.length).to.equal(1);
|
expect(createCalls.length).to.equal(1);
|
||||||
const [actualPath] = executeCalls[0].args;
|
const [actualCode] = createCalls[0].args;
|
||||||
expect(actualPath).to.equal(expectedFilePath);
|
expect(actualCode).to.equal(expectedCode);
|
||||||
|
});
|
||||||
|
it('uses provided extension', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedFileExtension = 'expected-file-extension';
|
||||||
|
const fileCreator = new ScriptFileCreatorStub();
|
||||||
|
const context = new CodeRunnerTestSetup()
|
||||||
|
.withFileCreator(fileCreator)
|
||||||
|
.withFileExtension(expectedFileExtension);
|
||||||
|
|
||||||
|
// act
|
||||||
|
await context.runCode();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
||||||
|
expect(createCalls.length).to.equal(1);
|
||||||
|
const [,scriptFileNameParts] = createCalls[0].args;
|
||||||
|
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
|
||||||
|
expect(scriptFileNameParts.scriptFileExtension).to.equal(expectedFileExtension);
|
||||||
|
});
|
||||||
|
it('uses default script name', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedScriptName = ScriptFilename;
|
||||||
|
const fileCreator = new ScriptFileCreatorStub();
|
||||||
|
const context = new CodeRunnerTestSetup()
|
||||||
|
.withFileCreator(fileCreator);
|
||||||
|
|
||||||
|
// act
|
||||||
|
await context.runCode();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
||||||
|
expect(createCalls.length).to.equal(1);
|
||||||
|
const [,scriptFileNameParts] = createCalls[0].args;
|
||||||
|
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
|
||||||
|
expect(scriptFileNameParts.scriptName).to.equal(expectedScriptName);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it('creates script file with provided code', async () => {
|
describe('executing file', () => {
|
||||||
// arrange
|
it('executes at correct path', async () => {
|
||||||
const expectedCode = 'expected code';
|
// arrange
|
||||||
const fileCreator = new ScriptFileCreatorStub();
|
const expectedFilePath = 'expected script path';
|
||||||
const context = new CodeRunnerTestSetup()
|
const fileExecutor = new ScriptFileExecutorStub();
|
||||||
.withFileCreator(fileCreator)
|
const context = new CodeRunnerTestSetup()
|
||||||
.withCode(expectedCode);
|
.withFileCreator(new ScriptFileCreatorStub().withCreatedFilePath(expectedFilePath))
|
||||||
|
.withFileExecutor(fileExecutor);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.runCode();
|
await context.runCode();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
const executeCalls = fileExecutor.callHistory.filter((call) => call.methodName === 'executeScriptFile');
|
||||||
expect(createCalls.length).to.equal(1);
|
expect(executeCalls.length).to.equal(1);
|
||||||
const [actualCode] = createCalls[0].args;
|
const [actualPath] = executeCalls[0].args;
|
||||||
expect(actualCode).to.equal(expectedCode);
|
expect(actualPath).to.equal(expectedFilePath);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it('creates script file with provided extension', async () => {
|
describe('successful run', () => {
|
||||||
// arrange
|
it('indicates success', async () => {
|
||||||
const expectedFileExtension = 'expected-file-extension';
|
// arrange
|
||||||
const fileCreator = new ScriptFileCreatorStub();
|
const expectedSuccessResult = true;
|
||||||
const context = new CodeRunnerTestSetup()
|
const context = new CodeRunnerTestSetup();
|
||||||
.withFileCreator(fileCreator)
|
|
||||||
.withFileExtension(expectedFileExtension);
|
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.runCode();
|
const { success: actualSuccessValue } = await context.runCode();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
expect(actualSuccessValue).to.equal(expectedSuccessResult);
|
||||||
expect(createCalls.length).to.equal(1);
|
});
|
||||||
const [,scriptFileNameParts] = createCalls[0].args;
|
it('logs success message', async () => {
|
||||||
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
|
// arrange
|
||||||
expect(scriptFileNameParts.scriptFileExtension).to.equal(expectedFileExtension);
|
const expectedMessagePart = 'Successfully ran script';
|
||||||
});
|
const logger = new LoggerStub();
|
||||||
it('creates script file with provided name', async () => {
|
const context = new CodeRunnerTestSetup()
|
||||||
// arrange
|
.withLogger(logger);
|
||||||
const expectedScriptName = ScriptFileName;
|
|
||||||
const fileCreator = new ScriptFileCreatorStub();
|
|
||||||
const context = new CodeRunnerTestSetup()
|
|
||||||
.withFileCreator(fileCreator);
|
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.runCode();
|
await context.runCode();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
|
logger.assertLogsContainMessagePart('info', expectedMessagePart);
|
||||||
expect(createCalls.length).to.equal(1);
|
});
|
||||||
const [,scriptFileNameParts] = createCalls[0].args;
|
|
||||||
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
|
|
||||||
expect(scriptFileNameParts.scriptName).to.equal(expectedScriptName);
|
|
||||||
});
|
});
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
const testScenarios: ReadonlyArray<{
|
const testScenarios: ReadonlyArray<{
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly injectedException: Error;
|
readonly expectedErrorType: CodeRunErrorType;
|
||||||
readonly faultyContext: CodeRunnerTestSetup;
|
readonly expectedErrorMessage: string;
|
||||||
|
buildFaultyContext(
|
||||||
|
setup: CodeRunnerTestSetup,
|
||||||
|
errorMessage: string,
|
||||||
|
errorType: CodeRunErrorType,
|
||||||
|
): CodeRunnerTestSetup;
|
||||||
}> = [
|
}> = [
|
||||||
(() => {
|
{
|
||||||
const error = new Error('Test Error: Script file execution intentionally failed for testing purposes.');
|
description: 'execution failure',
|
||||||
const executor = new ScriptFileExecutorStub();
|
expectedErrorType: 'FileExecutionError',
|
||||||
executor.executeScriptFile = () => {
|
expectedErrorMessage: 'execution error',
|
||||||
throw error;
|
buildFaultyContext: (setup, errorMessage, errorType) => {
|
||||||
};
|
const executor = new ScriptFileExecutorStub();
|
||||||
return {
|
executor.executeScriptFile = () => Promise.resolve({
|
||||||
description: 'fails to execute script file',
|
success: false,
|
||||||
injectedException: error,
|
error: {
|
||||||
faultyContext: new CodeRunnerTestSetup().withFileExecutor(executor),
|
message: errorMessage,
|
||||||
};
|
type: errorType,
|
||||||
})(),
|
},
|
||||||
(() => {
|
});
|
||||||
const error = new Error('Test Error: Script file creation intentionally failed for testing purposes.');
|
return setup.withFileExecutor(executor);
|
||||||
const creator = new ScriptFileCreatorStub();
|
},
|
||||||
creator.createScriptFile = () => {
|
},
|
||||||
throw error;
|
{
|
||||||
};
|
description: 'creation failure',
|
||||||
return {
|
expectedErrorType: 'FileWriteError',
|
||||||
description: 'fails to create script file',
|
expectedErrorMessage: 'creation error',
|
||||||
injectedException: error,
|
buildFaultyContext: (setup, errorMessage, errorType) => {
|
||||||
faultyContext: new CodeRunnerTestSetup().withFileCreator(creator),
|
const creator = new ScriptFileCreatorStub();
|
||||||
};
|
creator.createScriptFile = () => Promise.resolve({
|
||||||
})(),
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: errorMessage,
|
||||||
|
type: errorType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return setup.withFileCreator(creator);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
describe('logs errors', () => {
|
testScenarios.forEach(({
|
||||||
testScenarios.forEach(({ description, faultyContext }) => {
|
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||||
it(`logs error when ${description}`, async () => {
|
}) => {
|
||||||
// arrange
|
it(`handles ${description}`, async () => {
|
||||||
const logger = new LoggerStub();
|
// arrange
|
||||||
faultyContext.withLogger(logger);
|
const context = buildFaultyContext(
|
||||||
// act
|
new CodeRunnerTestSetup(),
|
||||||
try {
|
expectedErrorMessage,
|
||||||
await faultyContext.runCode();
|
expectedErrorType,
|
||||||
} catch {
|
);
|
||||||
// Swallow
|
|
||||||
}
|
// act
|
||||||
// assert
|
const { success, error } = await context.runCode();
|
||||||
const errorCall = logger.callHistory.find((c) => c.methodName === 'error');
|
|
||||||
expectExists(errorCall);
|
// assert
|
||||||
});
|
expect(success).to.equal(false);
|
||||||
});
|
expectExists(error);
|
||||||
});
|
expect(error.message).to.include(expectedErrorMessage);
|
||||||
describe('rethrows errors', () => {
|
expect(error.type).to.equal(expectedErrorType);
|
||||||
testScenarios.forEach(({ description, injectedException, faultyContext }) => {
|
|
||||||
it(`rethrows error when ${description}`, async () => {
|
|
||||||
// act
|
|
||||||
const act = () => faultyContext.runCode();
|
|
||||||
// assert
|
|
||||||
await expectThrowsAsync(act, injectedException.message);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -155,13 +191,14 @@ class CodeRunnerTestSetup {
|
|||||||
|
|
||||||
private logger: Logger = new LoggerStub();
|
private logger: Logger = new LoggerStub();
|
||||||
|
|
||||||
public async runCode(): Promise<void> {
|
public runCode() {
|
||||||
const runner = new ScriptFileCodeRunner(
|
const runner = new ScriptFileCodeRunner(
|
||||||
this.fileExecutor,
|
this.fileExecutor,
|
||||||
this.fileCreator,
|
this.fileCreator,
|
||||||
this.logger,
|
this.logger,
|
||||||
);
|
);
|
||||||
await runner.runCode(this.code, this.fileExtension);
|
return runner
|
||||||
|
.runCode(this.code, this.fileExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
public withFileExecutor(fileExecutor: ScriptFileExecutor): this {
|
public withFileExecutor(fileExecutor: ScriptFileExecutor): this {
|
||||||
|
|||||||
@@ -1,31 +1,108 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
import { BrowserDialog, WindowDialogAccessor } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||||
import { BrowserSaveFileDialog } from '@/infrastructure/Dialog/Browser/BrowserSaveFileDialog';
|
import { BrowserSaveFileDialog } from '@/infrastructure/Dialog/Browser/BrowserSaveFileDialog';
|
||||||
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
|
||||||
describe('BrowserDialog', () => {
|
describe('BrowserDialog', () => {
|
||||||
describe('saveFile', () => {
|
describe('saveFile', () => {
|
||||||
it('passes correct arguments', () => {
|
it('forwards arguments', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedFileContents = 'test content';
|
const expectedSaveFileArgs = createTestSaveFileArguments();
|
||||||
const expectedFileName = 'test.sh';
|
|
||||||
const expectedFileType = FileType.ShellScript;
|
|
||||||
let actualSaveFileArgs: Parameters<BrowserSaveFileDialog['saveFile']> | undefined;
|
let actualSaveFileArgs: Parameters<BrowserSaveFileDialog['saveFile']> | undefined;
|
||||||
const fileSaverDialogSpy: BrowserSaveFileDialog = {
|
const fileSaverDialogSpy: BrowserSaveFileDialog = {
|
||||||
saveFile: (...args) => {
|
saveFile: (...args) => {
|
||||||
actualSaveFileArgs = args;
|
actualSaveFileArgs = args;
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
|
const browserDialog = new BrowserDialogBuilder()
|
||||||
|
.withBrowserSaveFileDialog(fileSaverDialogSpy)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
|
await browserDialog.saveFile(...expectedSaveFileArgs);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(actualSaveFileArgs)
|
expect(actualSaveFileArgs).to.deep.equal(expectedSaveFileArgs);
|
||||||
.to
|
});
|
||||||
.deep
|
it('forwards outcome', async () => {
|
||||||
.equal([expectedFileContents, expectedFileName, expectedFileType]);
|
// arrange
|
||||||
|
const expectedResult: SaveFileOutcome = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
const fileSaverDialogMock: BrowserSaveFileDialog = {
|
||||||
|
saveFile: () => expectedResult,
|
||||||
|
};
|
||||||
|
const browserDialog = new BrowserDialogBuilder()
|
||||||
|
.withBrowserSaveFileDialog(fileSaverDialogMock)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// act
|
||||||
|
const actualResult = await browserDialog.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(actualResult).to.equal(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('showError', () => {
|
||||||
|
it('alerts with formatted error message', () => {
|
||||||
|
// arrange
|
||||||
|
const errorTitle = 'Expected Error Title';
|
||||||
|
const errorMessage = 'expected error message';
|
||||||
|
const expectedMessage = `❌ ${errorTitle}\n\n${errorMessage}`;
|
||||||
|
let actualShowErrorArgs: Parameters<WindowDialogAccessor['alert']> | undefined;
|
||||||
|
const windowDialogAccessorSpy: WindowDialogAccessor = {
|
||||||
|
alert: (...args) => {
|
||||||
|
actualShowErrorArgs = args;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const browserDialog = new BrowserDialogBuilder()
|
||||||
|
.withWindowDialogAccessor(windowDialogAccessorSpy)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// act
|
||||||
|
browserDialog.showError(errorTitle, errorMessage);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expectExists(actualShowErrorArgs);
|
||||||
|
const [actualMessage] = actualShowErrorArgs;
|
||||||
|
expect(actualMessage).to.equal(expectedMessage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTestSaveFileArguments(): Parameters<BrowserSaveFileDialog['saveFile']> {
|
||||||
|
return [
|
||||||
|
'test file content',
|
||||||
|
'test filename',
|
||||||
|
FileType.ShellScript,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowserDialogBuilder {
|
||||||
|
private browserSaveFileDialog: BrowserSaveFileDialog = {
|
||||||
|
saveFile: () => ({ success: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
private windowDialogAccessor: WindowDialogAccessor = {
|
||||||
|
alert: () => { /* NOOP */ },
|
||||||
|
};
|
||||||
|
|
||||||
|
public withBrowserSaveFileDialog(browserSaveFileDialog: BrowserSaveFileDialog): this {
|
||||||
|
this.browserSaveFileDialog = browserSaveFileDialog;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withWindowDialogAccessor(windowDialogAccessor: WindowDialogAccessor): this {
|
||||||
|
this.windowDialogAccessor = windowDialogAccessor;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build() {
|
||||||
|
return new BrowserDialog(
|
||||||
|
this.windowDialogAccessor,
|
||||||
|
this.browserSaveFileDialog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class SaveFileTestSetup {
|
|||||||
|
|
||||||
private fileContents: string = `${SaveFileTestSetup.name} file contents`;
|
private fileContents: string = `${SaveFileTestSetup.name} file contents`;
|
||||||
|
|
||||||
private fileName: string = `${SaveFileTestSetup.name} file name`;
|
private filename: string = `${SaveFileTestSetup.name} filename`;
|
||||||
|
|
||||||
private fileType: FileType = FileType.BatchFile;
|
private fileType: FileType = FileType.BatchFile;
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ class SaveFileTestSetup {
|
|||||||
);
|
);
|
||||||
return dialog.saveFile(
|
return dialog.saveFile(
|
||||||
this.fileContents,
|
this.fileContents,
|
||||||
this.fileName,
|
this.filename,
|
||||||
this.fileType,
|
this.fileType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,104 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
|
||||||
import { ElectronSaveFileDialog } from '@/infrastructure/Dialog/Electron/ElectronSaveFileDialog';
|
import { ElectronSaveFileDialog } from '@/infrastructure/Dialog/Electron/ElectronSaveFileDialog';
|
||||||
|
import { ElectronDialog, ElectronDialogAccessor } from '@/infrastructure/Dialog/Electron/ElectronDialog';
|
||||||
|
|
||||||
describe('BrowserDialog', () => {
|
describe('ElectronDialog', () => {
|
||||||
describe('saveFile', () => {
|
describe('saveFile', () => {
|
||||||
it('passes correct arguments', async () => {
|
it('forwards arguments', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedFileContents = 'test content';
|
const expectedSaveFileArgs = createTestSaveFileArguments();
|
||||||
const expectedFileName = 'test.sh';
|
|
||||||
const expectedFileType = FileType.ShellScript;
|
|
||||||
let actualSaveFileArgs: Parameters<ElectronSaveFileDialog['saveFile']> | undefined;
|
let actualSaveFileArgs: Parameters<ElectronSaveFileDialog['saveFile']> | undefined;
|
||||||
const fileSaverDialogSpy: ElectronSaveFileDialog = {
|
const fileSaverDialogSpy: ElectronSaveFileDialog = {
|
||||||
saveFile: (...args) => {
|
saveFile: (...args) => {
|
||||||
actualSaveFileArgs = args;
|
actualSaveFileArgs = args;
|
||||||
return Promise.resolve();
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
|
const electronDialog = new ElectronDialogBuilder()
|
||||||
|
.withSaveFileDialog(fileSaverDialogSpy)
|
||||||
|
.build();
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
|
await electronDialog.saveFile(...expectedSaveFileArgs);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(actualSaveFileArgs)
|
expect(actualSaveFileArgs).to.deep.equal(expectedSaveFileArgs);
|
||||||
.to
|
});
|
||||||
.deep
|
it('forwards outcome', async () => {
|
||||||
.equal([expectedFileContents, expectedFileName, expectedFileType]);
|
// arrange
|
||||||
|
const expectedResult: SaveFileOutcome = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
const fileSaverDialogMock: ElectronSaveFileDialog = {
|
||||||
|
saveFile: () => Promise.resolve(expectedResult),
|
||||||
|
};
|
||||||
|
const browserDialog = new ElectronDialogBuilder()
|
||||||
|
.withSaveFileDialog(fileSaverDialogMock)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// act
|
||||||
|
const actualResult = await browserDialog.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(actualResult).to.equal(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('showError', () => {
|
||||||
|
it('forwards arguments', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedShowErrorArguments: Parameters<ElectronDialog['showError']> = [
|
||||||
|
'test title', 'test message',
|
||||||
|
];
|
||||||
|
let actualShowErrorArgs: Parameters<ElectronDialogAccessor['showErrorBox']> | undefined;
|
||||||
|
const electronDialogAccessorSpy: ElectronDialogAccessor = {
|
||||||
|
showErrorBox: (...args) => {
|
||||||
|
actualShowErrorArgs = args;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const electronDialog = new ElectronDialogBuilder()
|
||||||
|
.withElectron(electronDialogAccessorSpy)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// act
|
||||||
|
electronDialog.showError(...expectedShowErrorArguments);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(actualShowErrorArgs).to.deep.equal(expectedShowErrorArguments);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTestSaveFileArguments(): Parameters<ElectronSaveFileDialog['saveFile']> {
|
||||||
|
return [
|
||||||
|
'test file content',
|
||||||
|
'test filename',
|
||||||
|
FileType.ShellScript,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ElectronDialogBuilder {
|
||||||
|
private electron: ElectronDialogAccessor = {
|
||||||
|
showErrorBox: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
private saveFileDialog: ElectronSaveFileDialog = {
|
||||||
|
saveFile: () => Promise.resolve({ success: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
public withElectron(electron: ElectronDialogAccessor): this {
|
||||||
|
this.electron = electron;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withSaveFileDialog(saveFileDialog: ElectronSaveFileDialog): this {
|
||||||
|
this.saveFileDialog = saveFileDialog;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build() {
|
||||||
|
return new ElectronDialog(this.saveFileDialog, this.electron);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { FileType } from '@/presentation/common/Dialog';
|
import { FileType, SaveFileErrorType } from '@/presentation/common/Dialog';
|
||||||
import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||||
@@ -8,14 +8,14 @@ import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperations
|
|||||||
import { NodeFileOperationsStub } from './NodeFileOperationsStub';
|
import { NodeFileOperationsStub } from './NodeFileOperationsStub';
|
||||||
|
|
||||||
describe('NodeElectronSaveFileDialog', () => {
|
describe('NodeElectronSaveFileDialog', () => {
|
||||||
describe('shows dialog with correct options', () => {
|
describe('dialog options', () => {
|
||||||
it('correct title', async () => {
|
it('correct title', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedFileName = 'expected-file-name';
|
const expectedFileName = 'expected-file-name';
|
||||||
const electronMock = new ElectronFileDialogOperationsStub();
|
const electronMock = new ElectronFileDialogOperationsStub();
|
||||||
const context = new SaveFileDialogTestSetup()
|
const context = new SaveFileDialogTestSetup()
|
||||||
.withElectron(electronMock)
|
.withElectron(electronMock)
|
||||||
.withFileName(expectedFileName);
|
.withDefaultFilename(expectedFileName);
|
||||||
// act
|
// act
|
||||||
await context.saveFile();
|
await context.saveFile();
|
||||||
// assert
|
// assert
|
||||||
@@ -52,7 +52,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
|||||||
.withUserDownloadsPath(expectedParentDirectory);
|
.withUserDownloadsPath(expectedParentDirectory);
|
||||||
const context = new SaveFileDialogTestSetup()
|
const context = new SaveFileDialogTestSetup()
|
||||||
.withElectron(electronMock)
|
.withElectron(electronMock)
|
||||||
.withFileName(expectedFileName)
|
.withDefaultFilename(expectedFileName)
|
||||||
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
||||||
// act
|
// act
|
||||||
await context.saveFile();
|
await context.saveFile();
|
||||||
@@ -63,7 +63,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
|||||||
electronMock,
|
electronMock,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
describe('correct filters', () => {
|
describe('correct file type filters', () => {
|
||||||
const defaultFilter: Electron.FileFilter = {
|
const defaultFilter: Electron.FileFilter = {
|
||||||
name: 'All Files',
|
name: 'All Files',
|
||||||
extensions: ['*'],
|
extensions: ['*'],
|
||||||
@@ -109,92 +109,224 @@ describe('NodeElectronSaveFileDialog', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('saves the file when the dialog is not canceled', () => {
|
describe('file saving process', () => {
|
||||||
it('writes to the selected file path', async () => {
|
describe('when dialog is confirmed', () => {
|
||||||
// arrange
|
it('writes to the selected file path', async () => {
|
||||||
const expectedFilePath = 'expected-file-path';
|
// arrange
|
||||||
const isCancelled = false;
|
const expectedFilePath = 'expected-file-path';
|
||||||
const electronMock = new ElectronFileDialogOperationsStub()
|
const isCancelled = false;
|
||||||
.withMimicUserCancel(isCancelled)
|
const electronMock = new ElectronFileDialogOperationsStub()
|
||||||
.withUserSelectedFilePath(expectedFilePath);
|
.withMimicUserCancel(isCancelled)
|
||||||
const nodeMock = new NodeFileOperationsStub();
|
.withUserSelectedFilePath(expectedFilePath);
|
||||||
const context = new SaveFileDialogTestSetup()
|
const nodeMock = new NodeFileOperationsStub();
|
||||||
.withElectron(electronMock)
|
const context = new SaveFileDialogTestSetup()
|
||||||
.withNode(nodeMock);
|
.withElectron(electronMock)
|
||||||
|
.withNode(nodeMock);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.saveFile();
|
await context.saveFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||||
expect(saveFileCalls).to.have.lengthOf(1);
|
expect(saveFileCalls).to.have.lengthOf(1);
|
||||||
const [actualFilePath] = saveFileCalls[0].args;
|
const [actualFilePath] = saveFileCalls[0].args;
|
||||||
expect(actualFilePath).to.equal(expectedFilePath);
|
expect(actualFilePath).to.equal(expectedFilePath);
|
||||||
|
});
|
||||||
|
it('writes the correct file contents', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedFileContents = 'expected-file-contents';
|
||||||
|
const isCancelled = false;
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub()
|
||||||
|
.withMimicUserCancel(isCancelled);
|
||||||
|
const nodeMock = new NodeFileOperationsStub();
|
||||||
|
const context = new SaveFileDialogTestSetup()
|
||||||
|
.withElectron(electronMock)
|
||||||
|
.withFileContents(expectedFileContents)
|
||||||
|
.withNode(nodeMock);
|
||||||
|
|
||||||
|
// act
|
||||||
|
await context.saveFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||||
|
expect(saveFileCalls).to.have.lengthOf(1);
|
||||||
|
const [,actualFileContents] = saveFileCalls[0].args;
|
||||||
|
expect(actualFileContents).to.equal(expectedFileContents);
|
||||||
|
});
|
||||||
|
it('returns success status', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedSuccessValue = true;
|
||||||
|
const context = new SaveFileDialogTestSetup();
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { success } = await context.saveFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(success).to.equal(expectedSuccessValue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
describe('when dialog is canceled', async () => {
|
||||||
|
it('does not save file', async () => {
|
||||||
|
// arrange
|
||||||
|
const isCancelled = true;
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub()
|
||||||
|
.withMimicUserCancel(isCancelled);
|
||||||
|
const nodeMock = new NodeFileOperationsStub();
|
||||||
|
const context = new SaveFileDialogTestSetup()
|
||||||
|
.withElectron(electronMock)
|
||||||
|
.withNode(nodeMock);
|
||||||
|
|
||||||
it('writes the correct file contents', async () => {
|
// act
|
||||||
// arrange
|
await context.saveFile();
|
||||||
const expectedFileContents = 'expected-file-contents';
|
|
||||||
const isCancelled = false;
|
|
||||||
const electronMock = new ElectronFileDialogOperationsStub()
|
|
||||||
.withMimicUserCancel(isCancelled);
|
|
||||||
const nodeMock = new NodeFileOperationsStub();
|
|
||||||
const context = new SaveFileDialogTestSetup()
|
|
||||||
.withElectron(electronMock)
|
|
||||||
.withFileContents(expectedFileContents)
|
|
||||||
.withNode(nodeMock);
|
|
||||||
|
|
||||||
// act
|
// assert
|
||||||
await context.saveFile();
|
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
|
||||||
|
expect(saveFileCall).to.equal(undefined);
|
||||||
|
});
|
||||||
|
it('logs cancelation info', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedLogMessagePart = 'File save cancelled';
|
||||||
|
const logger = new LoggerStub();
|
||||||
|
const isCancelled = true;
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub()
|
||||||
|
.withMimicUserCancel(isCancelled);
|
||||||
|
const context = new SaveFileDialogTestSetup()
|
||||||
|
.withElectron(electronMock)
|
||||||
|
.withLogger(logger);
|
||||||
|
|
||||||
// assert
|
// act
|
||||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
await context.saveFile();
|
||||||
expect(saveFileCalls).to.have.lengthOf(1);
|
|
||||||
const [,actualFileContents] = saveFileCalls[0].args;
|
// assert
|
||||||
expect(actualFileContents).to.equal(expectedFileContents);
|
logger.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||||
|
});
|
||||||
|
it('returns success', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedSuccessValue = true;
|
||||||
|
const isCancelled = true;
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub()
|
||||||
|
.withMimicUserCancel(isCancelled);
|
||||||
|
const context = new SaveFileDialogTestSetup()
|
||||||
|
.withElectron(electronMock);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const { success } = await context.saveFile();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(success).to.equal(expectedSuccessValue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not save file when dialog is canceled', async () => {
|
describe('error handling', () => {
|
||||||
// arrange
|
const testScenarios: ReadonlyArray<{
|
||||||
const isCancelled = true;
|
readonly description: string;
|
||||||
const electronMock = new ElectronFileDialogOperationsStub()
|
readonly expectedErrorType: SaveFileErrorType;
|
||||||
.withMimicUserCancel(isCancelled);
|
readonly expectedErrorMessage: string;
|
||||||
const nodeMock = new NodeFileOperationsStub();
|
buildFaultyContext(
|
||||||
const context = new SaveFileDialogTestSetup()
|
setup: SaveFileDialogTestSetup,
|
||||||
.withElectron(electronMock)
|
errorMessage: string,
|
||||||
.withNode(nodeMock);
|
): SaveFileDialogTestSetup;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
description: 'file writing failure',
|
||||||
|
expectedErrorType: 'FileCreationError',
|
||||||
|
expectedErrorMessage: 'Error when writing file',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||||
|
const nodeMock = new NodeFileOperationsStub();
|
||||||
|
nodeMock.writeFile = () => Promise.reject(new Error(errorMessage));
|
||||||
|
return setup
|
||||||
|
.withElectron(electronMock)
|
||||||
|
.withNode(nodeMock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'user path retrieval failure',
|
||||||
|
expectedErrorType: 'DialogDisplayError',
|
||||||
|
expectedErrorMessage: 'Error when retrieving user path',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||||
|
electronMock.getUserDownloadsPath = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup
|
||||||
|
.withElectron(electronMock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'path combination failure',
|
||||||
|
expectedErrorType: 'DialogDisplayError',
|
||||||
|
expectedErrorMessage: 'Error when combining paths',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const nodeMock = new NodeFileOperationsStub();
|
||||||
|
nodeMock.join = () => {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
};
|
||||||
|
return setup
|
||||||
|
.withNode(nodeMock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'dialog display failure',
|
||||||
|
expectedErrorType: 'DialogDisplayError',
|
||||||
|
expectedErrorMessage: 'Error when showing save dialog',
|
||||||
|
buildFaultyContext: (setup, errorMessage) => {
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||||
|
electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage));
|
||||||
|
return setup
|
||||||
|
.withElectron(electronMock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'unexpected dialog return value failure',
|
||||||
|
expectedErrorType: 'DialogDisplayError',
|
||||||
|
expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.',
|
||||||
|
buildFaultyContext: (setup) => {
|
||||||
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||||
|
electronMock.showSaveDialog = () => Promise.resolve({
|
||||||
|
canceled: false,
|
||||||
|
filePath: undefined,
|
||||||
|
});
|
||||||
|
return setup
|
||||||
|
.withElectron(electronMock);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||||
|
}) => {
|
||||||
|
it(`handles error - ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new SaveFileDialogTestSetup(),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await context.saveFile();
|
const { success, error } = await context.saveFile();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
|
expect(success).to.equal(false);
|
||||||
expect(saveFileCall).to.equal(undefined);
|
expectExists(error);
|
||||||
});
|
expect(error.message).to.include(expectedErrorMessage);
|
||||||
|
expect(error.type).to.equal(expectedErrorType);
|
||||||
|
});
|
||||||
|
it(`logs error: ${description}`, async () => {
|
||||||
|
// arrange
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = buildFaultyContext(
|
||||||
|
new SaveFileDialogTestSetup()
|
||||||
|
.withLogger(loggerStub),
|
||||||
|
expectedErrorMessage,
|
||||||
|
);
|
||||||
|
|
||||||
describe('logging', () => {
|
// act
|
||||||
it('logs an error if writing the file fails', async () => {
|
await context.saveFile();
|
||||||
// arrange
|
|
||||||
const expectedErrorMessage = 'Injected write error';
|
|
||||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
|
||||||
const nodeMock = new NodeFileOperationsStub();
|
|
||||||
nodeMock.writeFile = () => Promise.reject(new Error(expectedErrorMessage));
|
|
||||||
const loggerStub = new LoggerStub();
|
|
||||||
const context = new SaveFileDialogTestSetup()
|
|
||||||
.withElectron(electronMock)
|
|
||||||
.withNode(nodeMock)
|
|
||||||
.withLogger(loggerStub);
|
|
||||||
|
|
||||||
// act
|
// assert
|
||||||
await context.saveFile();
|
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||||
|
});
|
||||||
// assert
|
|
||||||
const errorCalls = loggerStub.callHistory.filter((c) => c.methodName === 'error');
|
|
||||||
expect(errorCalls.length).to.equal(1);
|
|
||||||
const errorCall = errorCalls[0];
|
|
||||||
const [errorMessage] = errorCall.args;
|
|
||||||
expect(errorMessage).to.include(expectedErrorMessage);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -202,7 +334,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
|||||||
class SaveFileDialogTestSetup {
|
class SaveFileDialogTestSetup {
|
||||||
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
|
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
|
||||||
|
|
||||||
private fileName = `${SaveFileDialogTestSetup.name} file name`;
|
private filename = `${SaveFileDialogTestSetup.name} filename`;
|
||||||
|
|
||||||
private fileType = FileType.BatchFile;
|
private fileType = FileType.BatchFile;
|
||||||
|
|
||||||
@@ -227,8 +359,8 @@ class SaveFileDialogTestSetup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public withFileName(fileName: string): this {
|
public withDefaultFilename(defaultFilename: string): this {
|
||||||
this.fileName = fileName;
|
this.filename = defaultFilename;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +378,7 @@ class SaveFileDialogTestSetup {
|
|||||||
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node);
|
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node);
|
||||||
return dialog.saveFile(
|
return dialog.saveFile(
|
||||||
this.fileContents,
|
this.fileContents,
|
||||||
this.fileName,
|
this.filename,
|
||||||
this.fileType,
|
this.fileType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
163
tests/unit/infrastructure/Dialog/LoggingDialogDecorator.spec.ts
Normal file
163
tests/unit/infrastructure/Dialog/LoggingDialogDecorator.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { decorateWithLogging } from '@/infrastructure/Dialog/LoggingDialogDecorator';
|
||||||
|
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
|
||||||
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||||
|
|
||||||
|
describe('LoggingDialogDecorator', () => {
|
||||||
|
describe('decorateWithLogging', () => {
|
||||||
|
describe('saveFile', () => {
|
||||||
|
it('delegates call to dialog', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedArguments = createTestSaveFileArguments();
|
||||||
|
const dialog = new DialogStub();
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withDialog(dialog);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
await decorator.saveFile(...expectedArguments);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(dialog.callHistory).to.have.lengthOf(1);
|
||||||
|
const call = dialog.callHistory.find((c) => c.methodName === 'saveFile');
|
||||||
|
expectExists(call);
|
||||||
|
const actualArguments = call.args;
|
||||||
|
expect(expectedArguments).to.deep.equal(actualArguments);
|
||||||
|
});
|
||||||
|
it('returns dialog\'s response', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedResult: SaveFileOutcome = { success: true };
|
||||||
|
const dialog = new DialogStub();
|
||||||
|
dialog.saveFile = () => Promise.resolve(expectedResult);
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withDialog(dialog);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
const actualResult = await decorator.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(expectedResult).to.equal(actualResult);
|
||||||
|
});
|
||||||
|
it('logs information on invocation', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedLogMessagePart = 'Opening save file dialog';
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withLogger(loggerStub);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
await decorator.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||||
|
});
|
||||||
|
it('logs information on success', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedLogMessagePart = 'completed successfully';
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withLogger(loggerStub);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
await decorator.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||||
|
});
|
||||||
|
it('logs error on save failure', async () => {
|
||||||
|
// arrange
|
||||||
|
const expectedLogMessagePart = 'Error encountered';
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const dialog = new DialogStub();
|
||||||
|
dialog.saveFile = () => Promise.resolve({ success: false, error: { message: 'error', type: 'DialogDisplayError' } });
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withLogger(loggerStub);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
await decorator.saveFile(...createTestSaveFileArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('error', expectedLogMessagePart);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('showError', () => {
|
||||||
|
it('delegates call to the dialog', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedArguments = createTestShowErrorArguments();
|
||||||
|
const dialog = new DialogStub();
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withDialog(dialog);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
decorator.showError(...expectedArguments);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(dialog.callHistory).to.have.lengthOf(1);
|
||||||
|
const call = dialog.callHistory.find((c) => c.methodName === 'showError');
|
||||||
|
expectExists(call);
|
||||||
|
const actualArguments = call.args;
|
||||||
|
expect(expectedArguments).to.deep.equal(actualArguments);
|
||||||
|
});
|
||||||
|
it('logs error message', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedLogMessagePart = 'Showing error dialog';
|
||||||
|
const loggerStub = new LoggerStub();
|
||||||
|
const context = new LoggingDialogDecoratorTestSetup()
|
||||||
|
.withLogger(loggerStub);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const decorator = context.decorateWithLogging();
|
||||||
|
decorator.showError(...createTestShowErrorArguments());
|
||||||
|
|
||||||
|
// assert
|
||||||
|
loggerStub.assertLogsContainMessagePart('error', expectedLogMessagePart);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class LoggingDialogDecoratorTestSetup {
|
||||||
|
private dialog: Dialog = new DialogStub();
|
||||||
|
|
||||||
|
private logger: Logger = new LoggerStub();
|
||||||
|
|
||||||
|
public withDialog(dialog: Dialog): this {
|
||||||
|
this.dialog = dialog;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public withLogger(logger: Logger): this {
|
||||||
|
this.logger = logger;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decorateWithLogging() {
|
||||||
|
return decorateWithLogging(
|
||||||
|
this.dialog,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestSaveFileArguments(): Parameters<Dialog['saveFile']> {
|
||||||
|
return [
|
||||||
|
'test-file-contents',
|
||||||
|
'test-default-filename',
|
||||||
|
FileType.BatchFile,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestShowErrorArguments(): Parameters<Dialog['showError']> {
|
||||||
|
return [
|
||||||
|
'test-error-title',
|
||||||
|
'test-error-message',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/AppInitializationLogger';
|
import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/AppInitializationLogger';
|
||||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||||
|
|
||||||
@@ -11,8 +11,6 @@ describe('AppInitializationLogger', () => {
|
|||||||
// act
|
// act
|
||||||
await sut.bootstrap();
|
await sut.bootstrap();
|
||||||
// assert
|
// assert
|
||||||
expect(loggerStub.callHistory).to.have.lengthOf(1);
|
loggerStub.assertLogsContainMessagePart('info', marker);
|
||||||
expect(loggerStub.callHistory[0].args).to.have.lengthOf(1);
|
|
||||||
expect(loggerStub.callHistory[0].args[0]).to.include(marker);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +1,94 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { determineDialogBasedOnEnvironment, WindowDialogCreationFunction, BrowserDialogCreationFunction } from '@/presentation/components/Shared/Hooks/Dialog/ClientDialogFactory';
|
import {
|
||||||
|
createEnvironmentSpecificLoggedDialog, WindowDialogCreationFunction,
|
||||||
|
BrowserDialogCreationFunction, DialogLoggingDecorator,
|
||||||
|
} from '@/presentation/components/Shared/Hooks/Dialog/ClientDialogFactory';
|
||||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||||
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
|
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
|
||||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||||
|
import { Dialog } from '@/presentation/common/Dialog';
|
||||||
|
|
||||||
describe('ClientDialogFactory', () => {
|
describe('ClientDialogFactory', () => {
|
||||||
describe('determineDialogBasedOnEnvironment', () => {
|
describe('createEnvironmentSpecificLoggedDialog', () => {
|
||||||
describe('non-desktop environment', () => {
|
describe('dialog selection based on environment', () => {
|
||||||
it('returns browser dialog', () => {
|
describe('when in non-desktop environment', () => {
|
||||||
// arrange
|
it('provides a browser dialog', () => {
|
||||||
const expectedDialog = new DialogStub();
|
// arrange
|
||||||
const context = new DialogCreationTestSetup()
|
const expectedDialog = new DialogStub();
|
||||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
const context = new DialogCreationTestSetup()
|
||||||
.withBrowserDialogFactory(() => expectedDialog);
|
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
||||||
|
.withBrowserDialogFactory(() => expectedDialog);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualDialog = context.createDialogForTest();
|
const actualDialog = context.createDialogForTest();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(expectedDialog).to.equal(actualDialog);
|
expect(expectedDialog).to.equal(actualDialog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('when in desktop environment', () => {
|
||||||
|
it('provides a window-injected dialog', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedDialog = new DialogStub();
|
||||||
|
const context = new DialogCreationTestSetup()
|
||||||
|
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||||
|
.withWindowInjectedDialogFactory(() => expectedDialog);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const actualDialog = context.createDialogForTest();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(expectedDialog).to.equal(actualDialog);
|
||||||
|
});
|
||||||
|
it('throws error if window-injected dialog is not available', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedError = 'Failed to retrieve Dialog API from window object in desktop environment.';
|
||||||
|
const context = new DialogCreationTestSetup()
|
||||||
|
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||||
|
.withWindowInjectedDialogFactory(() => undefined);
|
||||||
|
|
||||||
|
// act
|
||||||
|
const act = () => context.createDialogForTest();
|
||||||
|
|
||||||
|
// assert
|
||||||
|
const actualError = collectExceptionMessage(act);
|
||||||
|
expect(actualError).to.include(expectedError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('desktop environment', () => {
|
describe('dialog decoration with logging', () => {
|
||||||
it('returns window-injected dialog', () => {
|
it('returns a dialog decorated with logging', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedDialog = new DialogStub();
|
const expectedLoggingDialogStub = new DialogStub();
|
||||||
|
const decoratorStub: DialogLoggingDecorator = () => expectedLoggingDialogStub;
|
||||||
const context = new DialogCreationTestSetup()
|
const context = new DialogCreationTestSetup()
|
||||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
.withDialogLoggingDecorator(decoratorStub);
|
||||||
.withWindowInjectedDialogFactory(() => expectedDialog);
|
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const actualDialog = context.createDialogForTest();
|
const actualDialog = context.createDialogForTest();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
expect(expectedDialog).to.equal(actualDialog);
|
expect(expectedLoggingDialogStub).to.equal(actualDialog);
|
||||||
});
|
});
|
||||||
it('throws error when window-injected dialog is unavailable', () => {
|
it('applies logging decorator to the provided dialog', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const expectedError = 'The Dialog API could not be retrieved from the window object.';
|
const expectedDialog = new DialogStub();
|
||||||
|
let actualDecoratedDialog: Dialog | undefined;
|
||||||
|
const decoratorStub: DialogLoggingDecorator = (dialog) => {
|
||||||
|
actualDecoratedDialog = dialog;
|
||||||
|
return new DialogStub();
|
||||||
|
};
|
||||||
const context = new DialogCreationTestSetup()
|
const context = new DialogCreationTestSetup()
|
||||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
||||||
.withWindowInjectedDialogFactory(() => undefined);
|
.withBrowserDialogFactory(() => expectedDialog)
|
||||||
|
.withDialogLoggingDecorator(decoratorStub);
|
||||||
|
|
||||||
// act
|
// act
|
||||||
const act = () => context.createDialogForTest();
|
context.createDialogForTest();
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
const actualError = collectExceptionMessage(act);
|
expect(expectedDialog).to.equal(actualDecoratedDialog);
|
||||||
expect(actualError).to.include(expectedError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -61,6 +101,8 @@ class DialogCreationTestSetup {
|
|||||||
|
|
||||||
private windowInjectedDialogFactory: WindowDialogCreationFunction = () => new DialogStub();
|
private windowInjectedDialogFactory: WindowDialogCreationFunction = () => new DialogStub();
|
||||||
|
|
||||||
|
private dialogLoggingDecorator: DialogLoggingDecorator = (dialog) => dialog;
|
||||||
|
|
||||||
public withEnvironment(environment: RuntimeEnvironment): this {
|
public withEnvironment(environment: RuntimeEnvironment): this {
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
return this;
|
return this;
|
||||||
@@ -78,9 +120,17 @@ class DialogCreationTestSetup {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public withDialogLoggingDecorator(
|
||||||
|
dialogLoggingDecorator: DialogLoggingDecorator,
|
||||||
|
): this {
|
||||||
|
this.dialogLoggingDecorator = dialogLoggingDecorator;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public createDialogForTest() {
|
public createDialogForTest() {
|
||||||
return determineDialogBasedOnEnvironment(
|
return createEnvironmentSpecificLoggedDialog(
|
||||||
this.environment,
|
this.environment,
|
||||||
|
this.dialogLoggingDecorator,
|
||||||
this.windowInjectedDialogFactory,
|
this.windowInjectedDialogFactory,
|
||||||
this.browserDialogFactory,
|
this.browserDialogFactory,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
import { CodeRunOutcome, CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||||
|
|
||||||
export class CodeRunnerStub implements CodeRunner {
|
export class CodeRunnerStub implements CodeRunner {
|
||||||
public runCode(): Promise<void> {
|
public runCode(): Promise<CodeRunOutcome> {
|
||||||
return Promise.resolve();
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { Dialog } from '@/presentation/common/Dialog';
|
import { Dialog, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||||
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class DialogStub implements Dialog {
|
export class DialogStub
|
||||||
public saveFile(): Promise<void> {
|
extends StubWithObservableMethodCalls<Dialog>
|
||||||
return Promise.resolve();
|
implements Dialog {
|
||||||
|
public saveFile(...args: Parameters<Dialog['saveFile']>): Promise<SaveFileOutcome> {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'saveFile',
|
||||||
|
args: [...args],
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public showError(...args: Parameters<Dialog['showError']>): void {
|
||||||
|
this.registerMethodCall({
|
||||||
|
methodName: 'showError',
|
||||||
|
args: [...args],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator';
|
import { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator';
|
||||||
import { ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
import { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class FilenameGeneratorStub
|
export class FilenameGeneratorStub
|
||||||
@@ -7,7 +7,7 @@ export class FilenameGeneratorStub
|
|||||||
implements FilenameGenerator {
|
implements FilenameGenerator {
|
||||||
private filename = `[${FilenameGeneratorStub.name}]file-name-stub`;
|
private filename = `[${FilenameGeneratorStub.name}]file-name-stub`;
|
||||||
|
|
||||||
public generateFilename(scriptFileNameParts: ScriptFileNameParts): string {
|
public generateFilename(scriptFileNameParts: ScriptFilenameParts): string {
|
||||||
this.registerMethodCall({
|
this.registerMethodCall({
|
||||||
methodName: 'generateFilename',
|
methodName: 'generateFilename',
|
||||||
args: [scriptFileNameParts],
|
args: [scriptFileNameParts],
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Logger } from '@/application/Common/Log/Logger';
|
import { Logger } from '@/application/Common/Log/Logger';
|
||||||
|
import { FunctionKeys, isString } from '@/TypeHelpers';
|
||||||
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements Logger {
|
export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements Logger {
|
||||||
@@ -29,4 +31,27 @@ export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements
|
|||||||
args: params,
|
args: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public assertLogsContainMessagePart(
|
||||||
|
methodName: FunctionKeys<Logger>,
|
||||||
|
expectedLogMessagePart: string,
|
||||||
|
) {
|
||||||
|
const loggedMessages = this.getLoggedMessages(methodName);
|
||||||
|
expect(
|
||||||
|
loggedMessages.some((m) => m.includes(expectedLogMessagePart)),
|
||||||
|
formatAssertionMessage([
|
||||||
|
`Log function: ${methodName}`,
|
||||||
|
`Expected log message part: ${expectedLogMessagePart}`,
|
||||||
|
'Actual log messages:',
|
||||||
|
loggedMessages.join('\n- '),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLoggedMessages(methodName: FunctionKeys<Logger>): string[] {
|
||||||
|
const calls = this.callHistory.filter((m) => m.methodName === methodName);
|
||||||
|
const loggedItems = calls.flatMap((call) => call.args);
|
||||||
|
const stringLogs = loggedItems.filter((message): message is string => isString(message));
|
||||||
|
return stringLogs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
import { ScriptDirectoryOutcome, ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||||
|
|
||||||
export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
||||||
private directoryPath = `[${ScriptDirectoryProviderStub.name}]scriptDirectory`;
|
private directoryPath = `[${ScriptDirectoryProviderStub.name}]scriptDirectory`;
|
||||||
@@ -8,7 +8,10 @@ export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public provideScriptDirectory(): Promise<string> {
|
public provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||||
return Promise.resolve(this.directoryPath);
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
directoryAbsolutePath: this.directoryPath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ScriptFileCreator, ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
import { ScriptFileCreationOutcome, ScriptFileCreator, ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class ScriptFileCreatorStub
|
export class ScriptFileCreatorStub
|
||||||
@@ -13,12 +13,15 @@ export class ScriptFileCreatorStub
|
|||||||
|
|
||||||
public createScriptFile(
|
public createScriptFile(
|
||||||
contents: string,
|
contents: string,
|
||||||
scriptFileNameParts: ScriptFileNameParts,
|
scriptFileNameParts: ScriptFilenameParts,
|
||||||
): Promise<string> {
|
): Promise<ScriptFileCreationOutcome> {
|
||||||
this.registerMethodCall({
|
this.registerMethodCall({
|
||||||
methodName: 'createScriptFile',
|
methodName: 'createScriptFile',
|
||||||
args: [contents, scriptFileNameParts],
|
args: [contents, scriptFileNameParts],
|
||||||
});
|
});
|
||||||
return Promise.resolve(this.createdFilePath);
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
scriptFileAbsolutePath: this.createdFilePath,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
import { ScriptFileExecutionOutcome, ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||||
|
|
||||||
export class ScriptFileExecutorStub
|
export class ScriptFileExecutorStub
|
||||||
extends StubWithObservableMethodCalls<ScriptFileExecutor>
|
extends StubWithObservableMethodCalls<ScriptFileExecutor>
|
||||||
implements ScriptFileExecutor {
|
implements ScriptFileExecutor {
|
||||||
public executeScriptFile(filePath: string): Promise<void> {
|
public executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||||
this.registerMethodCall({
|
this.registerMethodCall({
|
||||||
methodName: 'executeScriptFile',
|
methodName: 'executeScriptFile',
|
||||||
args: [filePath],
|
args: [filePath],
|
||||||
});
|
});
|
||||||
return Promise.resolve();
|
return Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user