Fix script cancellation with new dialog on Linux
This commit improves the management of script execution process by enhancing the way terminal commands are handled, paving the way for easier future modifications and providing clearer feedback to users when scripts are cancelled. Previously, the UI displayed a generic error message which could lead to confusion if the user intentionally cancelled the script execution. Now, a specific error dialog will appear, improving the user experience by accurately reflecting the action taken by the user. This change affects code execution on Linux where closing GNOME terminal returns exit code `137` which is then treated by script cancellation by privacy.sexy to show the accurate error dialog. It does not affect macOS and Windows as curret commands result in success (`0`) exit code on cancellation. Additionally, this update encapsulates OS-specific logic into dedicated classes, promoting better separation of concerns and increasing the modularity of the codebase. This makes it simpler to maintain and extend the application. Key changes: - Display a specific error message for script cancellations. - Refactor command execution into dedicated classes. - Improve file permission setting flexibility and avoid setting file permissions on Windows as it's not required to execute files. - Introduce more granular error types for script execution. - Increase logging for shell commands to aid in debugging. - Expand test coverage to ensure reliability. - Fix error dialogs not showing the error messages due to incorrect propagation of errors. Other supported changes: - Update `SECURITY.md` with details on script readback and verification. - Fix a typo in `IpcRegistration.spec.ts`. - Document antivirus scans in `desktop-vs-web-features.md`.
This commit is contained in:
15
SECURITY.md
15
SECURITY.md
@@ -43,10 +43,17 @@ privacy.sexy adopts a defense in depth strategy to protect users on multiple lay
|
||||
elevation of privileges for system modifications with explicit user consent and logs every action taken with high privileges. This
|
||||
approach actively minimizes potential security risks by limiting privileged operations and aligning with the principle of least privilege.
|
||||
- **Secure Script Execution/Storage:**
|
||||
Before executing any script, the desktop application stores a copy to allow antivirus software to perform scans. This safeguards against
|
||||
any unwanted modifications. Furthermore, the application incorporates integrity checks for tamper protection. If the script file differs from
|
||||
the user's selected script, the application will not execute or save the script, ensuring the processing of authentic scripts.
|
||||
Recognizing that some users prefer not to keep these records, privacy.sexy provides specialized scripts for deletion of these scripts.
|
||||
- **Antivirus scans:**
|
||||
Before executing any script, the desktop application stores a copy to allow antivirus software to perform scans.
|
||||
This step allows confirming that the scripts are secure and safe to use.
|
||||
- **Tamper protection:**
|
||||
The application incorporates integrity checks for tamper protection.
|
||||
If the script file differs from the user's selected script, the application will not execute or save the script, ensuring the processing
|
||||
of authentic scripts.
|
||||
This safeguards against any unwanted modifications.
|
||||
- **Clean-up:**
|
||||
Recognizing that some users prefer not to keep these records, privacy.sexy provides specialized scripts for deletion of these scripts.
|
||||
This allows users to maintain their privacy by removing traces of their usage patterns or script preferences.
|
||||
|
||||
### Update Security and Integrity
|
||||
|
||||
|
||||
@@ -8,10 +8,8 @@ This table outlines the differences between the desktop and web versions of `pri
|
||||
| [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available |
|
||||
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
|
||||
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
|
||||
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
|
||||
| [Error handling](#error-handling) | 🟢 Advanced | 🟡 Limited |
|
||||
| [Native dialogs](#native-dialogs) | 🟢 Available | 🔴 Not available |
|
||||
| [Secure script execution/storage](#secure-script-executionstorage) | 🟢 Available | 🔴 Not available |
|
||||
| [Native dialogs](#native-dialogs) | 🟢 Available | 🔴 Not available |
|
||||
|
||||
## Feature descriptions
|
||||
|
||||
@@ -53,7 +51,7 @@ Log file locations vary by operating system:
|
||||
|
||||
> 💡 privacy.sexy provides scripts to securely erase these logs.
|
||||
|
||||
### Script execution
|
||||
### Secure script execution/storage
|
||||
|
||||
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.
|
||||
@@ -69,31 +67,27 @@ These locations vary based on the operating system:
|
||||
|
||||
> 💡 privacy.sexy provides scripts to securely erase your script execution history.
|
||||
|
||||
### Error handling
|
||||
**Script antivirus scans:**
|
||||
|
||||
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.
|
||||
To enhance system protection, the desktop version of privacy.sexy automatically verifies the security of script
|
||||
execution files by reading them back.
|
||||
This process triggers antivirus scans to verify that scripts are safe before the execution.
|
||||
|
||||
### 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.
|
||||
|
||||
### Secure script execution/storage
|
||||
|
||||
**Integrity checks:**
|
||||
**Script integrity checks:**
|
||||
|
||||
The desktop version of privacy.sexy implements robust integrity checks for both script execution and storage.
|
||||
Featuring tamper protection, the application actively verifies the integrity of script files before executing or saving them.
|
||||
If the actual contents of a script file do not align with the expected contents, the application refuses to execute or save the script.
|
||||
This proactive approach ensures only unaltered and verified scripts undergo processing, thereby enhancing both security and reliability.
|
||||
Due to browser constraints, this feature is absent in the web version.
|
||||
|
||||
**Error handling:**
|
||||
|
||||
The desktop version of privacy.sexy features advanced error handling capabilities.
|
||||
In scenarios where script execution or storage encounters failure, the desktop application initiates automated troubleshooting and self-healing processes.
|
||||
It also guides users through potential issues with filesystem or third-party software, such as antivirus interventions.
|
||||
Specifically, the application is capable of identifying when antivirus software blocks or removes a script, providing users with tailored error messages
|
||||
and detailed resolution steps. This level of proactive error handling and user guidance enhances the application's security and reliability,
|
||||
offering a feature not achievable in the web version due to browser limitations.
|
||||
It employs robust and reliable execution strategies, including self-healing mechanisms, and provides guidance and troubleshooting information to resolve issues effectively.
|
||||
This proactive error handling and user guidance enhances the application's security and reliability.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -11,10 +11,11 @@ export type CodeRunErrorType =
|
||||
| 'FileWriteError'
|
||||
| 'FileReadbackVerificationError'
|
||||
| 'FilePathGenerationError'
|
||||
| 'UnsupportedOperatingSystem'
|
||||
| 'FileExecutionError'
|
||||
| 'UnsupportedPlatform'
|
||||
| 'DirectoryCreationError'
|
||||
| 'UnexpectedError';
|
||||
| 'FilePermissionChangeError'
|
||||
| 'FileExecutionError'
|
||||
| 'ExternalProcessTermination';
|
||||
|
||||
interface CodeRunStatus {
|
||||
readonly success: boolean;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface CommandDefinition {
|
||||
buildShellCommand(filePath: string): string;
|
||||
isExecutionTerminatedExternally(exitCode: number): boolean;
|
||||
isExecutablePermissionsRequiredOnFile(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PosixShellArgumentEscaper } from './ShellArgument/PosixShellArgumentEscaper';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
|
||||
|
||||
export const LinuxTerminalEmulator = 'x-terminal-emulator';
|
||||
|
||||
export class LinuxVisibleTerminalCommand implements CommandDefinition {
|
||||
constructor(
|
||||
private readonly escaper: ShellArgumentEscaper = new PosixShellArgumentEscaper(),
|
||||
) { }
|
||||
|
||||
public buildShellCommand(filePath: string): string {
|
||||
return `${LinuxTerminalEmulator} -e ${this.escaper.escapePathArgument(filePath)}`;
|
||||
/*
|
||||
🤔 Potential improvements:
|
||||
Use user-friendly GUI sudo prompt (not terminal-based).
|
||||
If `pkexec` exists, we could do `x-terminal-emulator -e pkexec 'path'`, which always
|
||||
prompts with user-friendly GUI sudo prompt.
|
||||
📝 Options:
|
||||
`x-terminal-emulator -e 'path'`:
|
||||
✅ Visible terminal window
|
||||
❌ Terminal-based (not GUI) sudo prompt.
|
||||
`x-terminal-emulator -e pkexec 'path'
|
||||
✅ Visible terminal window
|
||||
✅ Always prompts with user-friendly GUI sudo prompt.
|
||||
🤔 Not using `pkexec` as it is not in all Linux distributions. It should have smarter
|
||||
logic to handle if it does not exist.
|
||||
`electron.shell.openPath`:
|
||||
❌ Opens the script in the default text editor, verified on
|
||||
Debian/Ubuntu-based distributions.
|
||||
`child_process.execFile()`:
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
*/
|
||||
}
|
||||
|
||||
public isExecutionTerminatedExternally(exitCode: number): boolean {
|
||||
return exitCode === 137;
|
||||
/*
|
||||
`x-terminal-emulator` may return exit code `137` under specific circumstances like when the
|
||||
user closes the terminal (observed with `gnome-terminal` on Pop!_OS). This exit code (128 +
|
||||
Unix signal 9) indicates the process was terminated by a SIGKILL signal, which can occur due
|
||||
to user action (cancelling the progress) or the system (e.g., due to memory shortages).
|
||||
|
||||
Additional exit codes noted for future consideration (currently not handled as they have not
|
||||
been reproduced):
|
||||
|
||||
- 130 (130 = 128 + Unix signal 2): Indicates the script was terminated by the user
|
||||
(Control-C), corresponding to a SIGINT signal.
|
||||
- 143 (128 + Unix signal 15): Indicates termination by a SIGTERM signal, suggesting a request
|
||||
to gracefully terminate the process.
|
||||
*/
|
||||
}
|
||||
|
||||
public isExecutablePermissionsRequiredOnFile(): boolean {
|
||||
/*
|
||||
On Linux, a script file without executable permissions cannot be run directly by its path
|
||||
without specifying a shell explicitly.
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { PosixShellArgumentEscaper } from './ShellArgument/PosixShellArgumentEscaper';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
|
||||
|
||||
export class MacOsVisibleTerminalCommand implements CommandDefinition {
|
||||
constructor(
|
||||
private readonly escaper: ShellArgumentEscaper = new PosixShellArgumentEscaper(),
|
||||
) { }
|
||||
|
||||
public buildShellCommand(filePath: string): string {
|
||||
return `open -a Terminal.app ${this.escaper.escapePathArgument(filePath)}`;
|
||||
/*
|
||||
📝 Options:
|
||||
`child_process.execFile()`
|
||||
"path", `cmd.exe /c "path"`
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
This occurs only when the user runs the application as administrator, as seen
|
||||
in Windows Pro VMs on Azure.
|
||||
`PowerShell Start -Verb RunAs "path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
`PowerShell Start "path"`
|
||||
`explorer.exe "path"`
|
||||
`electron.shell.openPath`
|
||||
`start cmd.exe /c "$path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
👍 Among all options `start` command is the most explicit one, being the most resilient
|
||||
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
|
||||
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
|
||||
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
*/
|
||||
}
|
||||
|
||||
public isExecutionTerminatedExternally(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public isExecutablePermissionsRequiredOnFile(): boolean {
|
||||
/*
|
||||
On macOS, a script file without executable permissions cannot be run directly by its path
|
||||
without specifying a shell explicitly.
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ShellArgumentEscaper } from './ShellArgumentEscaper';
|
||||
|
||||
export class CmdShellArgumentEscaper implements ShellArgumentEscaper {
|
||||
public escapePathArgument(pathArgument: string): string {
|
||||
return cmdShellPathArgumentEscape(pathArgument);
|
||||
}
|
||||
}
|
||||
|
||||
function cmdShellPathArgumentEscape(pathArgument: string): string {
|
||||
// - Encloses the path in double quotes, which is necessary for Windows command line (cmd.exe)
|
||||
// to correctly handle paths containing spaces.
|
||||
// - Paths in Windows cannot include double quotes `"` themselves, so these are not escaped.
|
||||
return `"${pathArgument}"`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ShellArgumentEscaper } from './ShellArgumentEscaper';
|
||||
|
||||
export class PosixShellArgumentEscaper implements ShellArgumentEscaper {
|
||||
public escapePathArgument(pathArgument: string): string {
|
||||
return posixShellPathArgumentEscape(pathArgument);
|
||||
}
|
||||
}
|
||||
|
||||
function posixShellPathArgumentEscape(pathArgument: string): string {
|
||||
/*
|
||||
- Wraps the path in single quotes, which is a standard practice in POSIX shells
|
||||
(like bash and zsh) found on macOS/Linux to ensure that characters like spaces, '*', and
|
||||
'?' are treated as literals, not as special characters.
|
||||
- Escapes any single quotes within the path itself. This allows paths containing single
|
||||
quotes to be correctly interpreted in POSIX-compliant systems, such as Linux and macOS.
|
||||
*/
|
||||
return `'${pathArgument.replaceAll('\'', '\'\\\'\'')}'`;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ShellArgumentEscaper {
|
||||
escapePathArgument(pathArgument: string): string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CmdShellArgumentEscaper } from './ShellArgument/CmdShellArgumentEscaper';
|
||||
import type { ShellArgumentEscaper } from './ShellArgument/ShellArgumentEscaper';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
|
||||
export class WindowsVisibleTerminalCommand implements CommandDefinition {
|
||||
constructor(
|
||||
private readonly escaper: ShellArgumentEscaper = new CmdShellArgumentEscaper(),
|
||||
) { }
|
||||
|
||||
public buildShellCommand(filePath: string): string {
|
||||
const command = [
|
||||
'PowerShell',
|
||||
'Start-Process',
|
||||
'-Verb RunAs', // Run as administrator with GUI sudo prompt
|
||||
`-FilePath ${this.escaper.escapePathArgument(filePath)}`,
|
||||
].join(' ');
|
||||
return command;
|
||||
/*
|
||||
📝 Options:
|
||||
`child_process.execFile()`
|
||||
"path", `cmd.exe /c "path"`
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
This occurs only when the user runs the application as administrator, as seen
|
||||
in Windows Pro VMs on Azure.
|
||||
`PowerShell Start -Verb RunAs "path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
`PowerShell Start "path"`
|
||||
`explorer.exe "path"`
|
||||
`electron.shell.openPath`
|
||||
`start cmd.exe /c "$path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
👍 Among all options `start` command is the most explicit one, being the most resilient
|
||||
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
|
||||
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
|
||||
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
*/
|
||||
}
|
||||
|
||||
public isExecutionTerminatedExternally(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public isExecutablePermissionsRequiredOnFile(): boolean {
|
||||
/*
|
||||
In Windows, whether a file can be executed is determined by its file extension
|
||||
(.exe, .bat, .cmd, etc.) rather than executable permissions set on the file.
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
|
||||
export interface CommandDefinitionFactory {
|
||||
provideCommandDefinition(): CommandDefinition;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { WindowsVisibleTerminalCommand } from '../Commands/WindowsVisibleTerminalCommand';
|
||||
import { LinuxVisibleTerminalCommand } from '../Commands/LinuxVisibleTerminalCommand';
|
||||
import { MacOsVisibleTerminalCommand } from '../Commands/MacOsVisibleTerminalCommand';
|
||||
import type { CommandDefinitionFactory } from './CommandDefinitionFactory';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
|
||||
export class OsSpecificTerminalLaunchCommandFactory implements CommandDefinitionFactory {
|
||||
constructor(
|
||||
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
||||
) { }
|
||||
|
||||
public provideCommandDefinition(): CommandDefinition {
|
||||
const { os } = this.environment;
|
||||
if (os === undefined) {
|
||||
throw new Error('Operating system could not be identified from environment.');
|
||||
}
|
||||
return getOperatingSystemCommandDefinition(os);
|
||||
}
|
||||
}
|
||||
|
||||
function getOperatingSystemCommandDefinition(
|
||||
operatingSystem: OperatingSystem,
|
||||
): CommandDefinition {
|
||||
const definition = SupportedDesktopCommandDefinitions[operatingSystem];
|
||||
if (!definition) {
|
||||
throw new Error(`Unsupported operating system: ${OperatingSystem[operatingSystem]}`);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
const SupportedDesktopCommandDefinitions: Readonly<Partial<Record<
|
||||
OperatingSystem,
|
||||
CommandDefinition>>> = {
|
||||
[OperatingSystem.Windows]: new WindowsVisibleTerminalCommand(),
|
||||
[OperatingSystem.Linux]: new LinuxVisibleTerminalCommand(),
|
||||
[OperatingSystem.macOS]: new MacOsVisibleTerminalCommand(),
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ScriptFileExecutionOutcome } from '../../ScriptFileExecutor';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
|
||||
export interface CommandDefinitionRunner {
|
||||
runCommandDefinition(
|
||||
commandDefinition: CommandDefinition,
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { FileSystemExecutablePermissionSetter } from './PermissionSetter/FileSystemExecutablePermissionSetter';
|
||||
import { LoggingNodeShellCommandRunner } from './ShellRunner/LoggingNodeShellCommandRunner';
|
||||
import type { FailedScriptFileExecution, ScriptFileExecutionOutcome } from '../../ScriptFileExecutor';
|
||||
import type { CommandDefinition } from '../CommandDefinition';
|
||||
import type { CommandDefinitionRunner } from './CommandDefinitionRunner';
|
||||
import type { ExecutablePermissionSetter } from './PermissionSetter/ExecutablePermissionSetter';
|
||||
import type { ShellCommandOutcome, ShellCommandRunner } from './ShellRunner/ShellCommandRunner';
|
||||
|
||||
export class ExecutableFileShellCommandDefinitionRunner implements CommandDefinitionRunner {
|
||||
constructor(
|
||||
private readonly executablePermissionSetter: ExecutablePermissionSetter
|
||||
= new FileSystemExecutablePermissionSetter(),
|
||||
private readonly shellCommandRunner: ShellCommandRunner
|
||||
= new LoggingNodeShellCommandRunner(),
|
||||
) { }
|
||||
|
||||
public async runCommandDefinition(
|
||||
commandDefinition: CommandDefinition,
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
if (commandDefinition.isExecutablePermissionsRequiredOnFile()) {
|
||||
const filePermissionsResult = await this.executablePermissionSetter
|
||||
.makeFileExecutable(filePath);
|
||||
if (!filePermissionsResult.success) {
|
||||
return filePermissionsResult;
|
||||
}
|
||||
}
|
||||
const command = commandDefinition.buildShellCommand(filePath);
|
||||
const shellOutcome = await this.shellCommandRunner.runShellCommand(command);
|
||||
return interpretShellOutcome(shellOutcome, commandDefinition);
|
||||
}
|
||||
}
|
||||
|
||||
function interpretShellOutcome(
|
||||
outcome: ShellCommandOutcome,
|
||||
commandDefinition: CommandDefinition,
|
||||
): ScriptFileExecutionOutcome {
|
||||
switch (outcome.type) {
|
||||
case 'RegularProcessExit':
|
||||
if (outcome.exitCode === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
if (commandDefinition.isExecutionTerminatedExternally(outcome.exitCode)) {
|
||||
return createFailureOutcome(
|
||||
'ExternalProcessTermination',
|
||||
`Process terminated externally: Exit code ${outcome.exitCode}.`,
|
||||
);
|
||||
}
|
||||
return createFailureOutcome(
|
||||
'FileExecutionError',
|
||||
`Unexpected exit code: ${outcome.exitCode}.`,
|
||||
);
|
||||
case 'ExternallyTerminated':
|
||||
return createFailureOutcome(
|
||||
'ExternalProcessTermination',
|
||||
`Process terminated by signal ${outcome.terminationSignal}.`,
|
||||
);
|
||||
case 'ExecutionError':
|
||||
return createFailureOutcome(
|
||||
'FileExecutionError',
|
||||
`Execution error: ${outcome.error.message}.`,
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unknown outcome type: ${outcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createFailureOutcome(
|
||||
type: CodeRunErrorType,
|
||||
errorMessage: string,
|
||||
): FailedScriptFileExecution {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type,
|
||||
message: `Error during command execution: ${errorMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
|
||||
export interface ExecutablePermissionSetter {
|
||||
makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
|
||||
|
||||
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
public async makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||
/*
|
||||
This is required on macOS and Linux otherwise the terminal emulators will refuse to
|
||||
execute the script. It's not needed on Windows.
|
||||
*/
|
||||
try {
|
||||
this.logger.info(`Setting execution permissions for file at ${filePath}`);
|
||||
await this.system.fileSystem.setFilePermissions(filePath, '755');
|
||||
this.logger.info(`Execution permissions set successfully for ${filePath}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'FilePermissionChangeError',
|
||||
message: `Error setting script file permission: ${error.message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
||||
import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunner';
|
||||
|
||||
export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(),
|
||||
) {
|
||||
}
|
||||
|
||||
public runShellCommand(command: string): Promise<ShellCommandOutcome> {
|
||||
this.logger.info(`Executing command: ${command}`);
|
||||
return new Promise((resolve) => {
|
||||
this.systemOps.command.exec(command)
|
||||
// https://archive.today/2024.01.19-004011/https://nodejs.org/api/child_process.html#child_process_event_exit
|
||||
.on('exit', (
|
||||
code, // The exit code if the child exited on its own.
|
||||
signal, // The signal by which the child process was terminated.
|
||||
) => {
|
||||
// One of `code` or `signal` will always be non-null.
|
||||
// If the process exited, code is the final exit code of the process, otherwise null.
|
||||
if (code !== null) {
|
||||
this.logger.info(`Command completed with exit code ${code}.`);
|
||||
resolve({ type: 'RegularProcessExit', exitCode: code });
|
||||
return; // Prevent further execution to avoid multiple promise resolutions and logs.
|
||||
}
|
||||
// If the process terminated due to receipt of a signal, signal is the string name of
|
||||
// the signal, otherwise null.
|
||||
resolve({ type: 'ExternallyTerminated', terminationSignal: signal as NodeJS.Signals });
|
||||
this.logger.warn(`Command terminated by signal: ${signal}`);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
// https://archive.ph/20200912193803/https://nodejs.org/api/child_process.html#child_process_event_error
|
||||
// The 'error' event is emitted whenever:
|
||||
// - The process could not be spawned, or
|
||||
// - The process could not be killed, or
|
||||
// - Sending a message to the child process failed.
|
||||
// The 'exit' event may or may not fire after an error has occurred.
|
||||
this.logger.error('Command execution failed:', error);
|
||||
resolve({ type: 'ExecutionError', error });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface ShellCommandRunner {
|
||||
runShellCommand(command: string): Promise<ShellCommandOutcome>;
|
||||
}
|
||||
|
||||
export type ShellCommandOutcome = ProcessStatus & ({
|
||||
readonly type: 'RegularProcessExit',
|
||||
readonly exitCode: number;
|
||||
} | {
|
||||
readonly type: 'ExternallyTerminated';
|
||||
readonly terminationSignal: NodeJS.Signals;
|
||||
} | {
|
||||
readonly type: 'ExecutionError';
|
||||
readonly error: Error;
|
||||
});
|
||||
|
||||
type ProcessOutcomeType = 'RegularProcessExit' | 'ExternallyTerminated' | 'ExecutionError';
|
||||
|
||||
interface ProcessStatus {
|
||||
readonly type: ProcessOutcomeType;
|
||||
readonly error?: Error;
|
||||
readonly terminationSignal?: NodeJS.Signals;
|
||||
readonly exitCode?: number;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { OsSpecificTerminalLaunchCommandFactory } from './CommandDefinition/Factory/OsSpecificTerminalLaunchCommandFactory';
|
||||
import { ExecutableFileShellCommandDefinitionRunner } from './CommandDefinition/Runner/ExecutableFileShellCommandDefinitionRunner';
|
||||
import type { ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
|
||||
import type { CommandDefinitionFactory } from './CommandDefinition/Factory/CommandDefinitionFactory';
|
||||
import type { CommandDefinitionRunner } from './CommandDefinition/Runner/CommandDefinitionRunner';
|
||||
import type { CommandDefinition } from './CommandDefinition/CommandDefinition';
|
||||
|
||||
export class VisibleTerminalFileRunner implements ScriptFileExecutor {
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly commandFactory: CommandDefinitionFactory
|
||||
= new OsSpecificTerminalLaunchCommandFactory(),
|
||||
private readonly commandRunner: CommandDefinitionRunner
|
||||
= new ExecutableFileShellCommandDefinitionRunner(),
|
||||
) { }
|
||||
|
||||
public async executeScriptFile(
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
this.logger.info(`Executing script file: ${filePath}.`);
|
||||
const outcome = await this.findAndExecuteCommand(filePath);
|
||||
this.logOutcome(outcome);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async findAndExecuteCommand(
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
try {
|
||||
let commandDefinition: CommandDefinition;
|
||||
try {
|
||||
commandDefinition = this.commandFactory.provideCommandDefinition();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'UnsupportedPlatform',
|
||||
message: `Error finding command: ${error.message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const runOutcome = await this.commandRunner.runCommandDefinition(
|
||||
commandDefinition,
|
||||
filePath,
|
||||
);
|
||||
return runOutcome;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'FileExecutionError',
|
||||
message: `Unexpected error: ${error.message}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private logOutcome(outcome: ScriptFileExecutionOutcome) {
|
||||
if (outcome.success) {
|
||||
this.logger.info('Executed script file in terminal successfully.');
|
||||
return;
|
||||
}
|
||||
this.logger.error(
|
||||
'Failed to execute the script file in terminal.',
|
||||
outcome.error.type,
|
||||
outcome.error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import type { CommandOps, SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { NodeElectronSystemOperations } from '@/infrastructure/CodeRunner/System/NodeElectronSystemOperations';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { isString } from '@/TypeHelpers';
|
||||
import type { FailedScriptFileExecution, ScriptFileExecutionOutcome, ScriptFileExecutor } from './ScriptFileExecutor';
|
||||
|
||||
export class VisibleTerminalScriptExecutor implements ScriptFileExecutor {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
||||
) { }
|
||||
|
||||
public async executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||
const { os } = this.environment;
|
||||
if (os === undefined) {
|
||||
return this.handleError('UnsupportedOperatingSystem', 'Operating system could not be identified from environment.');
|
||||
}
|
||||
const filePermissionsResult = await this.setFileExecutablePermissions(filePath);
|
||||
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<ScriptFileExecutionOutcome> {
|
||||
/*
|
||||
This is required on macOS and Linux otherwise the terminal emulators will refuse to
|
||||
execute the script. It's not needed on Windows.
|
||||
*/
|
||||
try {
|
||||
this.logger.info(`Setting execution permissions for file at ${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<ScriptFileExecutionOutcome> {
|
||||
this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`);
|
||||
const runner = TerminalRunners[os];
|
||||
if (!runner) {
|
||||
return this.handleError('UnsupportedOperatingSystem', `Unsupported operating system: ${OperatingSystem[os]}`);
|
||||
}
|
||||
const context: TerminalExecutionContext = {
|
||||
scriptFilePath: filePath,
|
||||
commandOps: this.system.command,
|
||||
logger: this.logger,
|
||||
};
|
||||
try {
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface TerminalExecutionContext {
|
||||
readonly scriptFilePath: string;
|
||||
readonly commandOps: CommandOps;
|
||||
readonly logger: Logger;
|
||||
}
|
||||
|
||||
type TerminalRunner = (context: TerminalExecutionContext) => Promise<void>;
|
||||
|
||||
export const LinuxTerminalEmulator = 'x-terminal-emulator';
|
||||
|
||||
const TerminalRunners: Partial<Record<OperatingSystem, TerminalRunner>> = {
|
||||
[OperatingSystem.Windows]: async (context) => {
|
||||
const command = [
|
||||
'PowerShell',
|
||||
'Start-Process',
|
||||
'-Verb RunAs', // Run as administrator with GUI sudo prompt
|
||||
`-FilePath ${cmdShellPathArgumentEscape(context.scriptFilePath)}`,
|
||||
].join(' ');
|
||||
/*
|
||||
📝 Options:
|
||||
`child_process.execFile()`
|
||||
"path", `cmd.exe /c "path"`
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
This occurs only when the user runs the application as administrator, as seen
|
||||
in Windows Pro VMs on Azure.
|
||||
`PowerShell Start -Verb RunAs "path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
`PowerShell Start "path"`
|
||||
`explorer.exe "path"`
|
||||
`electron.shell.openPath`
|
||||
`start cmd.exe /c "$path"`
|
||||
✅ Visible terminal window
|
||||
✅ GUI sudo prompt (through `RunAs` option)
|
||||
👍 Among all options `start` command is the most explicit one, being the most resilient
|
||||
against the potential changes in Windows or Electron framework (e.g. https://github.com/electron/electron/issues/36765).
|
||||
`%COMSPEC%` environment variable should be checked before defaulting to `cmd.exe.
|
||||
Related docs: https://web.archive.org/web/20240106002357/https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
*/
|
||||
await runCommand(command, context);
|
||||
},
|
||||
[OperatingSystem.Linux]: async (context) => {
|
||||
const command = `${LinuxTerminalEmulator} -e ${posixShellPathArgumentEscape(context.scriptFilePath)}`;
|
||||
/*
|
||||
🤔 Potential improvements:
|
||||
Use user-friendly GUI sudo prompt (not terminal-based).
|
||||
If `pkexec` exists, we could do `x-terminal-emulator -e pkexec 'path'`, which always
|
||||
prompts with user-friendly GUI sudo prompt.
|
||||
📝 Options:
|
||||
`x-terminal-emulator -e 'path'`:
|
||||
✅ Visible terminal window
|
||||
❌ Terminal-based (not GUI) sudo prompt.
|
||||
`x-terminal-emulator -e pkexec 'path'
|
||||
✅ Visible terminal window
|
||||
✅ Always prompts with user-friendly GUI sudo prompt.
|
||||
🤔 Not using `pkexec` as it is not in all Linux distributions. It should have smarter
|
||||
logic to handle if it does not exist.
|
||||
`electron.shell.openPath`:
|
||||
❌ Opens the script in the default text editor, verified on
|
||||
Debian/Ubuntu-based distributions.
|
||||
`child_process.execFile()`:
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
*/
|
||||
await runCommand(command, context);
|
||||
},
|
||||
[OperatingSystem.macOS]: async (context) => {
|
||||
const command = `open -a Terminal.app ${posixShellPathArgumentEscape(context.scriptFilePath)}`;
|
||||
// -a Specifies the application to use for opening the file
|
||||
/* eslint-disable vue/max-len */
|
||||
/*
|
||||
🤔 Potential improvements:
|
||||
Use user-friendly GUI sudo prompt for running the script.
|
||||
📝 Options:
|
||||
`open -a Terminal.app 'path'`
|
||||
✅ Visible terminal window
|
||||
❌ Terminal-based (not GUI) sudo prompt.
|
||||
❌ Terminal app requires many privileges to execute the script, this prompts user
|
||||
to grant privileges to the Terminal app.
|
||||
`osascript -e 'do shell script "'/tmp/test.sh'" with administrator privileges'`
|
||||
✅ Script as root
|
||||
✅ GUI sudo prompt.
|
||||
❌ Script execution in the background without a visible terminal.
|
||||
`osascript -e 'do shell script "open -a 'Terminal.app' '/tmp/test.sh'" with administrator privileges'`
|
||||
❌ Script as user, not root
|
||||
✅ GUI sudo prompt.
|
||||
✅ Visible terminal window
|
||||
`osascript -e 'do shell script "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal '/tmp/test.sh'" with administrator privileges'`
|
||||
✅ Script as root
|
||||
✅ GUI sudo prompt.
|
||||
✅ Visible terminal window
|
||||
Useful resources about `do shell script .. with administrator privileges`:
|
||||
- Change "osascript wants to make changes" prompt: https://web.archive.org/web/20240109191128/https://apple.stackexchange.com/questions/283353/how-to-rename-osascript-in-the-administrator-privileges-dialog
|
||||
- More about `do shell script`: https://web.archive.org/web/20100906222226/http://developer.apple.com/mac/library/technotes/tn2002/tn2065.html
|
||||
*/
|
||||
/* eslint-enable vue/max-len */
|
||||
await runCommand(command, context);
|
||||
},
|
||||
} as const;
|
||||
|
||||
async function runCommand(command: string, context: TerminalExecutionContext): Promise<void> {
|
||||
context.logger.info(`Executing command:\n${command}`);
|
||||
await context.commandOps.exec(command);
|
||||
context.logger.info('Executed command successfully.');
|
||||
}
|
||||
|
||||
function posixShellPathArgumentEscape(pathArgument: string): string {
|
||||
/*
|
||||
- Wraps the path in single quotes, which is a standard practice in POSIX shells
|
||||
(like bash and zsh) found on macOS/Linux to ensure that characters like spaces, '*', and
|
||||
'?' are treated as literals, not as special characters.
|
||||
- Escapes any single quotes within the path itself. This allows paths containing single
|
||||
quotes to be correctly interpreted in POSIX-compliant systems, such as Linux and macOS.
|
||||
*/
|
||||
return `'${pathArgument.replaceAll('\'', '\'\\\'\'')}'`;
|
||||
}
|
||||
|
||||
function cmdShellPathArgumentEscape(pathArgument: string): string {
|
||||
// - Encloses the path in double quotes, which is necessary for Windows command line (cmd.exe)
|
||||
// to correctly handle paths containing spaces.
|
||||
// - Paths in Windows cannot include double quotes `"` themselves, so these are not escaped.
|
||||
return `"${pathArgument}"`;
|
||||
}
|
||||
@@ -4,15 +4,15 @@ import type {
|
||||
CodeRunError, CodeRunOutcome, CodeRunner, FailedCodeRun,
|
||||
} from '@/application/CodeRunner/CodeRunner';
|
||||
import { ElectronLogger } from '../Log/ElectronLogger';
|
||||
import { VisibleTerminalScriptExecutor } from './Execution/VisibleTerminalScriptFileExecutor';
|
||||
import { ScriptFileCreationOrchestrator } from './Creation/ScriptFileCreationOrchestrator';
|
||||
import { VisibleTerminalFileRunner } from './Execution/VisibleTerminalFileRunner';
|
||||
import type { ScriptFileExecutor } from './Execution/ScriptFileExecutor';
|
||||
import type { ScriptFileCreator } from './Creation/ScriptFileCreator';
|
||||
|
||||
export class ScriptFileCodeRunner implements CodeRunner {
|
||||
constructor(
|
||||
private readonly scriptFileExecutor
|
||||
: ScriptFileExecutor = new VisibleTerminalScriptExecutor(),
|
||||
: ScriptFileExecutor = new VisibleTerminalFileRunner(),
|
||||
private readonly scriptFileCreator: ScriptFileCreator = new ScriptFileCreationOrchestrator(),
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
|
||||
} from './SystemOperations';
|
||||
|
||||
/**
|
||||
* Thin wrapper for Node and Electron APIs.
|
||||
*/
|
||||
export class NodeElectronSystemOperations implements SystemOperations {
|
||||
public readonly operatingSystem: OperatingSystemOps = {
|
||||
/*
|
||||
@@ -49,13 +52,6 @@ export class NodeElectronSystemOperations implements SystemOperations {
|
||||
};
|
||||
|
||||
public readonly command: CommandOps = {
|
||||
exec: (command) => new Promise((resolve, reject) => {
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
exec,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { exec } from 'node:child_process';
|
||||
|
||||
export interface SystemOperations {
|
||||
readonly operatingSystem: OperatingSystemOps;
|
||||
readonly location: LocationOps;
|
||||
@@ -14,7 +16,7 @@ export interface LocationOps {
|
||||
}
|
||||
|
||||
export interface CommandOps {
|
||||
exec(command: string): Promise<void>;
|
||||
exec(command: string): ReturnType<typeof exec>;
|
||||
}
|
||||
|
||||
export interface FileSystemOps {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
|
||||
import IconButton from './IconButton.vue';
|
||||
import { createScriptErrorDialog } from './ScriptErrorDialog';
|
||||
|
||||
@@ -38,15 +39,19 @@ export default defineComponent({
|
||||
currentContext.state.collection.scripting.fileExtension,
|
||||
);
|
||||
if (!success) {
|
||||
dialog.showError(...(await createScriptErrorDialog({
|
||||
errorContext: 'run',
|
||||
errorType: error.type,
|
||||
errorMessage: error.message,
|
||||
isFileReadbackError: error.type === 'FileReadbackVerificationError',
|
||||
}, scriptDiagnosticsCollector)));
|
||||
await handleCodeRunFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeRunFailure(error: CodeRunError) {
|
||||
dialog.showError(...(await createScriptErrorDialog({
|
||||
errorContext: 'run',
|
||||
errorType: error.type,
|
||||
errorMessage: error.message,
|
||||
isFileReadbackError: error.type === 'FileReadbackVerificationError',
|
||||
}, scriptDiagnosticsCollector)));
|
||||
}
|
||||
|
||||
return {
|
||||
canRun,
|
||||
runCode,
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import type { Dialog } from '@/presentation/common/Dialog';
|
||||
import type { Dialog, SaveFileErrorType } from '@/presentation/common/Dialog';
|
||||
|
||||
type ErrorDialogParameters = Parameters<Dialog['showError']>;
|
||||
|
||||
export async function createScriptErrorDialog(
|
||||
information: ScriptErrorDetails,
|
||||
scriptDiagnosticsCollector: ScriptDiagnosticsCollector | undefined,
|
||||
): Promise<Parameters<Dialog['showError']>> {
|
||||
): Promise<ErrorDialogParameters> {
|
||||
const diagnostics = await scriptDiagnosticsCollector?.collectDiagnosticInformation();
|
||||
if (information.isFileReadbackError) {
|
||||
return createAntivirusErrorDialog(information, diagnostics);
|
||||
}
|
||||
if (information.errorContext === 'run'
|
||||
&& information.errorType === 'ExternalProcessTermination') {
|
||||
return createScriptInterruptedDialog(information);
|
||||
}
|
||||
return createGenericErrorDialog(information, diagnostics);
|
||||
}
|
||||
|
||||
export interface ScriptErrorDetails {
|
||||
readonly errorContext: 'run' | 'save';
|
||||
readonly errorType: string;
|
||||
readonly errorType: CodeRunErrorType | SaveFileErrorType;
|
||||
readonly errorMessage: string;
|
||||
readonly isFileReadbackError: boolean;
|
||||
}
|
||||
@@ -23,7 +30,7 @@ export interface ScriptErrorDetails {
|
||||
function createGenericErrorDialog(
|
||||
information: ScriptErrorDetails,
|
||||
diagnostics: ScriptDiagnosticData | undefined,
|
||||
): Parameters<Dialog['showError']> {
|
||||
): ErrorDialogParameters {
|
||||
return [
|
||||
selectBasedOnErrorContext({
|
||||
runningScript: 'Error Running Script',
|
||||
@@ -66,7 +73,7 @@ function createGenericErrorDialog(
|
||||
function createAntivirusErrorDialog(
|
||||
information: ScriptErrorDetails,
|
||||
diagnostics: ScriptDiagnosticData | undefined,
|
||||
): Parameters<Dialog['showError']> {
|
||||
): ErrorDialogParameters {
|
||||
const defenderSteps = generateDefenderSteps(information, diagnostics);
|
||||
return [
|
||||
'Possible Antivirus Script Block',
|
||||
@@ -117,6 +124,33 @@ function createAntivirusErrorDialog(
|
||||
];
|
||||
}
|
||||
|
||||
function createScriptInterruptedDialog(
|
||||
information: ScriptErrorDetails,
|
||||
): ErrorDialogParameters {
|
||||
return [
|
||||
'Script Stopped',
|
||||
[
|
||||
'The script stopped before it could finish.',
|
||||
'This happens if the script is cancelled manually or if the system terminates the process.',
|
||||
'\n',
|
||||
generateUnorderedSolutionList({
|
||||
title: 'To ensure successful script completion:',
|
||||
solutions: [
|
||||
'Keep the terminal window open during script execution.',
|
||||
'If the script closed unexpectedly, try running it again.',
|
||||
'Check for sufficient memory (RAM) and system resources.',
|
||||
'Avoid running tasks that might disrupt the script.',
|
||||
],
|
||||
}),
|
||||
'\n',
|
||||
'If you intentionally stopped the script, ignore this message.',
|
||||
'Reach out to the community for further assistance.',
|
||||
'\n',
|
||||
generateTechnicalDetails(information),
|
||||
].join('\n'),
|
||||
];
|
||||
}
|
||||
|
||||
interface SolutionListOptions {
|
||||
readonly solutions: readonly string[];
|
||||
readonly title: string;
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Crea
|
||||
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/LinuxVisibleTerminalCommand';
|
||||
|
||||
describe('ScriptFileCodeRunner', () => {
|
||||
it('executes simple script correctly', async ({ skip }) => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LinuxVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/LinuxVisibleTerminalCommand';
|
||||
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
|
||||
import { ShellArgumentEscaperStub } from '@tests/unit/shared/Stubs/ShellArgumentEscaperStub';
|
||||
|
||||
describe('LinuxVisibleTerminalCommand', () => {
|
||||
describe('buildShellCommand', () => {
|
||||
it('returns expected command for given escaped file path', () => {
|
||||
// arrange
|
||||
const escapedFilePath = '/escaped/file/path';
|
||||
const expectedCommand = `x-terminal-emulator -e ${escapedFilePath}`;
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
escaper.escapePathArgument = () => escapedFilePath;
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
const actualCommand = sut.buildShellCommand('unimportant');
|
||||
// assert
|
||||
expect(actualCommand).to.equal(expectedCommand);
|
||||
});
|
||||
it('escapes provided file path correctly', () => {
|
||||
// arrange
|
||||
const expectedFilePath = '/input';
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
sut.buildShellCommand(expectedFilePath);
|
||||
// assert
|
||||
expect(escaper.callHistory).to.have.lengthOf(1);
|
||||
const [actualFilePath] = escaper.callHistory[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
});
|
||||
describe('isExecutionTerminatedExternally', () => {
|
||||
const testScenarios: readonly {
|
||||
readonly givenExitCode: number;
|
||||
readonly expectedResult: boolean;
|
||||
}[] = [
|
||||
{
|
||||
givenExitCode: 137,
|
||||
expectedResult: true,
|
||||
},
|
||||
];
|
||||
testScenarios.forEach((
|
||||
{ givenExitCode, expectedResult },
|
||||
) => {
|
||||
it(`returns ${expectedResult} for exit code ${givenExitCode}`, () => {
|
||||
// arrange
|
||||
const expectedValue = true;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutionTerminatedExternally(givenExitCode);
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('isExecutablePermissionsRequiredOnFile', () => {
|
||||
it('returns true', () => {
|
||||
// arrange
|
||||
const expectedValue = true;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutablePermissionsRequiredOnFile();
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class CommandBuilder {
|
||||
private escaper: ShellArgumentEscaper = new ShellArgumentEscaperStub();
|
||||
|
||||
public withEscaper(escaper: ShellArgumentEscaper): this {
|
||||
this.escaper = escaper;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): LinuxVisibleTerminalCommand {
|
||||
return new LinuxVisibleTerminalCommand(
|
||||
this.escaper,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MacOsVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/MacOsVisibleTerminalCommand';
|
||||
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
|
||||
import { ShellArgumentEscaperStub } from '@tests/unit/shared/Stubs/ShellArgumentEscaperStub';
|
||||
|
||||
describe('MacOsVisibleTerminalCommand', () => {
|
||||
describe('buildShellCommand', () => {
|
||||
it('returns expected command for given escaped file path', () => {
|
||||
// arrange
|
||||
const escapedFilePath = '/escaped/file/path';
|
||||
const expectedCommand = `open -a Terminal.app ${escapedFilePath}`;
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
escaper.escapePathArgument = () => escapedFilePath;
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
const actualCommand = sut.buildShellCommand('unimportant');
|
||||
// assert
|
||||
expect(actualCommand).to.equal(expectedCommand);
|
||||
});
|
||||
it('escapes provided file path correctly', () => {
|
||||
// arrange
|
||||
const expectedFilePath = '/input';
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
sut.buildShellCommand(expectedFilePath);
|
||||
// assert
|
||||
expect(escaper.callHistory).to.have.lengthOf(1);
|
||||
const [actualFilePath] = escaper.callHistory[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
});
|
||||
describe('isExecutionTerminatedExternally', () => {
|
||||
it('returns `false`', () => {
|
||||
// arrange
|
||||
const expectedValue = false;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutionTerminatedExternally();
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
describe('isExecutablePermissionsRequiredOnFile', () => {
|
||||
it('returns `true`', () => {
|
||||
// arrange
|
||||
const expectedValue = true;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutablePermissionsRequiredOnFile();
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class CommandBuilder {
|
||||
private escaper: ShellArgumentEscaper = new ShellArgumentEscaperStub();
|
||||
|
||||
public withEscaper(escaper: ShellArgumentEscaper): this {
|
||||
this.escaper = escaper;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): MacOsVisibleTerminalCommand {
|
||||
return new MacOsVisibleTerminalCommand(
|
||||
this.escaper,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe } from 'vitest';
|
||||
import { CmdShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/CmdShellArgumentEscaper';
|
||||
import { runEscapeTests } from './ShellArgumentEscaperTestRunner';
|
||||
|
||||
describe('CmdShellArgumentEscaper', () => {
|
||||
runEscapeTests(() => new CmdShellArgumentEscaper(), [
|
||||
{
|
||||
description: 'encloses the path in double quotes',
|
||||
givenPath: 'C:\\Program Files\\app.exe',
|
||||
expectedPath: '"C:\\Program Files\\app.exe"',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe } from 'vitest';
|
||||
import { PosixShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/PosixShellArgumentEscaper';
|
||||
import { runEscapeTests } from './ShellArgumentEscaperTestRunner';
|
||||
|
||||
describe('PosixShellArgumentEscaper', () => {
|
||||
runEscapeTests(() => new PosixShellArgumentEscaper(), [
|
||||
{
|
||||
description: 'encloses the path in quotes',
|
||||
givenPath: '/usr/local/bin',
|
||||
expectedPath: '\'/usr/local/bin\'',
|
||||
},
|
||||
{
|
||||
description: 'escapes single quotes in path',
|
||||
givenPath: 'f\'i\'le',
|
||||
expectedPath: '\'f\'\\\'\'i\'\\\'\'le\'',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
|
||||
|
||||
export function runEscapeTests(
|
||||
escaperFactory: () => ShellArgumentEscaper,
|
||||
testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly givenPath: string;
|
||||
readonly expectedPath: string;
|
||||
}>,
|
||||
) {
|
||||
testScenarios.forEach(({
|
||||
description, givenPath, expectedPath,
|
||||
}) => {
|
||||
it(description, () => {
|
||||
// arrange
|
||||
const escaper = escaperFactory();
|
||||
// act
|
||||
const actualPath = escaper.escapePathArgument(givenPath);
|
||||
// assert
|
||||
expect(actualPath).to.equal(expectedPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WindowsVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/WindowsVisibleTerminalCommand';
|
||||
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
|
||||
import { ShellArgumentEscaperStub } from '@tests/unit/shared/Stubs/ShellArgumentEscaperStub';
|
||||
|
||||
describe('WindowsVisibleTerminalCommand', () => {
|
||||
describe('buildShellCommand', () => {
|
||||
it('returns expected command for given escaped file path', () => {
|
||||
// arrange
|
||||
const escapedFilePath = '/escaped/file/path';
|
||||
const expectedCommand = `PowerShell Start-Process -Verb RunAs -FilePath ${escapedFilePath}`;
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
escaper.escapePathArgument = () => escapedFilePath;
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
const actualCommand = sut.buildShellCommand('unimportant');
|
||||
// assert
|
||||
expect(actualCommand).to.equal(expectedCommand);
|
||||
});
|
||||
it('escapes provided file path correctly', () => {
|
||||
// arrange
|
||||
const expectedFilePath = '/input';
|
||||
const escaper = new ShellArgumentEscaperStub();
|
||||
const sut = new CommandBuilder()
|
||||
.withEscaper(escaper)
|
||||
.build();
|
||||
// act
|
||||
sut.buildShellCommand(expectedFilePath);
|
||||
// assert
|
||||
expect(escaper.callHistory).to.have.lengthOf(1);
|
||||
const [actualFilePath] = escaper.callHistory[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
});
|
||||
describe('isExecutionTerminatedExternally', () => {
|
||||
it('returns `false`', () => {
|
||||
// arrange
|
||||
const expectedValue = false;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutionTerminatedExternally();
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
describe('isExecutablePermissionsRequiredOnFile', () => {
|
||||
it('returns `false`', () => {
|
||||
// arrange
|
||||
const expectedValue = false;
|
||||
const sut = new CommandBuilder().build();
|
||||
// act
|
||||
const actualValue = sut.isExecutablePermissionsRequiredOnFile();
|
||||
// assert
|
||||
expect(expectedValue).to.equal(actualValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class CommandBuilder {
|
||||
private escaper: ShellArgumentEscaper = new ShellArgumentEscaperStub();
|
||||
|
||||
public withEscaper(escaper: ShellArgumentEscaper): this {
|
||||
this.escaper = escaper;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): WindowsVisibleTerminalCommand {
|
||||
return new WindowsVisibleTerminalCommand(
|
||||
this.escaper,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OsSpecificTerminalLaunchCommandFactory } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Factory/OsSpecificTerminalLaunchCommandFactory';
|
||||
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { AllSupportedOperatingSystems, type SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems';
|
||||
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { WindowsVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/WindowsVisibleTerminalCommand';
|
||||
import type { Constructible } from '@/TypeHelpers';
|
||||
import { LinuxVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/LinuxVisibleTerminalCommand';
|
||||
import { MacOsVisibleTerminalCommand } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/MacOsVisibleTerminalCommand';
|
||||
|
||||
describe('OsSpecificTerminalLaunchCommandFactory', () => {
|
||||
describe('returns expected definitions for supported operating systems', () => {
|
||||
const testScenarios: Record<SupportedOperatingSystem, Constructible<CommandDefinition>> = {
|
||||
[OperatingSystem.Windows]: WindowsVisibleTerminalCommand,
|
||||
[OperatingSystem.Linux]: LinuxVisibleTerminalCommand,
|
||||
[OperatingSystem.macOS]: MacOsVisibleTerminalCommand,
|
||||
};
|
||||
AllSupportedOperatingSystems.forEach((operatingSystem) => {
|
||||
it(`${OperatingSystem[operatingSystem]}`, () => {
|
||||
// arrange
|
||||
const expectedDefinitionType = testScenarios[operatingSystem];
|
||||
const context = new TestContext()
|
||||
.withOperatingSystem(operatingSystem);
|
||||
// act
|
||||
const actualDefinition = context.provideCommandDefinition();
|
||||
// assert
|
||||
expect(actualDefinition).to.be.instanceOf(expectedDefinitionType);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the current operating system is undefined', () => {
|
||||
// arrange
|
||||
const expectedError = 'Operating system could not be identified from environment.';
|
||||
const operatingSystem = undefined;
|
||||
const context = new TestContext()
|
||||
.withOperatingSystem(operatingSystem);
|
||||
// act
|
||||
const act = () => context.provideCommandDefinition();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
|
||||
it('throws for an unsupported operating system', () => {
|
||||
// arrange
|
||||
const unsupportedOperatingSystem = OperatingSystem.BlackBerryOS;
|
||||
const expectedError = `Unsupported operating system: ${OperatingSystem[unsupportedOperatingSystem]}`;
|
||||
const context = new TestContext()
|
||||
.withOperatingSystem(unsupportedOperatingSystem);
|
||||
// act
|
||||
const act = () => context.provideCommandDefinition();
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private environment: RuntimeEnvironment = new RuntimeEnvironmentStub();
|
||||
|
||||
public withOperatingSystem(os: OperatingSystem | undefined): this {
|
||||
this.environment = new RuntimeEnvironmentStub()
|
||||
.withOs(os);
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideCommandDefinition(): ReturnType<
|
||||
OsSpecificTerminalLaunchCommandFactory['provideCommandDefinition']
|
||||
> {
|
||||
const sut = new OsSpecificTerminalLaunchCommandFactory(this.environment);
|
||||
return sut.provideCommandDefinition();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
|
||||
import { ExecutableFileShellCommandDefinitionRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ExecutableFileShellCommandDefinitionRunner';
|
||||
import type { ExecutablePermissionSetter } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/PermissionSetter/ExecutablePermissionSetter';
|
||||
import type { ShellCommandOutcome, ShellCommandRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ShellRunner/ShellCommandRunner';
|
||||
import { CommandDefinitionStub } from '@tests/unit/shared/Stubs/CommandDefinitionStub';
|
||||
import { ExecutablePermissionSetterStub } from '@tests/unit/shared/Stubs/ExecutablePermissionSetterStub';
|
||||
import { ShellCommandRunnerStub } from '@tests/unit/shared/Stubs/ShellCommandRunnerStub';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
|
||||
describe('ExecutableFileShellCommandDefinitionRunner', () => {
|
||||
describe('runCommandDefinition', () => {
|
||||
describe('handling file permissions', () => {
|
||||
describe('conditional permission settings', () => {
|
||||
it('sets permissions when required', async () => {
|
||||
// arrange
|
||||
const requireExecutablePermissions = true;
|
||||
const definition = new CommandDefinitionStub()
|
||||
.withExecutablePermissionsRequirement(requireExecutablePermissions);
|
||||
const permissionSetter = new ExecutablePermissionSetterStub();
|
||||
const context = new TestContext()
|
||||
.withCommandDefinition(definition)
|
||||
.withExecutablePermissionSetter(permissionSetter);
|
||||
// act
|
||||
await context.runCommandDefinition();
|
||||
// assert
|
||||
expect(permissionSetter.callHistory).to.have.lengthOf(1);
|
||||
});
|
||||
it('does not set permissions when not required', async () => {
|
||||
// arrange
|
||||
const requireExecutablePermissions = false;
|
||||
const definition = new CommandDefinitionStub()
|
||||
.withExecutablePermissionsRequirement(requireExecutablePermissions);
|
||||
const permissionSetter = new ExecutablePermissionSetterStub();
|
||||
const context = new TestContext()
|
||||
.withCommandDefinition(definition)
|
||||
.withExecutablePermissionSetter(permissionSetter);
|
||||
// act
|
||||
await context.runCommandDefinition();
|
||||
// assert
|
||||
expect(permissionSetter.callHistory).to.have.lengthOf(0);
|
||||
});
|
||||
});
|
||||
it('applies permissions to the correct file', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const permissionSetter = new ExecutablePermissionSetterStub();
|
||||
const context = new TestContext()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withCommandDefinition(createExecutableCommandDefinition())
|
||||
.withExecutablePermissionSetter(permissionSetter);
|
||||
|
||||
// act
|
||||
await context.runCommandDefinition();
|
||||
|
||||
// assert
|
||||
const calls = permissionSetter.callHistory.filter((call) => call.methodName === 'makeFileExecutable');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualFilePath] = calls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('executes command after setting permissions', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
let isExecutedOnExecutableFile = false;
|
||||
let isFileMadeExecutable = false;
|
||||
const permissionSetter = new ExecutablePermissionSetterStub();
|
||||
permissionSetter.methodCalls.on(() => {
|
||||
isFileMadeExecutable = true;
|
||||
});
|
||||
const commandRunner = new ShellCommandRunnerStub();
|
||||
commandRunner.methodCalls.on(() => {
|
||||
isExecutedOnExecutableFile = isFileMadeExecutable;
|
||||
});
|
||||
const context = new TestContext()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withCommandDefinition(createExecutableCommandDefinition())
|
||||
.withCommandRunner(commandRunner)
|
||||
.withExecutablePermissionSetter(permissionSetter);
|
||||
|
||||
// act
|
||||
await context.runCommandDefinition();
|
||||
|
||||
// assert
|
||||
expect(isExecutedOnExecutableFile).to.equal(true);
|
||||
});
|
||||
it('returns an error if permission setting fails', async () => {
|
||||
// arrange
|
||||
const expectedOutcome: ScriptFileExecutionOutcome = {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'FilePermissionChangeError',
|
||||
message: 'Expected error',
|
||||
},
|
||||
};
|
||||
const permissionSetter = new ExecutablePermissionSetterStub()
|
||||
.withOutcome(expectedOutcome);
|
||||
const context = new TestContext()
|
||||
.withCommandDefinition(createExecutableCommandDefinition())
|
||||
.withExecutablePermissionSetter(permissionSetter);
|
||||
|
||||
// act
|
||||
const actualOutcome = await context.runCommandDefinition();
|
||||
|
||||
// assert
|
||||
expect(expectedOutcome).to.equal(actualOutcome);
|
||||
});
|
||||
});
|
||||
describe('interpreting shell outcomes', () => {
|
||||
it('returns success for exit code 0', async () => {
|
||||
// arrange
|
||||
const expectedSuccessResult = true;
|
||||
const permissionSetter = new ShellCommandRunnerStub()
|
||||
.withOutcome({
|
||||
type: 'RegularProcessExit',
|
||||
exitCode: 0,
|
||||
});
|
||||
const context = new TestContext()
|
||||
.withCommandDefinition(createExecutableCommandDefinition())
|
||||
.withCommandRunner(permissionSetter);
|
||||
|
||||
// act
|
||||
const outcome = await context.runCommandDefinition();
|
||||
|
||||
// assert
|
||||
expect(outcome.success).to.equal(expectedSuccessResult);
|
||||
});
|
||||
describe('handling shell command failures', async () => {
|
||||
const testScenarios: readonly {
|
||||
readonly description: string;
|
||||
readonly shellOutcome: ShellCommandOutcome;
|
||||
readonly commandDefinition?: CommandDefinition;
|
||||
readonly expectedErrorType: CodeRunErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'non-zero exit code without external termination',
|
||||
shellOutcome: {
|
||||
type: 'RegularProcessExit',
|
||||
exitCode: 20,
|
||||
},
|
||||
commandDefinition: new CommandDefinitionStub()
|
||||
.withExternalTerminationStatusForExitCode(20, false),
|
||||
expectedErrorType: 'FileExecutionError',
|
||||
expectedErrorMessage: 'Unexpected exit code: 20.',
|
||||
},
|
||||
{
|
||||
description: 'non-zero exit code with external termination',
|
||||
shellOutcome: {
|
||||
type: 'RegularProcessExit',
|
||||
exitCode: 5,
|
||||
},
|
||||
commandDefinition: new CommandDefinitionStub()
|
||||
.withExternalTerminationStatusForExitCode(5, true),
|
||||
expectedErrorType: 'ExternalProcessTermination',
|
||||
expectedErrorMessage: 'Process terminated externally: Exit code 5.',
|
||||
},
|
||||
{
|
||||
description: 'external termination',
|
||||
shellOutcome: {
|
||||
type: 'ExternallyTerminated',
|
||||
terminationSignal: 'SIGABRT',
|
||||
},
|
||||
expectedErrorType: 'ExternalProcessTermination',
|
||||
expectedErrorMessage: 'Process terminated by signal SIGABRT.',
|
||||
},
|
||||
{
|
||||
description: 'execution errors',
|
||||
shellOutcome: {
|
||||
type: 'ExecutionError',
|
||||
error: new Error('Expected message'),
|
||||
},
|
||||
expectedErrorType: 'FileExecutionError',
|
||||
expectedErrorMessage: 'Execution error: Expected message.',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, shellOutcome, expectedErrorType, expectedErrorMessage, commandDefinition,
|
||||
}) => {
|
||||
it(description, async () => {
|
||||
// arrange
|
||||
const permissionSetter = new ShellCommandRunnerStub()
|
||||
.withOutcome(shellOutcome);
|
||||
const context = new TestContext()
|
||||
.withCommandDefinition(commandDefinition ?? createExecutableCommandDefinition())
|
||||
.withCommandRunner(permissionSetter);
|
||||
|
||||
// act
|
||||
const outcome = await context.runCommandDefinition();
|
||||
|
||||
// assert
|
||||
expect(outcome.success).to.equal(false);
|
||||
expectExists(outcome.error);
|
||||
expect(outcome.error.message).to.contain(expectedErrorMessage);
|
||||
expect(outcome.error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createExecutableCommandDefinition(): CommandDefinition {
|
||||
return new CommandDefinitionStub()
|
||||
.withExecutablePermissionsRequirement(true);
|
||||
}
|
||||
|
||||
class TestContext {
|
||||
private executablePermissionSetter
|
||||
: ExecutablePermissionSetter = new ExecutablePermissionSetterStub();
|
||||
|
||||
private shellCommandRunner
|
||||
: ShellCommandRunner = new ShellCommandRunnerStub();
|
||||
|
||||
private commandDefinition: CommandDefinition = new CommandDefinitionStub();
|
||||
|
||||
private filePath: string = 'test-file-path';
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCommandRunner(
|
||||
shellCommandRunner: ShellCommandRunner,
|
||||
): this {
|
||||
this.shellCommandRunner = shellCommandRunner;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCommandDefinition(
|
||||
commandDefinition: CommandDefinition,
|
||||
): this {
|
||||
this.commandDefinition = commandDefinition;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withExecutablePermissionSetter(
|
||||
executablePermissionSetter: ExecutablePermissionSetter,
|
||||
): this {
|
||||
this.executablePermissionSetter = executablePermissionSetter;
|
||||
return this;
|
||||
}
|
||||
|
||||
public runCommandDefinition(): ReturnType<
|
||||
ExecutableFileShellCommandDefinitionRunner['runCommandDefinition']
|
||||
> {
|
||||
const sut = new ExecutableFileShellCommandDefinitionRunner(
|
||||
this.executablePermissionSetter,
|
||||
this.shellCommandRunner,
|
||||
);
|
||||
return sut.runCommandDefinition(
|
||||
this.commandDefinition,
|
||||
this.filePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { FileSystemExecutablePermissionSetter } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/PermissionSetter/FileSystemExecutablePermissionSetter';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
|
||||
describe('FileSystemExecutablePermissionSetter', () => {
|
||||
describe('makeFileExecutable', () => {
|
||||
it('sets permissions on the specified file', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const context = new TestContext()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualFilePath] = calls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
|
||||
it('applies the correct permissions mode', async () => {
|
||||
// arrange
|
||||
const expectedMode = '755';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [, actualMode] = calls[0].args;
|
||||
expect(actualMode).to.equal(expectedMode);
|
||||
});
|
||||
|
||||
it('reports success when permissions are set without errors', async () => {
|
||||
// arrange
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.resolve();
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
const result = await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
expectTrue(result.success);
|
||||
expect(result.error).to.equal(undefined);
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns error expected error message when filesystem throws', async () => {
|
||||
// arrange
|
||||
const thrownErrorMessage = 'File system error';
|
||||
const expectedErrorMessage = `Error setting script file permission: ${thrownErrorMessage}`;
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
const result = await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
expect(result.success).to.equal(false);
|
||||
expectExists(result.error);
|
||||
expect(result.error.message).to.equal(expectedErrorMessage);
|
||||
});
|
||||
|
||||
it('returns expected error type when filesystem throws', async () => {
|
||||
// arrange
|
||||
const expectedErrorType: CodeRunErrorType = 'FilePermissionChangeError';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error('File system error'));
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
const result = await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
expect(result.success).to.equal(false);
|
||||
expectExists(result.error);
|
||||
const actualErrorType = result.error.type;
|
||||
expect(actualErrorType).to.equal(expectedErrorType);
|
||||
});
|
||||
|
||||
it('logs error when filesystem throws', async () => {
|
||||
// arrange
|
||||
const thrownErrorMessage = 'File system error';
|
||||
const logger = new LoggerStub();
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
fileSystem.setFilePermissions = () => Promise.reject(new Error(thrownErrorMessage));
|
||||
const context = new TestContext()
|
||||
.withLogger(logger)
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
await context.makeFileExecutable();
|
||||
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('error', thrownErrorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private filePath = `[${TestContext.name}] /file/path`;
|
||||
|
||||
private systemOperations: SystemOperations = new SystemOperationsStub();
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSystemOperations(systemOperations: SystemOperations): this {
|
||||
this.systemOperations = systemOperations;
|
||||
return this;
|
||||
}
|
||||
|
||||
public makeFileExecutable(): Promise<ScriptFileExecutionOutcome> {
|
||||
const sut = new FileSystemExecutablePermissionSetter(
|
||||
this.systemOperations,
|
||||
this.logger,
|
||||
);
|
||||
return sut.makeFileExecutable(this.filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { LoggingNodeShellCommandRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ShellRunner/LoggingNodeShellCommandRunner';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
||||
import { ChildProcessStub } from '@tests/unit/shared/Stubs/ChildProcesssStub';
|
||||
import type { ShellCommandOutcome } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ShellRunner/ShellCommandRunner';
|
||||
|
||||
describe('LoggingNodeShellCommandRunner', () => {
|
||||
describe('logging', () => {
|
||||
it('logs on command execution', () => {
|
||||
// arrange
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withLogger(logger);
|
||||
const expectedLogMessage = `Executing command: ${context.command}`;
|
||||
// act
|
||||
context.runShellCommand();
|
||||
// assert
|
||||
expect(logger.assertLogsContainMessagePart('info', expectedLogMessage));
|
||||
});
|
||||
|
||||
it('logs on command completion with exit code', () => {
|
||||
// arrange
|
||||
const exitCode = 31;
|
||||
const expectedLogMessage = `Command completed with exit code ${exitCode}.`;
|
||||
const logger = new LoggerStub();
|
||||
const childProcessStub = new ChildProcessStub();
|
||||
const context = new TestContext()
|
||||
.withLogger(logger)
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
context.runShellCommand();
|
||||
childProcessStub.emitExit(exitCode, null);
|
||||
// assert
|
||||
expect(logger.assertLogsContainMessagePart('info', expectedLogMessage));
|
||||
});
|
||||
|
||||
it('logs on command termination by a signal', async () => {
|
||||
// arrange
|
||||
const signal: NodeJS.Signals = 'SIGKILL';
|
||||
const expectedLogMessage = `Command terminated by signal: ${signal}`;
|
||||
const logger = new LoggerStub();
|
||||
const childProcessStub = new ChildProcessStub();
|
||||
const context = new TestContext()
|
||||
.withLogger(logger)
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
context.runShellCommand();
|
||||
childProcessStub.emitExit(null, signal);
|
||||
// assert
|
||||
expect(logger.assertLogsContainMessagePart('warn', expectedLogMessage));
|
||||
});
|
||||
|
||||
it('logs on command execution fail', async () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'Error when executing command';
|
||||
const expectedLogMessage = 'Command execution failed:';
|
||||
const logger = new LoggerStub();
|
||||
const childProcessStub = new ChildProcessStub();
|
||||
const context = new TestContext()
|
||||
.withLogger(logger)
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
context.runShellCommand();
|
||||
childProcessStub.emitError(new Error(expectedLogMessage));
|
||||
// assert
|
||||
expect(logger.assertLogsContainMessagePart('error', expectedLogMessage));
|
||||
expect(logger.assertLogsContainMessagePart('error', expectedErrorMessage));
|
||||
});
|
||||
});
|
||||
|
||||
describe('return object', () => {
|
||||
it('when child process exits on its own', async () => {
|
||||
// arrange
|
||||
const expectedExitCode = 31;
|
||||
const expectedOutcomeType: ShellCommandOutcome['type'] = 'RegularProcessExit';
|
||||
const childProcessStub = new ChildProcessStub()
|
||||
.withAutoEmitExit(false);
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
const task = context.runShellCommand();
|
||||
childProcessStub.emitExit(expectedExitCode, null);
|
||||
const actualResult = await task;
|
||||
// assert
|
||||
expect(actualResult.type).to.equal(expectedOutcomeType);
|
||||
expect(actualResult.exitCode).to.equal(expectedExitCode);
|
||||
});
|
||||
it('when child process is terminated by a signal', async () => {
|
||||
// arrange
|
||||
const expectedTerminationSignal: NodeJS.Signals = 'SIGABRT';
|
||||
const expectedOutcomeType: ShellCommandOutcome['type'] = 'ExternallyTerminated';
|
||||
const childProcessStub = new ChildProcessStub()
|
||||
.withAutoEmitExit(false);
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
const task = context.runShellCommand();
|
||||
childProcessStub.emitExit(null, expectedTerminationSignal);
|
||||
const actualResult = await task;
|
||||
// assert
|
||||
expect(actualResult.type).to.equal(expectedOutcomeType);
|
||||
expect(actualResult.terminationSignal).to.equal(expectedTerminationSignal);
|
||||
});
|
||||
it('when child process has errors', async () => {
|
||||
// arrange
|
||||
const expectedError = new Error('inner error');
|
||||
const expectedOutcomeType: ShellCommandOutcome['type'] = 'ExecutionError';
|
||||
const childProcessStub = new ChildProcessStub()
|
||||
.withAutoEmitExit(false);
|
||||
const context = new TestContext()
|
||||
.withSystemOperations(createSystemOperationsWithChildProcessStub(childProcessStub));
|
||||
// act
|
||||
const task = context.runShellCommand();
|
||||
childProcessStub.emitError(expectedError);
|
||||
const actualResult = await task;
|
||||
// assert
|
||||
expect(actualResult.type).to.equal(expectedOutcomeType);
|
||||
expect(actualResult.error).to.deep.equal(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createSystemOperationsWithChildProcessStub(
|
||||
childProcessStub: ChildProcessStub,
|
||||
): SystemOperations {
|
||||
const commandOps = new CommandOpsStub()
|
||||
.withChildProcess(childProcessStub.asChildProcess());
|
||||
return new SystemOperationsStub()
|
||||
.withCommand(commandOps);
|
||||
}
|
||||
|
||||
class TestContext {
|
||||
public readonly command: string = 'echo "Hello from unit tests!"';
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
private systemOps: SystemOperations = new SystemOperationsStub();
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSystemOperations(systemOps: SystemOperations): this {
|
||||
this.systemOps = systemOps;
|
||||
return this;
|
||||
}
|
||||
|
||||
public runShellCommand(): ReturnType<LoggingNodeShellCommandRunner['runShellCommand']> {
|
||||
const sut = new LoggingNodeShellCommandRunner(
|
||||
this.logger,
|
||||
this.systemOps,
|
||||
);
|
||||
return sut.runShellCommand(this.command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import type { CommandDefinitionFactory } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Factory/CommandDefinitionFactory';
|
||||
import { VisibleTerminalFileRunner } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalFileRunner';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { CommandDefinitionRunnerStub } from '@tests/unit/shared/Stubs/CommandDefinitionRunnerStub';
|
||||
import type { CommandDefinitionRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/CommandDefinitionRunner';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { CommandDefinitionStub } from '@tests/unit/shared/Stubs/CommandDefinitionStub';
|
||||
import { CommandDefinitionFactoryStub } from '@tests/unit/shared/Stubs/CommandDefinitionFactoryStub';
|
||||
|
||||
describe('VisibleTerminalFileRunner', () => {
|
||||
describe('executeScriptFile', () => {
|
||||
describe('logging', () => {
|
||||
it('logs execution start', async () => {
|
||||
// arrange
|
||||
const filePath = '/file/in/logs';
|
||||
const expectedLog = `Executing script file: ${filePath}.`;
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withFilePath(filePath)
|
||||
.withLogger(logger);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('info', expectedLog);
|
||||
});
|
||||
|
||||
it('logs if command factory throws', async () => {
|
||||
// arrange
|
||||
const errorFromCommandFactory = 'Expected error from command factory';
|
||||
const expectedLogMessage = 'Failed to execute the script file in terminal.';
|
||||
const expectedLogErrorType: CodeRunErrorType = 'UnsupportedPlatform';
|
||||
const expectedLogErrorMessage = `Error finding command: ${errorFromCommandFactory}`;
|
||||
const commandFactory = new CommandDefinitionFactoryStub();
|
||||
commandFactory.provideCommandDefinition = () => {
|
||||
throw new Error(errorFromCommandFactory);
|
||||
};
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandFactory(commandFactory)
|
||||
.withLogger(logger);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('error', expectedLogMessage);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorType);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorMessage);
|
||||
});
|
||||
|
||||
it('logs if command runner throws', async () => {
|
||||
// arrange
|
||||
const errorFromCommandRunner = 'Expected error from command runner';
|
||||
const expectedLogMessage = 'Failed to execute the script file in terminal.';
|
||||
const expectedLogErrorType: CodeRunErrorType = 'FileExecutionError';
|
||||
const expectedLogErrorMessage = `Unexpected error: ${errorFromCommandRunner}`;
|
||||
const commandRunner = new CommandDefinitionRunnerStub();
|
||||
commandRunner.runCommandDefinition = () => {
|
||||
throw new Error(errorFromCommandRunner);
|
||||
};
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner)
|
||||
.withLogger(logger);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('error', expectedLogMessage);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorType);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorMessage);
|
||||
});
|
||||
|
||||
it('logs if command runner returns error', async () => {
|
||||
// arrange
|
||||
const expectedLogMessage = 'Failed to execute the script file in terminal.';
|
||||
const expectedLogErrorType: CodeRunErrorType = 'ExternalProcessTermination';
|
||||
const expectedLogErrorMessage = 'Expected error from command runner';
|
||||
const errorFromCommandRunner: ScriptFileExecutionOutcome = {
|
||||
success: false,
|
||||
error: {
|
||||
type: expectedLogErrorType,
|
||||
message: expectedLogErrorMessage,
|
||||
},
|
||||
};
|
||||
const commandRunner = new CommandDefinitionRunnerStub()
|
||||
.withOutcome(errorFromCommandRunner);
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner)
|
||||
.withLogger(logger);
|
||||
// act
|
||||
context.executeScriptFile();
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('error', expectedLogMessage);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorType);
|
||||
logger.assertLogsContainMessagePart('error', expectedLogErrorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns correct outcome', () => {
|
||||
it('returns success on happy path', async () => {
|
||||
// arrange
|
||||
const context = new TestContext();
|
||||
// act
|
||||
const outcome = await context.executeScriptFile();
|
||||
// assert
|
||||
expect(outcome.success).to.equal(true);
|
||||
});
|
||||
|
||||
it('returns error when command factory throws', async () => {
|
||||
// arrange
|
||||
const errorFromCommandFactory = 'Expected error from command factory';
|
||||
const expectedErrorType: CodeRunErrorType = 'UnsupportedPlatform';
|
||||
const expectedErrorMessage = `Error finding command: ${errorFromCommandFactory}`;
|
||||
const commandFactory = new CommandDefinitionFactoryStub();
|
||||
commandFactory.provideCommandDefinition = () => {
|
||||
throw new Error(errorFromCommandFactory);
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withCommandFactory(commandFactory);
|
||||
// act
|
||||
const outcome = await context.executeScriptFile();
|
||||
// assert
|
||||
expect(outcome.success).to.equal(false);
|
||||
expectExists(outcome.error);
|
||||
expect(outcome.error.message).to.equal(expectedErrorMessage);
|
||||
expect(outcome.error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
|
||||
it('returns error when command runner throws', async () => {
|
||||
// arrange
|
||||
const errorFromCommandRunner = 'Expected error from command runner';
|
||||
const expectedErrorType: CodeRunErrorType = 'FileExecutionError';
|
||||
const expectedErrorMessage = `Unexpected error: ${errorFromCommandRunner}`;
|
||||
const commandRunner = new CommandDefinitionRunnerStub();
|
||||
commandRunner.runCommandDefinition = () => {
|
||||
throw new Error(errorFromCommandRunner);
|
||||
};
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner);
|
||||
// act
|
||||
const outcome = await context.executeScriptFile();
|
||||
// assert
|
||||
expect(outcome.success).to.equal(false);
|
||||
expectExists(outcome.error);
|
||||
expect(outcome.error.message).to.equal(expectedErrorMessage);
|
||||
expect(outcome.error.type).to.equal(expectedErrorType);
|
||||
});
|
||||
|
||||
it('returns error when command runner returns error', async () => {
|
||||
// arrange
|
||||
const expectedOutcome: ScriptFileExecutionOutcome = {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'FileExecutionError',
|
||||
message: 'Expected error from command runner',
|
||||
},
|
||||
};
|
||||
const commandRunner = new CommandDefinitionRunnerStub()
|
||||
.withOutcome(expectedOutcome);
|
||||
const logger = new LoggerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner)
|
||||
.withLogger(logger);
|
||||
// act
|
||||
const actualOutcome = await context.executeScriptFile();
|
||||
// assert
|
||||
expect(actualOutcome).to.equal(expectedOutcome);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command running', () => {
|
||||
it('runs command once', async () => {
|
||||
// arrange
|
||||
const commandRunner = new CommandDefinitionRunnerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
const calls = commandRunner.callHistory.filter((c) => c.methodName === 'runCommandDefinition');
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('runs correct definition', async () => {
|
||||
// arrange
|
||||
const expectedDefinition = new CommandDefinitionStub();
|
||||
const commandFactory = new CommandDefinitionFactoryStub()
|
||||
.withDefinition(expectedDefinition);
|
||||
const commandRunner = new CommandDefinitionRunnerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner)
|
||||
.withCommandFactory(commandFactory);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
const call = commandRunner.callHistory.find((c) => c.methodName === 'runCommandDefinition');
|
||||
expectExists(call);
|
||||
const [actualDefinition] = call.args;
|
||||
expect(actualDefinition).to.equal(expectedDefinition);
|
||||
});
|
||||
|
||||
it('runs correct file', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = '/expected/file/path';
|
||||
const commandRunner = new CommandDefinitionRunnerStub();
|
||||
const context = new TestContext()
|
||||
.withCommandRunner(commandRunner)
|
||||
.withFilePath(expectedFilePath);
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
// assert
|
||||
const call = commandRunner.callHistory.find((c) => c.methodName === 'runCommandDefinition');
|
||||
expectExists(call);
|
||||
const [,actualFilePath] = call.args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestContext {
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
public filePath = '/test/file/path';
|
||||
|
||||
public commandFactory: CommandDefinitionFactory = new CommandDefinitionFactoryStub();
|
||||
|
||||
public commandRunner: CommandDefinitionRunner = new CommandDefinitionRunnerStub();
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCommandRunner(commandRunner: CommandDefinitionRunner): this {
|
||||
this.commandRunner = commandRunner;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withCommandFactory(commandFactory: CommandDefinitionFactory): this {
|
||||
this.commandFactory = commandFactory;
|
||||
return this;
|
||||
}
|
||||
|
||||
public executeScriptFile(): ReturnType<VisibleTerminalFileRunner['executeScriptFile']> {
|
||||
const runner = new VisibleTerminalFileRunner(
|
||||
this.logger,
|
||||
this.commandFactory,
|
||||
this.commandRunner,
|
||||
);
|
||||
return runner.executeScriptFile(
|
||||
this.filePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { AllSupportedOperatingSystems, type SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems';
|
||||
import { VisibleTerminalScriptExecutor } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
|
||||
import type { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
|
||||
import type { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
|
||||
describe('VisibleTerminalScriptFileExecutor', () => {
|
||||
describe('executeScriptFile', () => {
|
||||
describe('command execution', () => {
|
||||
// arrange
|
||||
const testScenarios: Record<SupportedOperatingSystem, readonly {
|
||||
readonly filePath: string;
|
||||
readonly expectedCommand: string;
|
||||
readonly description: string;
|
||||
}[]> = {
|
||||
[OperatingSystem.Windows]: [
|
||||
{
|
||||
description: 'encloses path in quotes',
|
||||
filePath: 'file',
|
||||
expectedCommand: 'PowerShell Start-Process -Verb RunAs -FilePath "file"',
|
||||
},
|
||||
],
|
||||
[OperatingSystem.macOS]: [
|
||||
{
|
||||
description: 'encloses path in quotes',
|
||||
filePath: 'file',
|
||||
expectedCommand: 'open -a Terminal.app \'file\'',
|
||||
},
|
||||
{
|
||||
description: 'escapes single quotes in path',
|
||||
filePath: 'f\'i\'le',
|
||||
expectedCommand: 'open -a Terminal.app \'f\'\\\'\'i\'\\\'\'le\'',
|
||||
},
|
||||
],
|
||||
[OperatingSystem.Linux]: [
|
||||
{
|
||||
description: 'encloses path in quotes',
|
||||
filePath: 'file',
|
||||
expectedCommand: 'x-terminal-emulator -e \'file\'',
|
||||
},
|
||||
{
|
||||
description: 'escapes single quotes in path',
|
||||
filePath: 'f\'i\'le',
|
||||
expectedCommand: 'x-terminal-emulator -e \'f\'\\\'\'i\'\\\'\'le\'',
|
||||
},
|
||||
],
|
||||
};
|
||||
AllSupportedOperatingSystems.forEach((operatingSystem) => {
|
||||
describe(`on ${OperatingSystem[operatingSystem]}`, () => {
|
||||
testScenarios[operatingSystem].forEach((
|
||||
{ description, filePath, expectedCommand },
|
||||
) => {
|
||||
it(`executes command - ${description}`, async () => {
|
||||
// arrange
|
||||
const command = new CommandOpsStub();
|
||||
const context = new ScriptFileExecutorTestSetup()
|
||||
.withOs(operatingSystem)
|
||||
.withFilePath(filePath)
|
||||
.withSystemOperations(new SystemOperationsStub().withCommand(command));
|
||||
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
|
||||
// assert
|
||||
const calls = command.callHistory.filter((c) => c.methodName === 'exec');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualCommand] = calls[0].args;
|
||||
expect(actualCommand).to.equal(expectedCommand);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('file permissions', () => {
|
||||
it('sets permissions before execution', async () => {
|
||||
// arrange
|
||||
let isExecutedAfterPermissions = false;
|
||||
let isPermissionsSet = false;
|
||||
const fileSystemMock = new FileSystemOpsStub();
|
||||
fileSystemMock.setFilePermissions = () => {
|
||||
isPermissionsSet = true;
|
||||
return Promise.resolve();
|
||||
};
|
||||
const commandMock = new CommandOpsStub();
|
||||
commandMock.exec = () => {
|
||||
isExecutedAfterPermissions = isPermissionsSet;
|
||||
return Promise.resolve();
|
||||
};
|
||||
const context = new ScriptFileExecutorTestSetup()
|
||||
.withSystemOperations(new SystemOperationsStub()
|
||||
.withFileSystem(fileSystemMock)
|
||||
.withCommand(commandMock));
|
||||
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
|
||||
// assert
|
||||
expect(isExecutedAfterPermissions).to.equal(true);
|
||||
});
|
||||
it('applies correct permissions', async () => {
|
||||
// arrange
|
||||
const expectedMode = '755';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const context = new ScriptFileExecutorTestSetup()
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
|
||||
// assert
|
||||
const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [, actualMode] = calls[0].args;
|
||||
expect(actualMode).to.equal(expectedMode);
|
||||
});
|
||||
it('sets permissions for correct file', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const fileSystem = new FileSystemOpsStub();
|
||||
const context = new ScriptFileExecutorTestSetup()
|
||||
.withFilePath(expectedFilePath)
|
||||
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
|
||||
|
||||
// act
|
||||
await context.executeScriptFile();
|
||||
|
||||
// assert
|
||||
const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions');
|
||||
expect(calls.length).to.equal(1);
|
||||
const [actualFilePath] = calls[0].args;
|
||||
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: 'unidentified 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 ScriptFileExecutorTestSetup {
|
||||
private os?: OperatingSystem = OperatingSystem.Windows;
|
||||
|
||||
private filePath = `[${ScriptFileExecutorTestSetup.name}] file path`;
|
||||
|
||||
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 {
|
||||
this.os = os;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSystemOperations(system: SystemOperations): this {
|
||||
this.system = system;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFilePath(filePath: string): this {
|
||||
this.filePath = filePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
public executeScriptFile() {
|
||||
const environment = new RuntimeEnvironmentStub().withOs(this.os);
|
||||
const executor = new VisibleTerminalScriptExecutor(this.system, this.logger, environment);
|
||||
return executor.executeScriptFile(this.filePath);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ describe('ScriptFileCodeRunner', () => {
|
||||
}> = [
|
||||
{
|
||||
description: 'execution failure',
|
||||
expectedErrorType: 'FileExecutionError',
|
||||
expectedErrorType: 'FilePermissionChangeError',
|
||||
expectedErrorMessage: 'execution error',
|
||||
buildFaultyContext: (setup, errorMessage, errorType) => {
|
||||
const executor = new ScriptFileExecutorStub();
|
||||
|
||||
@@ -8,32 +8,70 @@ import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedO
|
||||
import { ScriptDiagnosticsCollectorStub } from '@tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub';
|
||||
|
||||
describe('ScriptErrorDialog', () => {
|
||||
describe('handles readback error type', () => {
|
||||
it('handles file readback error', async () => {
|
||||
// arrange
|
||||
const errorDetails = createErrorDetails({ isFileReadbackError: true });
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDetails(errorDetails);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
it('handles generic error', async () => {
|
||||
// arrange
|
||||
const errorDetails = createErrorDetails({ isFileReadbackError: false });
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDetails(errorDetails);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
describe('handling different error types', () => {
|
||||
const testScenarios: readonly {
|
||||
readonly description: string,
|
||||
readonly givenErrorDetails: ScriptErrorDetails;
|
||||
readonly expectedDialogTitle: string;
|
||||
}[] = [
|
||||
{
|
||||
description: 'generic error when running',
|
||||
givenErrorDetails: createErrorDetails({
|
||||
isFileReadbackError: false,
|
||||
errorContext: 'run',
|
||||
}),
|
||||
expectedDialogTitle: 'Error Running Script',
|
||||
},
|
||||
{
|
||||
description: 'generic error when saving',
|
||||
givenErrorDetails: createErrorDetails({
|
||||
isFileReadbackError: false,
|
||||
errorContext: 'save',
|
||||
}),
|
||||
expectedDialogTitle: 'Error Saving Script',
|
||||
},
|
||||
{
|
||||
description: 'file readback failure',
|
||||
givenErrorDetails: createErrorDetails({ isFileReadbackError: true }),
|
||||
expectedDialogTitle: 'Possible Antivirus Script Block',
|
||||
},
|
||||
{
|
||||
description: 'script interruption',
|
||||
givenErrorDetails: createErrorDetails({
|
||||
errorContext: 'run',
|
||||
errorType: 'ExternalProcessTermination',
|
||||
}),
|
||||
expectedDialogTitle: 'Script Stopped',
|
||||
},
|
||||
];
|
||||
testScenarios.forEach((
|
||||
{ description, givenErrorDetails, expectedDialogTitle },
|
||||
) => {
|
||||
it(`creates dialog for "${description}"`, async () => {
|
||||
// arrange
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDetails(givenErrorDetails);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
it(`creates dialog for "${description}" with title "${expectedDialogTitle}"`, async () => {
|
||||
// arrange
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDetails(givenErrorDetails);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
const [actualDialogTitle] = dialog;
|
||||
expect(actualDialogTitle).to.equal(expectedDialogTitle);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles supported operatingSystems', () => {
|
||||
describe('handling supported operating systems', () => {
|
||||
AllSupportedOperatingSystems.forEach((operatingSystem) => {
|
||||
it(`${OperatingSystem[operatingSystem]}`, async () => {
|
||||
it(`creates dialog for ${OperatingSystem[operatingSystem]}`, async () => {
|
||||
// arrange
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
.withOperatingSystem(operatingSystem);
|
||||
@@ -47,46 +85,48 @@ describe('ScriptErrorDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined diagnostics collector', async () => {
|
||||
const diagnostics = undefined;
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
describe('handling missing inputs', () => {
|
||||
it('creates dialog when diagnostics collector is undefined', async () => {
|
||||
const diagnostics = undefined;
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
|
||||
it('creates dialog when operating system is undefined', async () => {
|
||||
// arrange
|
||||
const undefinedOperatingSystem = undefined;
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
.withOperatingSystem(undefinedOperatingSystem);
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
|
||||
it('creates dialog when script directory path is undefined', async () => {
|
||||
// arrange
|
||||
const undefinedScriptsDirectory = undefined;
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
.withScriptDirectoryPath(undefinedScriptsDirectory);
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined operating system', async () => {
|
||||
// arrange
|
||||
const undefinedOperatingSystem = undefined;
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
.withOperatingSystem(undefinedOperatingSystem);
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
|
||||
it('handles directory path', async () => {
|
||||
// arrange
|
||||
const undefinedScriptsDirectory = undefined;
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
.withScriptDirectoryPath(undefinedScriptsDirectory);
|
||||
const context = new CreateScriptErrorDialogTestSetup()
|
||||
.withDiagnostics(diagnostics);
|
||||
// act
|
||||
const dialog = await context.createScriptErrorDialog();
|
||||
// assert
|
||||
assertValidDialog(dialog);
|
||||
});
|
||||
|
||||
describe('handles all contexts', () => {
|
||||
describe('handling all error contexts', () => {
|
||||
const possibleContexts: ScriptErrorDetails['errorContext'][] = ['run', 'save'];
|
||||
possibleContexts.forEach((dialogContext) => {
|
||||
it(`${dialogContext} context`, async () => {
|
||||
it(`creates dialog for '${dialogContext}' context`, async () => {
|
||||
// arrange
|
||||
const undefinedScriptsDirectory = undefined;
|
||||
const diagnostics = new ScriptDiagnosticsCollectorStub()
|
||||
@@ -114,7 +154,7 @@ function assertValidDialog(dialog: Parameters<Dialog['showError']>): void {
|
||||
function createErrorDetails(partialDetails?: Partial<ScriptErrorDetails>): ScriptErrorDetails {
|
||||
const defaultDetails: ScriptErrorDetails = {
|
||||
errorContext: 'run',
|
||||
errorType: 'test-error-type',
|
||||
errorType: 'UnsupportedPlatform',
|
||||
errorMessage: 'test error message',
|
||||
isFileReadbackError: false,
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('IpcRegistration', () => {
|
||||
ScriptDiagnosticsCollector: (() => {
|
||||
const expectedInstance = new ScriptDiagnosticsCollectorStub();
|
||||
return {
|
||||
buildContext: (c) => c.witScriptDiagnosticsCollectorFactory(() => expectedInstance),
|
||||
buildContext: (c) => c.withScriptDiagnosticsCollectorFactory(() => expectedInstance),
|
||||
expectedInstance,
|
||||
};
|
||||
})(),
|
||||
@@ -112,7 +112,7 @@ class IpcRegistrationTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public witScriptDiagnosticsCollectorFactory(
|
||||
public withScriptDiagnosticsCollectorFactory(
|
||||
scriptDiagnosticsCollectorFactory: ScriptDiagnosticsCollectorFactory,
|
||||
): this {
|
||||
this.scriptDiagnosticsCollectorFactory = scriptDiagnosticsCollectorFactory;
|
||||
|
||||
43
tests/unit/shared/Stubs/ChildProcesssStub.ts
Normal file
43
tests/unit/shared/Stubs/ChildProcesssStub.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
export class ChildProcessStub implements Partial<ChildProcess> {
|
||||
private readonly eventListeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
|
||||
private autoEmitExit = true;
|
||||
|
||||
public on(event: string, listener: (...args: never[]) => void): ChildProcess {
|
||||
if (!this.eventListeners[event]) {
|
||||
this.eventListeners[event] = [];
|
||||
}
|
||||
this.eventListeners[event].push(listener);
|
||||
if (event === 'exit' && this.autoEmitExit) {
|
||||
this.emitExit(0, null);
|
||||
}
|
||||
return this.asChildProcess();
|
||||
}
|
||||
|
||||
public emitExit(code: number | null, signal: NodeJS.Signals | null) {
|
||||
this.emitEvent('exit', code, signal);
|
||||
}
|
||||
|
||||
public emitError(error: Error): void {
|
||||
this.emitEvent('error', error);
|
||||
}
|
||||
|
||||
public withAutoEmitExit(autoEmitExit: boolean): this {
|
||||
this.autoEmitExit = autoEmitExit;
|
||||
return this;
|
||||
}
|
||||
|
||||
public asChildProcess(): ChildProcess {
|
||||
return this as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
private emitEvent(event: string, ...args: unknown[]): void {
|
||||
if (this.eventListeners[event]) {
|
||||
this.eventListeners[event].forEach((listener) => {
|
||||
listener(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
16
tests/unit/shared/Stubs/CommandDefinitionFactoryStub.ts
Normal file
16
tests/unit/shared/Stubs/CommandDefinitionFactoryStub.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
|
||||
import type { CommandDefinitionFactory } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Factory/CommandDefinitionFactory';
|
||||
import { CommandDefinitionStub } from './CommandDefinitionStub';
|
||||
|
||||
export class CommandDefinitionFactoryStub implements CommandDefinitionFactory {
|
||||
private definition: CommandDefinition = new CommandDefinitionStub();
|
||||
|
||||
public withDefinition(definition: CommandDefinition): this {
|
||||
this.definition = definition;
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideCommandDefinition(): CommandDefinition {
|
||||
return this.definition;
|
||||
}
|
||||
}
|
||||
28
tests/unit/shared/Stubs/CommandDefinitionRunnerStub.ts
Normal file
28
tests/unit/shared/Stubs/CommandDefinitionRunnerStub.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
|
||||
import type { CommandDefinitionRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/CommandDefinitionRunner';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class CommandDefinitionRunnerStub
|
||||
extends StubWithObservableMethodCalls<CommandDefinitionRunner>
|
||||
implements CommandDefinitionRunner {
|
||||
private outcome: ScriptFileExecutionOutcome = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
public withOutcome(outcome: ScriptFileExecutionOutcome): this {
|
||||
this.outcome = outcome;
|
||||
return this;
|
||||
}
|
||||
|
||||
public runCommandDefinition(
|
||||
commandDefinition: CommandDefinition,
|
||||
filePath: string,
|
||||
): Promise<ScriptFileExecutionOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'runCommandDefinition',
|
||||
args: [commandDefinition, filePath],
|
||||
});
|
||||
return Promise.resolve(this.outcome);
|
||||
}
|
||||
}
|
||||
33
tests/unit/shared/Stubs/CommandDefinitionStub.ts
Normal file
33
tests/unit/shared/Stubs/CommandDefinitionStub.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { CommandDefinition } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/CommandDefinition';
|
||||
|
||||
export class CommandDefinitionStub implements CommandDefinition {
|
||||
private requireExecutablePermissions = false;
|
||||
|
||||
private exitCodeToTerminationStatus: Map<number, boolean> = new Map<number, boolean>();
|
||||
|
||||
public withExecutablePermissionsRequirement(requireExecutablePermissions: boolean): this {
|
||||
this.requireExecutablePermissions = requireExecutablePermissions;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withExternalTerminationStatusForExitCode(exitCode: number, state: boolean): this {
|
||||
this.exitCodeToTerminationStatus.set(exitCode, state);
|
||||
return this;
|
||||
}
|
||||
|
||||
public buildShellCommand(filePath: string): string {
|
||||
return `[${CommandDefinitionStub.name}] ${filePath}`;
|
||||
}
|
||||
|
||||
public isExecutionTerminatedExternally(exitCode: number): boolean {
|
||||
const status = this.exitCodeToTerminationStatus.get(exitCode);
|
||||
if (status === undefined) {
|
||||
return false;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
public isExecutablePermissionsRequiredOnFile(): boolean {
|
||||
return this.requireExecutablePermissions;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import type { CommandOps } from '@/infrastructure/CodeRunner/System/SystemOperations';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
import { ChildProcessStub } from './ChildProcesssStub';
|
||||
|
||||
export class CommandOpsStub
|
||||
extends StubWithObservableMethodCalls<CommandOps>
|
||||
implements CommandOps {
|
||||
public exec(command: string): Promise<void> {
|
||||
private childProcess: ChildProcess = new ChildProcessStub()
|
||||
.withAutoEmitExit(true)
|
||||
.asChildProcess();
|
||||
|
||||
public withChildProcess(childProcess: ChildProcess): this {
|
||||
this.childProcess = childProcess;
|
||||
return this;
|
||||
}
|
||||
|
||||
public exec(command: string): ChildProcess {
|
||||
this.registerMethodCall({
|
||||
methodName: 'exec',
|
||||
args: [command],
|
||||
});
|
||||
return Promise.resolve();
|
||||
return this.childProcess;
|
||||
}
|
||||
}
|
||||
|
||||
24
tests/unit/shared/Stubs/ExecutablePermissionSetterStub.ts
Normal file
24
tests/unit/shared/Stubs/ExecutablePermissionSetterStub.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ExecutablePermissionSetter } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/PermissionSetter/ExecutablePermissionSetter';
|
||||
import type { ScriptFileExecutionOutcome } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ExecutablePermissionSetterStub
|
||||
extends StubWithObservableMethodCalls<ExecutablePermissionSetter>
|
||||
implements ExecutablePermissionSetter {
|
||||
private outcome: ScriptFileExecutionOutcome = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
public withOutcome(outcome: ScriptFileExecutionOutcome): this {
|
||||
this.outcome = outcome;
|
||||
return this;
|
||||
}
|
||||
|
||||
public makeFileExecutable(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'makeFileExecutable',
|
||||
args: [filePath],
|
||||
});
|
||||
return Promise.resolve(this.outcome);
|
||||
}
|
||||
}
|
||||
14
tests/unit/shared/Stubs/ShellArgumentEscaperStub.ts
Normal file
14
tests/unit/shared/Stubs/ShellArgumentEscaperStub.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ShellArgumentEscaper } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Commands/ShellArgument/ShellArgumentEscaper';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ShellArgumentEscaperStub
|
||||
extends StubWithObservableMethodCalls<ShellArgumentEscaper>
|
||||
implements ShellArgumentEscaper {
|
||||
public escapePathArgument(pathArgument: string): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'escapePathArgument',
|
||||
args: [pathArgument],
|
||||
});
|
||||
return `[${ShellArgumentEscaperStub.name}] ${pathArgument}`;
|
||||
}
|
||||
}
|
||||
24
tests/unit/shared/Stubs/ShellCommandRunnerStub.ts
Normal file
24
tests/unit/shared/Stubs/ShellCommandRunnerStub.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ShellCommandOutcome, ShellCommandRunner } from '@/infrastructure/CodeRunner/Execution/CommandDefinition/Runner/ShellRunner/ShellCommandRunner';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ShellCommandRunnerStub
|
||||
extends StubWithObservableMethodCalls<ShellCommandRunner>
|
||||
implements ShellCommandRunner {
|
||||
private outcome: ShellCommandOutcome = {
|
||||
type: 'RegularProcessExit',
|
||||
exitCode: 0,
|
||||
};
|
||||
|
||||
public withOutcome(outcome: ShellCommandOutcome): this {
|
||||
this.outcome = outcome;
|
||||
return this;
|
||||
}
|
||||
|
||||
public runShellCommand(command: string): Promise<ShellCommandOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'runShellCommand',
|
||||
args: [command],
|
||||
});
|
||||
return Promise.resolve(this.outcome);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user