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:
@@ -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