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:
undergroundwires
2024-04-30 15:04:59 +02:00
parent 694bf1a74d
commit 8c17396285
49 changed files with 2097 additions and 606 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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