Show save/execution error dialogs on desktop #264

This commit introduces system-native error dialogs on desktop
application for code save or execution failures, addressing user confusion
described in issue #264.

This commit adds informative feedback when script execution or saving
fails.

Changes:

- Implement support for system-native error dialogs.
- Refactor `CodeRunner` and `Dialog` interfaces and their
  implementations to improve error handling and provide better type
  safety.
- Introduce structured error handling, allowing UI to display detailed
  error messages.
- Replace error throwing with an error object interface for controlled
  handling. This ensures that errors are propagated to the renderer
  process without being limited by Electron's error object
  serialization limitations as detailed in electron/electron#24427.
- Add logging for dialog actions to aid in troubleshooting.
- Rename `fileName` to `defaultFilename` in `saveFile` functions
  to clarify its purpose.
- Centralize message assertion in `LoggerStub` for consistency.
- Introduce `expectTrue` in tests for clearer boolean assertions.
- Standardize `filename` usage across the codebase.
- Enhance existing test names and organization for clarity.
- Update related documentation.
This commit is contained in:
undergroundwires
2024-01-14 22:35:53 +01:00
parent c546a33eff
commit e09db0f1bd
48 changed files with 1986 additions and 578 deletions

View File

@@ -8,12 +8,13 @@ import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystem
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
describe('PersistentDirectoryProvider', () => {
describe('createDirectory', () => {
describe('path generation', () => {
it('uses user directory as base', async () => {
describe('path construction', () => {
it('bases path on user directory', async () => {
// arrange
const expectedBaseDirectory = 'base-directory';
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
@@ -26,17 +27,18 @@ describe('PersistentDirectoryProvider', () => {
.withLocation(locationOps));
// act
const actualDirectoryResult = await context.createDirectory();
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
// assert
const actualBaseDirectory = actualDirectoryResult.split(pathSegmentSeparator)[0];
expectTrue(success);
const actualBaseDirectory = directoryAbsolutePath.split(pathSegmentSeparator)[0];
expect(actualBaseDirectory).to.equal(expectedBaseDirectory);
const calls = locationOps.callHistory.filter((call) => call.methodName === 'combinePaths');
expect(calls.length).to.equal(1);
const [combinedBaseDirectory] = calls[0].args;
expect(combinedBaseDirectory).to.equal(expectedBaseDirectory);
});
it('appends execution subdirectory', async () => {
it('includes execution subdirectory in path', async () => {
// arrange
const expectedSubdirectory = ExecutionSubdirectory;
const pathSegmentSeparator = '/STUB-SEGMENT-SEPARATOR/';
@@ -46,10 +48,11 @@ describe('PersistentDirectoryProvider', () => {
.withLocation(locationOps));
// act
const actualDirectoryResult = await context.createDirectory();
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
// assert
const actualSubdirectory = actualDirectoryResult
expectTrue(success);
const actualSubdirectory = directoryAbsolutePath
.split(pathSegmentSeparator)
.pop();
expect(actualSubdirectory).to.equal(expectedSubdirectory);
@@ -58,7 +61,7 @@ describe('PersistentDirectoryProvider', () => {
const [,combinedSubdirectory] = calls[0].args;
expect(combinedSubdirectory).to.equal(expectedSubdirectory);
});
it('correctly forms the full path', async () => {
it('forms full path correctly', async () => {
// arrange
const pathSegmentSeparator = '/';
const baseDirectory = 'base-directory';
@@ -71,14 +74,15 @@ describe('PersistentDirectoryProvider', () => {
));
// act
const actualDirectory = await context.createDirectory();
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
// assert
expect(actualDirectory).to.equal(expectedDirectory);
expectTrue(success);
expect(directoryAbsolutePath).to.equal(expectedDirectory);
});
});
describe('directory creation', () => {
it('creates directory recursively', async () => {
it('creates directory with recursion', async () => {
// arrange
const expectedIsRecursive = true;
const filesystem = new FileSystemOpsStub();
@@ -86,48 +90,100 @@ describe('PersistentDirectoryProvider', () => {
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
// act
const expectedDir = await context.createDirectory();
const { success, directoryAbsolutePath } = await context.provideScriptDirectory();
// assert
expectTrue(success);
const calls = filesystem.callHistory.filter((call) => call.methodName === 'createDirectory');
expect(calls.length).to.equal(1);
const [actualPath, actualIsRecursive] = calls[0].args;
expect(actualPath).to.equal(expectedDir);
expect(actualPath).to.equal(directoryAbsolutePath);
expect(actualIsRecursive).to.equal(expectedIsRecursive);
});
it('logs error when creation fails', async () => {
// arrange
const logger = new LoggerStub();
const filesystem = new FileSystemOpsStub();
filesystem.createDirectory = () => { throw new Error(); };
const context = new PersistentDirectoryProviderTestSetup()
.withLogger(logger)
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedErrorType: CodeRunErrorType;
readonly expectedErrorMessage: string;
buildFaultyContext(
setup: PersistentDirectoryProviderTestSetup,
errorMessage: string,
): PersistentDirectoryProviderTestSetup;
}> = [
{
description: 'path combination failure',
expectedErrorType: 'DirectoryCreationError',
expectedErrorMessage: 'Error when combining paths',
buildFaultyContext: (setup, errorMessage) => {
const locationStub = new LocationOpsStub();
locationStub.combinePaths = () => {
throw new Error(errorMessage);
};
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
},
},
{
description: 'user data retrieval failure',
expectedErrorType: 'DirectoryCreationError',
expectedErrorMessage: 'Error when locating user data directory',
buildFaultyContext: (setup, errorMessage) => {
const operatingSystemStub = new OperatingSystemOpsStub();
operatingSystemStub.getUserDataDirectory = () => {
throw new Error(errorMessage);
};
return setup.withSystem(
new SystemOperationsStub().withOperatingSystem(operatingSystemStub),
);
},
},
{
description: 'directory creation failure',
expectedErrorType: 'DirectoryCreationError',
expectedErrorMessage: 'Error when creating directory',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOpsStub();
fileSystemStub.createDirectory = () => {
throw new Error(errorMessage);
};
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new PersistentDirectoryProviderTestSetup(),
expectedErrorMessage,
);
// act
try {
await context.createDirectory();
} catch {
// swallow
}
// act
const { success, error } = await context.provideScriptDirectory();
// assert
const errorCall = logger.callHistory.find((c) => c.methodName === 'error');
expectExists(errorCall);
});
it('throws error on creation failure', async () => {
// arrange
const expectedError = 'expected file system error';
const filesystem = new FileSystemOpsStub();
filesystem.createDirectory = () => { throw new Error(expectedError); };
const context = new PersistentDirectoryProviderTestSetup()
.withSystem(new SystemOperationsStub().withFileSystem(filesystem));
// 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 PersistentDirectoryProviderTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
);
// act
const act = () => context.createDirectory();
// act
await context.provideScriptDirectory();
// assert
await expectThrowsAsync(act, expectedError);
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
});
});
});
@@ -148,7 +204,7 @@ class PersistentDirectoryProviderTestSetup {
return this;
}
public createDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> {
public provideScriptDirectory(): ReturnType<PersistentDirectoryProvider['provideScriptDirectory']> {
const provider = new PersistentDirectoryProvider(this.system, this.logger);
return provider.provideScriptDirectory();
}

View File

@@ -38,7 +38,7 @@ describe('TimestampedFilenameGenerator', () => {
const filename = generateFilenamePartsForTesting({ date });
// assert
expect(filename.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[
`Generated file name: ${filename.generatedFilename}`
`Generated filename: ${filename.generatedFilename}`
]);
});
describe('extension', () => {
@@ -49,7 +49,7 @@ describe('TimestampedFilenameGenerator', () => {
const filename = generateFilenamePartsForTesting({ extension: expectedExtension });
// assert
expect(filename.extension).to.equal(expectedExtension, formatAssertionMessage[
`Generated file name: ${filename.generatedFilename}`
`Generated filename: ${filename.generatedFilename}`
]);
});
describe('handles absent extension', () => {

View File

@@ -11,19 +11,21 @@ import { FilenameGeneratorStub } from '@tests/unit/shared/Stubs/FilenameGenerato
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
import { ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
import { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
describe('ScriptFileCreationOrchestrator', () => {
describe('createScriptFile', () => {
describe('path generation', () => {
it('generates correct directory path', async () => {
it('correctly generates directory path', async () => {
// arrange
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
const expectedScriptDirectory = '/expected-script-directory';
const filesystem = new FileSystemOpsStub();
const context = new ScriptFileCreationOrchestratorTestSetup()
.withSystemOperations(new SystemOperationsStub()
const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub()
.withLocation(
new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator),
)
@@ -33,45 +35,47 @@ describe('ScriptFileCreationOrchestrator', () => {
);
// act
const actualFilePath = await context.createScriptFile();
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
// assert
const actualDirectory = actualFilePath
expectTrue(success);
const actualDirectory = scriptFileAbsolutePath
.split(pathSegmentSeparator)
.slice(0, -1)
.join(pathSegmentSeparator);
expect(actualDirectory).to.equal(expectedScriptDirectory, formatAssertionMessage([
`Actual file path: ${actualFilePath}`,
`Actual file path: ${scriptFileAbsolutePath}`,
]));
});
it('generates correct file name', async () => {
it('correctly generates filename', async () => {
// arrange
const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/';
const filesystem = new FileSystemOpsStub();
const expectedFilename = 'expected-script-file-name';
const context = new ScriptFileCreationOrchestratorTestSetup()
const context = new ScriptFileCreatorTestSetup()
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename))
.withSystemOperations(new SystemOperationsStub()
.withSystem(new SystemOperationsStub()
.withFileSystem(filesystem)
.withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator)));
// act
const actualFilePath = await context.createScriptFile();
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
// assert
const actualFileName = actualFilePath
expectTrue(success);
const actualFileName = scriptFileAbsolutePath
.split(pathSegmentSeparator)
.pop();
expect(actualFileName).to.equal(expectedFilename);
});
it('generates file name using specified parts', async () => {
it('uses specified parts to generate filename', async () => {
// arrange
const expectedParts: ScriptFileNameParts = {
const expectedParts: ScriptFilenameParts = {
scriptName: 'expected-script-name',
scriptFileExtension: 'expected-script-file-extension',
};
const filenameGeneratorStub = new FilenameGeneratorStub();
const context = new ScriptFileCreationOrchestratorTestSetup()
const context = new ScriptFileCreatorTestSetup()
.withFileNameParts(expectedParts)
.withFilenameGenerator(filenameGeneratorStub);
@@ -79,58 +83,60 @@ describe('ScriptFileCreationOrchestrator', () => {
await context.createScriptFile();
// assert
const fileNameGenerationCalls = filenameGeneratorStub.callHistory.filter((c) => c.methodName === 'generateFilename');
expect(fileNameGenerationCalls).to.have.lengthOf(1);
const callArguments = fileNameGenerationCalls[0].args;
const filenameGenerationCalls = filenameGeneratorStub.callHistory.filter((c) => c.methodName === 'generateFilename');
expect(filenameGenerationCalls).to.have.lengthOf(1);
const callArguments = filenameGenerationCalls[0].args;
const [scriptNameFileParts] = callArguments;
expectExists(scriptNameFileParts, `Call arguments: ${JSON.stringify(callArguments)}`);
expect(scriptNameFileParts).to.equal(expectedParts);
});
it('generates complete file path', async () => {
it('correctly generates complete file path', async () => {
// arrange
const expectedPath = 'expected-script-path';
const fileName = 'file-name';
const filename = 'filename';
const directoryPath = 'directory-path';
const filesystem = new FileSystemOpsStub();
const context = new ScriptFileCreationOrchestratorTestSetup()
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(fileName))
const context = new ScriptFileCreatorTestSetup()
.withFilenameGenerator(new FilenameGeneratorStub().withFilename(filename))
.withDirectoryProvider(new ScriptDirectoryProviderStub().withDirectoryPath(directoryPath))
.withSystemOperations(new SystemOperationsStub()
.withSystem(new SystemOperationsStub()
.withFileSystem(filesystem)
.withLocation(
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, fileName),
new LocationOpsStub().withJoinResult(expectedPath, directoryPath, filename),
));
// act
const actualFilePath = await context.createScriptFile();
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
// assert
expect(actualFilePath).to.equal(expectedPath);
expectTrue(success);
expect(scriptFileAbsolutePath).to.equal(expectedPath);
});
});
describe('file writing', () => {
it('writes file to the generated path', async () => {
it('writes to generated file path', async () => {
// arrange
const filesystem = new FileSystemOpsStub();
const context = new ScriptFileCreationOrchestratorTestSetup()
.withSystemOperations(new SystemOperationsStub()
const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub()
.withFileSystem(filesystem));
// act
const expectedPath = await context.createScriptFile();
const { success, scriptFileAbsolutePath } = await context.createScriptFile();
// assert
expectTrue(success);
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
expect(calls.length).to.equal(1);
const [actualFilePath] = calls[0].args;
expect(actualFilePath).to.equal(expectedPath);
expect(actualFilePath).to.equal(scriptFileAbsolutePath);
});
it('writes provided script content to file', async () => {
it('writes script content to file', async () => {
// arrange
const expectedCode = 'expected-code';
const filesystem = new FileSystemOpsStub();
const context = new ScriptFileCreationOrchestratorTestSetup()
.withSystemOperations(new SystemOperationsStub().withFileSystem(filesystem))
const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub().withFileSystem(filesystem))
.withFileContents(expectedCode);
// act
@@ -143,10 +149,119 @@ describe('ScriptFileCreationOrchestrator', () => {
expect(actualData).to.equal(expectedCode);
});
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedErrorType: CodeRunErrorType;
readonly expectedErrorMessage: string;
readonly expectLogs: boolean;
buildFaultyContext(
setup: ScriptFileCreatorTestSetup,
errorMessage: string,
errorType: CodeRunErrorType,
): ScriptFileCreatorTestSetup;
}> = [
{
description: 'path combination failure',
expectedErrorType: 'FilePathGenerationError',
expectedErrorMessage: 'Error when combining paths',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const locationStub = new LocationOpsStub();
locationStub.combinePaths = () => {
throw new Error(errorMessage);
};
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
},
},
{
description: 'file writing failure',
expectedErrorType: 'FileWriteError',
expectedErrorMessage: 'Error when writing to file',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOpsStub();
fileSystemStub.writeToFile = () => {
throw new Error(errorMessage);
};
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
},
},
{
description: 'filename generation failure',
expectedErrorType: 'FilePathGenerationError',
expectedErrorMessage: 'Error when writing to file',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => {
const filenameGenerator = new FilenameGeneratorStub();
filenameGenerator.generateFilename = () => {
throw new Error(errorMessage);
};
return setup.withFilenameGenerator(filenameGenerator);
},
},
{
description: 'script directory provision failure',
expectedErrorType: 'DirectoryCreationError',
expectedErrorMessage: 'Error when providing directory',
expectLogs: false,
buildFaultyContext: (setup, errorMessage, errorType) => {
const directoryProvider = new ScriptDirectoryProviderStub();
directoryProvider.provideScriptDirectory = () => Promise.resolve({
success: false,
error: {
message: errorMessage,
type: errorType,
},
});
return setup.withDirectoryProvider(directoryProvider);
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new ScriptFileCreatorTestSetup(),
expectedErrorMessage,
expectedErrorType,
);
// act
const { success, error } = await context.createScriptFile();
// assert
expect(success).to.equal(false);
expectExists(error);
expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType);
});
if (expectLogs) {
it(`logs error: ${description}`, async () => {
// arrange
const loggerStub = new LoggerStub();
const context = buildFaultyContext(
new ScriptFileCreatorTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
expectedErrorType,
);
// act
await context.createScriptFile();
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
}
});
});
});
});
class ScriptFileCreationOrchestratorTestSetup {
class ScriptFileCreatorTestSetup {
private system: SystemOperations = new SystemOperationsStub();
private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub();
@@ -155,11 +270,11 @@ class ScriptFileCreationOrchestratorTestSetup {
private logger: Logger = new LoggerStub();
private fileContents = `[${ScriptFileCreationOrchestratorTestSetup.name}] script file contents`;
private fileContents = `[${ScriptFileCreatorTestSetup.name}] script file contents`;
private fileNameParts: ScriptFileNameParts = {
scriptName: `[${ScriptFileCreationOrchestratorTestSetup.name}] script name`,
scriptFileExtension: `[${ScriptFileCreationOrchestratorTestSetup.name}] file extension`,
private filenameParts: ScriptFilenameParts = {
scriptName: `[${ScriptFileCreatorTestSetup.name}] script name`,
scriptFileExtension: `[${ScriptFileCreatorTestSetup.name}] file extension`,
};
public withFileContents(fileContents: string): this {
@@ -177,13 +292,18 @@ class ScriptFileCreationOrchestratorTestSetup {
return this;
}
public withSystemOperations(system: SystemOperations): this {
public withSystem(system: SystemOperations): this {
this.system = system;
return this;
}
public withFileNameParts(fileNameParts: ScriptFileNameParts): this {
this.fileNameParts = fileNameParts;
public withFileNameParts(filenameParts: ScriptFilenameParts): this {
this.filenameParts = filenameParts;
return this;
}
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
@@ -194,6 +314,6 @@ class ScriptFileCreationOrchestratorTestSetup {
this.directoryProvider,
this.logger,
);
return creator.createScriptFile(this.fileContents, this.fileNameParts);
return creator.createScriptFile(this.fileContents, this.filenameParts);
}
}

View File

@@ -1,5 +1,4 @@
import { describe, it, expect } from 'vitest';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems';
import { VisibleTerminalScriptExecutor } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
@@ -9,41 +8,12 @@ import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsS
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
import { SystemOperations } from '@/infrastructure/CodeRunner/System/SystemOperations';
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { Logger } from '@/application/Common/Log/Logger';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('VisibleTerminalScriptFileExecutor', () => {
describe('executeScriptFile', () => {
describe('throws error for invalid operating systems', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly invalidOs?: OperatingSystem;
readonly expectedError: string;
}> = [
(() => {
const unsupportedOs = OperatingSystem.Android;
return {
description: 'unsupported OS',
invalidOs: unsupportedOs,
expectedError: `Unsupported operating system: ${OperatingSystem[unsupportedOs]}`,
};
})(),
{
description: 'undefined OS',
invalidOs: undefined,
expectedError: 'Unknown operating system',
},
];
testScenarios.forEach(({ description, invalidOs, expectedError }) => {
it(description, async () => {
// arrange
const context = new ScriptFileTestSetup()
.withOs(invalidOs);
// act
const act = async () => { await context.executeScriptFile(); };
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
describe('command execution', () => {
// arrange
const testScenarios: Record<SupportedOperatingSystem, readonly {
@@ -88,10 +58,10 @@ describe('VisibleTerminalScriptFileExecutor', () => {
testScenarios[operatingSystem].forEach((
{ description, filePath, expectedCommand },
) => {
it(description, async () => {
it(`executes command - ${description}`, async () => {
// arrange
const command = new CommandOpsStub();
const context = new ScriptFileTestSetup()
const context = new ScriptFileExecutorTestSetup()
.withOs(operatingSystem)
.withFilePath(filePath)
.withSystemOperations(new SystemOperationsStub().withCommand(command));
@@ -124,7 +94,7 @@ describe('VisibleTerminalScriptFileExecutor', () => {
isExecutedAfterPermissions = isPermissionsSet;
return Promise.resolve();
};
const context = new ScriptFileTestSetup()
const context = new ScriptFileExecutorTestSetup()
.withSystemOperations(new SystemOperationsStub()
.withFileSystem(fileSystemMock)
.withCommand(commandMock));
@@ -139,7 +109,7 @@ describe('VisibleTerminalScriptFileExecutor', () => {
// arrange
const expectedMode = '755';
const fileSystem = new FileSystemOpsStub();
const context = new ScriptFileTestSetup()
const context = new ScriptFileExecutorTestSetup()
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
// act
@@ -151,11 +121,11 @@ describe('VisibleTerminalScriptFileExecutor', () => {
const [, actualMode] = calls[0].args;
expect(actualMode).to.equal(expectedMode);
});
it('sets permissions on the correct file', async () => {
it('sets permissions for correct file', async () => {
// arrange
const expectedFilePath = 'expected-file-path';
const fileSystem = new FileSystemOpsStub();
const context = new ScriptFileTestSetup()
const context = new ScriptFileExecutorTestSetup()
.withFilePath(expectedFilePath)
.withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem));
@@ -169,16 +139,121 @@ describe('VisibleTerminalScriptFileExecutor', () => {
expect(actualFilePath).to.equal(expectedFilePath);
});
});
it('indicates success on successful execution', async () => {
// arrange
const expectedSuccessResult = true;
const context = new ScriptFileExecutorTestSetup();
// act
const { success: actualSuccessValue } = await context.executeScriptFile();
// assert
expect(actualSuccessValue).to.equal(expectedSuccessResult);
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedErrorType: CodeRunErrorType;
readonly expectedErrorMessage: string;
buildFaultyContext(
setup: ScriptFileExecutorTestSetup,
errorMessage: string,
): ScriptFileExecutorTestSetup;
}> = [
{
description: 'unindentified os',
expectedErrorType: 'UnsupportedOperatingSystem',
expectedErrorMessage: 'Operating system could not be identified from environment',
buildFaultyContext: (setup) => {
return setup.withOs(undefined);
},
},
{
description: 'unsupported OS',
expectedErrorType: 'UnsupportedOperatingSystem',
expectedErrorMessage: `Unsupported operating system: ${OperatingSystem[OperatingSystem.Android]}`,
buildFaultyContext: (setup) => {
return setup.withOs(OperatingSystem.Android);
},
},
{
description: 'file permissions failure',
expectedErrorType: 'FileExecutionError',
expectedErrorMessage: 'Error when setting file permissions',
buildFaultyContext: (setup, errorMessage) => {
const fileSystem = new FileSystemOpsStub();
fileSystem.setFilePermissions = () => Promise.reject(errorMessage);
return setup.withSystemOperations(
new SystemOperationsStub().withFileSystem(fileSystem),
);
},
},
{
description: 'command failure',
expectedErrorType: 'FileExecutionError',
expectedErrorMessage: 'Error when setting file permissions',
buildFaultyContext: (setup, errorMessage) => {
const command = new CommandOpsStub();
command.exec = () => Promise.reject(errorMessage);
return setup.withSystemOperations(
new SystemOperationsStub().withCommand(command),
);
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new ScriptFileExecutorTestSetup(),
expectedErrorMessage,
);
// act
const { success, error } = await context.executeScriptFile();
// assert
expect(success).to.equal(false);
expectExists(error);
expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType);
});
it(`logs error - ${description}`, async () => {
// arrange
const loggerStub = new LoggerStub();
const context = buildFaultyContext(
new ScriptFileExecutorTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
);
// act
await context.executeScriptFile();
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
});
});
});
});
class ScriptFileTestSetup {
class ScriptFileExecutorTestSetup {
private os?: OperatingSystem = OperatingSystem.Windows;
private filePath = `[${ScriptFileTestSetup.name}] file path`;
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;
@@ -194,10 +269,9 @@ class ScriptFileTestSetup {
return this;
}
public executeScriptFile(): Promise<void> {
public executeScriptFile() {
const environment = new RuntimeEnvironmentStub().withOs(this.os);
const logger = new LoggerStub();
const executor = new VisibleTerminalScriptExecutor(this.system, logger, environment);
const executor = new VisibleTerminalScriptExecutor(this.system, this.logger, environment);
return executor.executeScriptFile(this.filePath);
}
}

View File

@@ -2,142 +2,178 @@ import { describe, it, expect } from 'vitest';
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { Logger } from '@/application/Common/Log/Logger';
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
import { ScriptFileExecutorStub } from '@tests/unit/shared/Stubs/ScriptFileExecutorStub';
import { ScriptFileCreator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
import { ScriptFileCreatorStub } from '@tests/unit/shared/Stubs/ScriptFileCreatorStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
describe('ScriptFileCodeRunner', () => {
describe('runCode', () => {
it('executes script file correctly', async () => {
// arrange
const expectedFilePath = 'expected script path';
const fileExecutor = new ScriptFileExecutorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(new ScriptFileCreatorStub().withCreatedFilePath(expectedFilePath))
.withFileExecutor(fileExecutor);
describe('creating file', () => {
it('uses provided code', async () => {
// arrange
const expectedCode = 'expected code';
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator)
.withCode(expectedCode);
// act
await context.runCode();
// act
await context.runCode();
// assert
const executeCalls = fileExecutor.callHistory.filter((call) => call.methodName === 'executeScriptFile');
expect(executeCalls.length).to.equal(1);
const [actualPath] = executeCalls[0].args;
expect(actualPath).to.equal(expectedFilePath);
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [actualCode] = createCalls[0].args;
expect(actualCode).to.equal(expectedCode);
});
it('uses provided extension', async () => {
// arrange
const expectedFileExtension = 'expected-file-extension';
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator)
.withFileExtension(expectedFileExtension);
// act
await context.runCode();
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [,scriptFileNameParts] = createCalls[0].args;
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
expect(scriptFileNameParts.scriptFileExtension).to.equal(expectedFileExtension);
});
it('uses default script name', async () => {
// arrange
const expectedScriptName = ScriptFilename;
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator);
// act
await context.runCode();
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [,scriptFileNameParts] = createCalls[0].args;
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
expect(scriptFileNameParts.scriptName).to.equal(expectedScriptName);
});
});
it('creates script file with provided code', async () => {
// arrange
const expectedCode = 'expected code';
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator)
.withCode(expectedCode);
describe('executing file', () => {
it('executes at correct path', async () => {
// arrange
const expectedFilePath = 'expected script path';
const fileExecutor = new ScriptFileExecutorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(new ScriptFileCreatorStub().withCreatedFilePath(expectedFilePath))
.withFileExecutor(fileExecutor);
// act
await context.runCode();
// act
await context.runCode();
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [actualCode] = createCalls[0].args;
expect(actualCode).to.equal(expectedCode);
// assert
const executeCalls = fileExecutor.callHistory.filter((call) => call.methodName === 'executeScriptFile');
expect(executeCalls.length).to.equal(1);
const [actualPath] = executeCalls[0].args;
expect(actualPath).to.equal(expectedFilePath);
});
});
it('creates script file with provided extension', async () => {
// arrange
const expectedFileExtension = 'expected-file-extension';
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator)
.withFileExtension(expectedFileExtension);
describe('successful run', () => {
it('indicates success', async () => {
// arrange
const expectedSuccessResult = true;
const context = new CodeRunnerTestSetup();
// act
await context.runCode();
// act
const { success: actualSuccessValue } = await context.runCode();
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [,scriptFileNameParts] = createCalls[0].args;
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
expect(scriptFileNameParts.scriptFileExtension).to.equal(expectedFileExtension);
});
it('creates script file with provided name', async () => {
// arrange
const expectedScriptName = ScriptFileName;
const fileCreator = new ScriptFileCreatorStub();
const context = new CodeRunnerTestSetup()
.withFileCreator(fileCreator);
// assert
expect(actualSuccessValue).to.equal(expectedSuccessResult);
});
it('logs success message', async () => {
// arrange
const expectedMessagePart = 'Successfully ran script';
const logger = new LoggerStub();
const context = new CodeRunnerTestSetup()
.withLogger(logger);
// act
await context.runCode();
// act
await context.runCode();
// assert
const createCalls = fileCreator.callHistory.filter((call) => call.methodName === 'createScriptFile');
expect(createCalls.length).to.equal(1);
const [,scriptFileNameParts] = createCalls[0].args;
expectExists(scriptFileNameParts, JSON.stringify(`Call args: ${JSON.stringify(createCalls[0].args)}`));
expect(scriptFileNameParts.scriptName).to.equal(expectedScriptName);
// assert
logger.assertLogsContainMessagePart('info', expectedMessagePart);
});
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly injectedException: Error;
readonly faultyContext: CodeRunnerTestSetup;
readonly expectedErrorType: CodeRunErrorType;
readonly expectedErrorMessage: string;
buildFaultyContext(
setup: CodeRunnerTestSetup,
errorMessage: string,
errorType: CodeRunErrorType,
): CodeRunnerTestSetup;
}> = [
(() => {
const error = new Error('Test Error: Script file execution intentionally failed for testing purposes.');
const executor = new ScriptFileExecutorStub();
executor.executeScriptFile = () => {
throw error;
};
return {
description: 'fails to execute script file',
injectedException: error,
faultyContext: new CodeRunnerTestSetup().withFileExecutor(executor),
};
})(),
(() => {
const error = new Error('Test Error: Script file creation intentionally failed for testing purposes.');
const creator = new ScriptFileCreatorStub();
creator.createScriptFile = () => {
throw error;
};
return {
description: 'fails to create script file',
injectedException: error,
faultyContext: new CodeRunnerTestSetup().withFileCreator(creator),
};
})(),
{
description: 'execution failure',
expectedErrorType: 'FileExecutionError',
expectedErrorMessage: 'execution error',
buildFaultyContext: (setup, errorMessage, errorType) => {
const executor = new ScriptFileExecutorStub();
executor.executeScriptFile = () => Promise.resolve({
success: false,
error: {
message: errorMessage,
type: errorType,
},
});
return setup.withFileExecutor(executor);
},
},
{
description: 'creation failure',
expectedErrorType: 'FileWriteError',
expectedErrorMessage: 'creation error',
buildFaultyContext: (setup, errorMessage, errorType) => {
const creator = new ScriptFileCreatorStub();
creator.createScriptFile = () => Promise.resolve({
success: false,
error: {
message: errorMessage,
type: errorType,
},
});
return setup.withFileCreator(creator);
},
},
];
describe('logs errors', () => {
testScenarios.forEach(({ description, faultyContext }) => {
it(`logs error when ${description}`, async () => {
// arrange
const logger = new LoggerStub();
faultyContext.withLogger(logger);
// act
try {
await faultyContext.runCode();
} catch {
// Swallow
}
// assert
const errorCall = logger.callHistory.find((c) => c.methodName === 'error');
expectExists(errorCall);
});
});
});
describe('rethrows errors', () => {
testScenarios.forEach(({ description, injectedException, faultyContext }) => {
it(`rethrows error when ${description}`, async () => {
// act
const act = () => faultyContext.runCode();
// assert
await expectThrowsAsync(act, injectedException.message);
});
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
}) => {
it(`handles ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new CodeRunnerTestSetup(),
expectedErrorMessage,
expectedErrorType,
);
// act
const { success, error } = await context.runCode();
// assert
expect(success).to.equal(false);
expectExists(error);
expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType);
});
});
});
@@ -155,13 +191,14 @@ class CodeRunnerTestSetup {
private logger: Logger = new LoggerStub();
public async runCode(): Promise<void> {
public runCode() {
const runner = new ScriptFileCodeRunner(
this.fileExecutor,
this.fileCreator,
this.logger,
);
await runner.runCode(this.code, this.fileExtension);
return runner
.runCode(this.code, this.fileExtension);
}
public withFileExecutor(fileExecutor: ScriptFileExecutor): this {