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:
@@ -6,10 +6,10 @@ import { describe, it } from 'vitest';
|
||||
import { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
import { ScriptFileCreationOrchestrator } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreationOrchestrator';
|
||||
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
|
||||
import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { LinuxTerminalEmulator } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
|
||||
describe('ScriptFileCodeRunner', () => {
|
||||
it('executes simple script correctly', async ({ skip }) => {
|
||||
@@ -23,9 +23,12 @@ describe('ScriptFileCodeRunner', () => {
|
||||
const codeRunner = createCodeRunner(temporaryDirectoryProvider);
|
||||
const args = getPlatformSpecificArguments(currentOperatingSystem);
|
||||
// act
|
||||
const act = () => codeRunner.runCode(...args);
|
||||
const { success, error } = await codeRunner.runCode(...args);
|
||||
// assert
|
||||
await expectDoesNotThrowAsync(act);
|
||||
expect(success).to.equal(true, formatAssertionMessage([
|
||||
'Failed to successfully execute the script.',
|
||||
'Details:', JSON.stringify(error),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +91,10 @@ function createTemporaryDirectoryProvider(): ScriptDirectoryProvider {
|
||||
provideScriptDirectory: async () => {
|
||||
const temporaryDirectoryPathPrefix = join(tmpdir(), 'privacy-sexy-tests-');
|
||||
const temporaryDirectoryFullPath = await mkdtemp(temporaryDirectoryPathPrefix);
|
||||
return temporaryDirectoryFullPath;
|
||||
return {
|
||||
success: true,
|
||||
directoryAbsolutePath: temporaryDirectoryFullPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
18
tests/shared/Assertions/ExpectTrue.ts
Normal file
18
tests/shared/Assertions/ExpectTrue.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Asserts that the provided boolean value is true.
|
||||
*
|
||||
* Useful when TypeScript's control flow analysis does not recognize standard
|
||||
* assertions, ensuring `value` is treated as true in subsequent code. This helps
|
||||
* prevent type errors and improves code safety and clarity. An optional custom
|
||||
* error message can be provided for more detailed assertion failures.
|
||||
*/
|
||||
export function expectTrue(value: boolean, errorMessage?: string): asserts value is true {
|
||||
if (value !== true) {
|
||||
throw new Error([
|
||||
`Assertion failed: Expected true, received ${value.toString()}.`,
|
||||
'Assertion failed: expected value is not true.',
|
||||
...(typeof value !== 'boolean' ? [`Received type: ${typeof value}`] : []),
|
||||
...(errorMessage ? [errorMessage] : []),
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystem
|
||||
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
|
||||
import { 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();
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,31 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { BrowserDialog, WindowDialogAccessor } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||
import { BrowserSaveFileDialog } from '@/infrastructure/Dialog/Browser/BrowserSaveFileDialog';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
|
||||
describe('BrowserDialog', () => {
|
||||
describe('saveFile', () => {
|
||||
it('passes correct arguments', () => {
|
||||
it('forwards arguments', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'test content';
|
||||
const expectedFileName = 'test.sh';
|
||||
const expectedFileType = FileType.ShellScript;
|
||||
const expectedSaveFileArgs = createTestSaveFileArguments();
|
||||
let actualSaveFileArgs: Parameters<BrowserSaveFileDialog['saveFile']> | undefined;
|
||||
const fileSaverDialogSpy: BrowserSaveFileDialog = {
|
||||
saveFile: (...args) => {
|
||||
actualSaveFileArgs = args;
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
|
||||
const browserDialog = new BrowserDialogBuilder()
|
||||
.withBrowserSaveFileDialog(fileSaverDialogSpy)
|
||||
.build();
|
||||
|
||||
// act
|
||||
browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
|
||||
await browserDialog.saveFile(...expectedSaveFileArgs);
|
||||
|
||||
// assert
|
||||
expect(actualSaveFileArgs)
|
||||
.to
|
||||
.deep
|
||||
.equal([expectedFileContents, expectedFileName, expectedFileType]);
|
||||
expect(actualSaveFileArgs).to.deep.equal(expectedSaveFileArgs);
|
||||
});
|
||||
it('forwards outcome', async () => {
|
||||
// arrange
|
||||
const expectedResult: SaveFileOutcome = {
|
||||
success: true,
|
||||
};
|
||||
const fileSaverDialogMock: BrowserSaveFileDialog = {
|
||||
saveFile: () => expectedResult,
|
||||
};
|
||||
const browserDialog = new BrowserDialogBuilder()
|
||||
.withBrowserSaveFileDialog(fileSaverDialogMock)
|
||||
.build();
|
||||
|
||||
// act
|
||||
const actualResult = await browserDialog.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
expect(actualResult).to.equal(expectedResult);
|
||||
});
|
||||
});
|
||||
describe('showError', () => {
|
||||
it('alerts with formatted error message', () => {
|
||||
// arrange
|
||||
const errorTitle = 'Expected Error Title';
|
||||
const errorMessage = 'expected error message';
|
||||
const expectedMessage = `❌ ${errorTitle}\n\n${errorMessage}`;
|
||||
let actualShowErrorArgs: Parameters<WindowDialogAccessor['alert']> | undefined;
|
||||
const windowDialogAccessorSpy: WindowDialogAccessor = {
|
||||
alert: (...args) => {
|
||||
actualShowErrorArgs = args;
|
||||
},
|
||||
};
|
||||
const browserDialog = new BrowserDialogBuilder()
|
||||
.withWindowDialogAccessor(windowDialogAccessorSpy)
|
||||
.build();
|
||||
|
||||
// act
|
||||
browserDialog.showError(errorTitle, errorMessage);
|
||||
|
||||
// assert
|
||||
expectExists(actualShowErrorArgs);
|
||||
const [actualMessage] = actualShowErrorArgs;
|
||||
expect(actualMessage).to.equal(expectedMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createTestSaveFileArguments(): Parameters<BrowserSaveFileDialog['saveFile']> {
|
||||
return [
|
||||
'test file content',
|
||||
'test filename',
|
||||
FileType.ShellScript,
|
||||
];
|
||||
}
|
||||
|
||||
class BrowserDialogBuilder {
|
||||
private browserSaveFileDialog: BrowserSaveFileDialog = {
|
||||
saveFile: () => ({ success: true }),
|
||||
};
|
||||
|
||||
private windowDialogAccessor: WindowDialogAccessor = {
|
||||
alert: () => { /* NOOP */ },
|
||||
};
|
||||
|
||||
public withBrowserSaveFileDialog(browserSaveFileDialog: BrowserSaveFileDialog): this {
|
||||
this.browserSaveFileDialog = browserSaveFileDialog;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withWindowDialogAccessor(windowDialogAccessor: WindowDialogAccessor): this {
|
||||
this.windowDialogAccessor = windowDialogAccessor;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new BrowserDialog(
|
||||
this.windowDialogAccessor,
|
||||
this.browserSaveFileDialog,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class SaveFileTestSetup {
|
||||
|
||||
private fileContents: string = `${SaveFileTestSetup.name} file contents`;
|
||||
|
||||
private fileName: string = `${SaveFileTestSetup.name} file name`;
|
||||
private filename: string = `${SaveFileTestSetup.name} filename`;
|
||||
|
||||
private fileType: FileType = FileType.BatchFile;
|
||||
|
||||
@@ -119,7 +119,7 @@ class SaveFileTestSetup {
|
||||
);
|
||||
return dialog.saveFile(
|
||||
this.fileContents,
|
||||
this.fileName,
|
||||
this.filename,
|
||||
this.fileType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
|
||||
import { FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { ElectronSaveFileDialog } from '@/infrastructure/Dialog/Electron/ElectronSaveFileDialog';
|
||||
import { ElectronDialog, ElectronDialogAccessor } from '@/infrastructure/Dialog/Electron/ElectronDialog';
|
||||
|
||||
describe('BrowserDialog', () => {
|
||||
describe('ElectronDialog', () => {
|
||||
describe('saveFile', () => {
|
||||
it('passes correct arguments', async () => {
|
||||
it('forwards arguments', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'test content';
|
||||
const expectedFileName = 'test.sh';
|
||||
const expectedFileType = FileType.ShellScript;
|
||||
const expectedSaveFileArgs = createTestSaveFileArguments();
|
||||
let actualSaveFileArgs: Parameters<ElectronSaveFileDialog['saveFile']> | undefined;
|
||||
const fileSaverDialogSpy: ElectronSaveFileDialog = {
|
||||
saveFile: (...args) => {
|
||||
actualSaveFileArgs = args;
|
||||
return Promise.resolve();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
|
||||
const electronDialog = new ElectronDialogBuilder()
|
||||
.withSaveFileDialog(fileSaverDialogSpy)
|
||||
.build();
|
||||
|
||||
// act
|
||||
await browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
|
||||
await electronDialog.saveFile(...expectedSaveFileArgs);
|
||||
|
||||
// assert
|
||||
expect(actualSaveFileArgs)
|
||||
.to
|
||||
.deep
|
||||
.equal([expectedFileContents, expectedFileName, expectedFileType]);
|
||||
expect(actualSaveFileArgs).to.deep.equal(expectedSaveFileArgs);
|
||||
});
|
||||
it('forwards outcome', async () => {
|
||||
// arrange
|
||||
const expectedResult: SaveFileOutcome = {
|
||||
success: true,
|
||||
};
|
||||
const fileSaverDialogMock: ElectronSaveFileDialog = {
|
||||
saveFile: () => Promise.resolve(expectedResult),
|
||||
};
|
||||
const browserDialog = new ElectronDialogBuilder()
|
||||
.withSaveFileDialog(fileSaverDialogMock)
|
||||
.build();
|
||||
|
||||
// act
|
||||
const actualResult = await browserDialog.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
expect(actualResult).to.equal(expectedResult);
|
||||
});
|
||||
});
|
||||
describe('showError', () => {
|
||||
it('forwards arguments', () => {
|
||||
// arrange
|
||||
const expectedShowErrorArguments: Parameters<ElectronDialog['showError']> = [
|
||||
'test title', 'test message',
|
||||
];
|
||||
let actualShowErrorArgs: Parameters<ElectronDialogAccessor['showErrorBox']> | undefined;
|
||||
const electronDialogAccessorSpy: ElectronDialogAccessor = {
|
||||
showErrorBox: (...args) => {
|
||||
actualShowErrorArgs = args;
|
||||
},
|
||||
};
|
||||
const electronDialog = new ElectronDialogBuilder()
|
||||
.withElectron(electronDialogAccessorSpy)
|
||||
.build();
|
||||
|
||||
// act
|
||||
electronDialog.showError(...expectedShowErrorArguments);
|
||||
|
||||
// assert
|
||||
expect(actualShowErrorArgs).to.deep.equal(expectedShowErrorArguments);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createTestSaveFileArguments(): Parameters<ElectronSaveFileDialog['saveFile']> {
|
||||
return [
|
||||
'test file content',
|
||||
'test filename',
|
||||
FileType.ShellScript,
|
||||
];
|
||||
}
|
||||
|
||||
class ElectronDialogBuilder {
|
||||
private electron: ElectronDialogAccessor = {
|
||||
showErrorBox: () => {},
|
||||
};
|
||||
|
||||
private saveFileDialog: ElectronSaveFileDialog = {
|
||||
saveFile: () => Promise.resolve({ success: true }),
|
||||
};
|
||||
|
||||
public withElectron(electron: ElectronDialogAccessor): this {
|
||||
this.electron = electron;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSaveFileDialog(saveFileDialog: ElectronSaveFileDialog): this {
|
||||
this.saveFileDialog = saveFileDialog;
|
||||
return this;
|
||||
}
|
||||
|
||||
public build() {
|
||||
return new ElectronDialog(this.saveFileDialog, this.electron);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import { FileType, SaveFileErrorType } from '@/presentation/common/Dialog';
|
||||
import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
@@ -8,14 +8,14 @@ import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperations
|
||||
import { NodeFileOperationsStub } from './NodeFileOperationsStub';
|
||||
|
||||
describe('NodeElectronSaveFileDialog', () => {
|
||||
describe('shows dialog with correct options', () => {
|
||||
describe('dialog options', () => {
|
||||
it('correct title', async () => {
|
||||
// arrange
|
||||
const expectedFileName = 'expected-file-name';
|
||||
const electronMock = new ElectronFileDialogOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withFileName(expectedFileName);
|
||||
.withDefaultFilename(expectedFileName);
|
||||
// act
|
||||
await context.saveFile();
|
||||
// assert
|
||||
@@ -52,7 +52,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
.withUserDownloadsPath(expectedParentDirectory);
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withFileName(expectedFileName)
|
||||
.withDefaultFilename(expectedFileName)
|
||||
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
||||
// act
|
||||
await context.saveFile();
|
||||
@@ -63,7 +63,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
electronMock,
|
||||
);
|
||||
});
|
||||
describe('correct filters', () => {
|
||||
describe('correct file type filters', () => {
|
||||
const defaultFilter: Electron.FileFilter = {
|
||||
name: 'All Files',
|
||||
extensions: ['*'],
|
||||
@@ -109,92 +109,224 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('saves the file when the dialog is not canceled', () => {
|
||||
it('writes to the selected file path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const isCancelled = false;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled)
|
||||
.withUserSelectedFilePath(expectedFilePath);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
describe('file saving process', () => {
|
||||
describe('when dialog is confirmed', () => {
|
||||
it('writes to the selected file path', async () => {
|
||||
// arrange
|
||||
const expectedFilePath = 'expected-file-path';
|
||||
const isCancelled = false;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled)
|
||||
.withUserSelectedFilePath(expectedFilePath);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = saveFileCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [actualFilePath] = saveFileCalls[0].args;
|
||||
expect(actualFilePath).to.equal(expectedFilePath);
|
||||
});
|
||||
it('writes the correct file contents', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'expected-file-contents';
|
||||
const isCancelled = false;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withFileContents(expectedFileContents)
|
||||
.withNode(nodeMock);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [,actualFileContents] = saveFileCalls[0].args;
|
||||
expect(actualFileContents).to.equal(expectedFileContents);
|
||||
});
|
||||
it('returns success status', async () => {
|
||||
// arrange
|
||||
const expectedSuccessValue = true;
|
||||
const context = new SaveFileDialogTestSetup();
|
||||
|
||||
// act
|
||||
const { success } = await context.saveFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(expectedSuccessValue);
|
||||
});
|
||||
});
|
||||
describe('when dialog is canceled', async () => {
|
||||
it('does not save file', async () => {
|
||||
// arrange
|
||||
const isCancelled = true;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
|
||||
it('writes the correct file contents', async () => {
|
||||
// arrange
|
||||
const expectedFileContents = 'expected-file-contents';
|
||||
const isCancelled = false;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withFileContents(expectedFileContents)
|
||||
.withNode(nodeMock);
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
// assert
|
||||
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCall).to.equal(undefined);
|
||||
});
|
||||
it('logs cancelation info', async () => {
|
||||
// arrange
|
||||
const expectedLogMessagePart = 'File save cancelled';
|
||||
const logger = new LoggerStub();
|
||||
const isCancelled = true;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withLogger(logger);
|
||||
|
||||
// assert
|
||||
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCalls).to.have.lengthOf(1);
|
||||
const [,actualFileContents] = saveFileCalls[0].args;
|
||||
expect(actualFileContents).to.equal(expectedFileContents);
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
logger.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||
});
|
||||
it('returns success', async () => {
|
||||
// arrange
|
||||
const expectedSuccessValue = true;
|
||||
const isCancelled = true;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock);
|
||||
|
||||
// act
|
||||
const { success } = await context.saveFile();
|
||||
|
||||
// assert
|
||||
expect(success).to.equal(expectedSuccessValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not save file when dialog is canceled', async () => {
|
||||
// arrange
|
||||
const isCancelled = true;
|
||||
const electronMock = new ElectronFileDialogOperationsStub()
|
||||
.withMimicUserCancel(isCancelled);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
describe('error handling', () => {
|
||||
const testScenarios: ReadonlyArray<{
|
||||
readonly description: string;
|
||||
readonly expectedErrorType: SaveFileErrorType;
|
||||
readonly expectedErrorMessage: string;
|
||||
buildFaultyContext(
|
||||
setup: SaveFileDialogTestSetup,
|
||||
errorMessage: string,
|
||||
): SaveFileDialogTestSetup;
|
||||
}> = [
|
||||
{
|
||||
description: 'file writing failure',
|
||||
expectedErrorType: 'FileCreationError',
|
||||
expectedErrorMessage: 'Error when writing file',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
nodeMock.writeFile = () => Promise.reject(new Error(errorMessage));
|
||||
return setup
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'user path retrieval failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when retrieving user path',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.getUserDownloadsPath = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup
|
||||
.withElectron(electronMock);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'path combination failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when combining paths',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
nodeMock.join = () => {
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
return setup
|
||||
.withNode(nodeMock);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'dialog display failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Error when showing save dialog',
|
||||
buildFaultyContext: (setup, errorMessage) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage));
|
||||
return setup
|
||||
.withElectron(electronMock);
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'unexpected dialog return value failure',
|
||||
expectedErrorType: 'DialogDisplayError',
|
||||
expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.',
|
||||
buildFaultyContext: (setup) => {
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
electronMock.showSaveDialog = () => Promise.resolve({
|
||||
canceled: false,
|
||||
filePath: undefined,
|
||||
});
|
||||
return setup
|
||||
.withElectron(electronMock);
|
||||
},
|
||||
},
|
||||
];
|
||||
testScenarios.forEach(({
|
||||
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
|
||||
}) => {
|
||||
it(`handles error - ${description}`, async () => {
|
||||
// arrange
|
||||
const context = buildFaultyContext(
|
||||
new SaveFileDialogTestSetup(),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
// act
|
||||
const { success, error } = await context.saveFile();
|
||||
|
||||
// assert
|
||||
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
|
||||
expect(saveFileCall).to.equal(undefined);
|
||||
});
|
||||
// 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 SaveFileDialogTestSetup()
|
||||
.withLogger(loggerStub),
|
||||
expectedErrorMessage,
|
||||
);
|
||||
|
||||
describe('logging', () => {
|
||||
it('logs an error if writing the file fails', async () => {
|
||||
// arrange
|
||||
const expectedErrorMessage = 'Injected write error';
|
||||
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
||||
const nodeMock = new NodeFileOperationsStub();
|
||||
nodeMock.writeFile = () => Promise.reject(new Error(expectedErrorMessage));
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = new SaveFileDialogTestSetup()
|
||||
.withElectron(electronMock)
|
||||
.withNode(nodeMock)
|
||||
.withLogger(loggerStub);
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// act
|
||||
await context.saveFile();
|
||||
|
||||
// assert
|
||||
const errorCalls = loggerStub.callHistory.filter((c) => c.methodName === 'error');
|
||||
expect(errorCalls.length).to.equal(1);
|
||||
const errorCall = errorCalls[0];
|
||||
const [errorMessage] = errorCall.args;
|
||||
expect(errorMessage).to.include(expectedErrorMessage);
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +334,7 @@ describe('NodeElectronSaveFileDialog', () => {
|
||||
class SaveFileDialogTestSetup {
|
||||
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
|
||||
|
||||
private fileName = `${SaveFileDialogTestSetup.name} file name`;
|
||||
private filename = `${SaveFileDialogTestSetup.name} filename`;
|
||||
|
||||
private fileType = FileType.BatchFile;
|
||||
|
||||
@@ -227,8 +359,8 @@ class SaveFileDialogTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withFileName(fileName: string): this {
|
||||
this.fileName = fileName;
|
||||
public withDefaultFilename(defaultFilename: string): this {
|
||||
this.filename = defaultFilename;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -246,7 +378,7 @@ class SaveFileDialogTestSetup {
|
||||
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node);
|
||||
return dialog.saveFile(
|
||||
this.fileContents,
|
||||
this.fileName,
|
||||
this.filename,
|
||||
this.fileType,
|
||||
);
|
||||
}
|
||||
|
||||
163
tests/unit/infrastructure/Dialog/LoggingDialogDecorator.spec.ts
Normal file
163
tests/unit/infrastructure/Dialog/LoggingDialogDecorator.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { decorateWithLogging } from '@/infrastructure/Dialog/LoggingDialogDecorator';
|
||||
import { Dialog, FileType, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
|
||||
describe('LoggingDialogDecorator', () => {
|
||||
describe('decorateWithLogging', () => {
|
||||
describe('saveFile', () => {
|
||||
it('delegates call to dialog', async () => {
|
||||
// arrange
|
||||
const expectedArguments = createTestSaveFileArguments();
|
||||
const dialog = new DialogStub();
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withDialog(dialog);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
await decorator.saveFile(...expectedArguments);
|
||||
|
||||
// assert
|
||||
expect(dialog.callHistory).to.have.lengthOf(1);
|
||||
const call = dialog.callHistory.find((c) => c.methodName === 'saveFile');
|
||||
expectExists(call);
|
||||
const actualArguments = call.args;
|
||||
expect(expectedArguments).to.deep.equal(actualArguments);
|
||||
});
|
||||
it('returns dialog\'s response', async () => {
|
||||
// arrange
|
||||
const expectedResult: SaveFileOutcome = { success: true };
|
||||
const dialog = new DialogStub();
|
||||
dialog.saveFile = () => Promise.resolve(expectedResult);
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withDialog(dialog);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
const actualResult = await decorator.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
expect(expectedResult).to.equal(actualResult);
|
||||
});
|
||||
it('logs information on invocation', async () => {
|
||||
// arrange
|
||||
const expectedLogMessagePart = 'Opening save file dialog';
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withLogger(loggerStub);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
await decorator.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||
});
|
||||
it('logs information on success', async () => {
|
||||
// arrange
|
||||
const expectedLogMessagePart = 'completed successfully';
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withLogger(loggerStub);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
await decorator.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('info', expectedLogMessagePart);
|
||||
});
|
||||
it('logs error on save failure', async () => {
|
||||
// arrange
|
||||
const expectedLogMessagePart = 'Error encountered';
|
||||
const loggerStub = new LoggerStub();
|
||||
const dialog = new DialogStub();
|
||||
dialog.saveFile = () => Promise.resolve({ success: false, error: { message: 'error', type: 'DialogDisplayError' } });
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withLogger(loggerStub);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
await decorator.saveFile(...createTestSaveFileArguments());
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedLogMessagePart);
|
||||
});
|
||||
});
|
||||
describe('showError', () => {
|
||||
it('delegates call to the dialog', () => {
|
||||
// arrange
|
||||
const expectedArguments = createTestShowErrorArguments();
|
||||
const dialog = new DialogStub();
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withDialog(dialog);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
decorator.showError(...expectedArguments);
|
||||
|
||||
// assert
|
||||
expect(dialog.callHistory).to.have.lengthOf(1);
|
||||
const call = dialog.callHistory.find((c) => c.methodName === 'showError');
|
||||
expectExists(call);
|
||||
const actualArguments = call.args;
|
||||
expect(expectedArguments).to.deep.equal(actualArguments);
|
||||
});
|
||||
it('logs error message', () => {
|
||||
// arrange
|
||||
const expectedLogMessagePart = 'Showing error dialog';
|
||||
const loggerStub = new LoggerStub();
|
||||
const context = new LoggingDialogDecoratorTestSetup()
|
||||
.withLogger(loggerStub);
|
||||
|
||||
// act
|
||||
const decorator = context.decorateWithLogging();
|
||||
decorator.showError(...createTestShowErrorArguments());
|
||||
|
||||
// assert
|
||||
loggerStub.assertLogsContainMessagePart('error', expectedLogMessagePart);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class LoggingDialogDecoratorTestSetup {
|
||||
private dialog: Dialog = new DialogStub();
|
||||
|
||||
private logger: Logger = new LoggerStub();
|
||||
|
||||
public withDialog(dialog: Dialog): this {
|
||||
this.dialog = dialog;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withLogger(logger: Logger): this {
|
||||
this.logger = logger;
|
||||
return this;
|
||||
}
|
||||
|
||||
public decorateWithLogging() {
|
||||
return decorateWithLogging(
|
||||
this.dialog,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createTestSaveFileArguments(): Parameters<Dialog['saveFile']> {
|
||||
return [
|
||||
'test-file-contents',
|
||||
'test-default-filename',
|
||||
FileType.BatchFile,
|
||||
];
|
||||
}
|
||||
|
||||
function createTestShowErrorArguments(): Parameters<Dialog['showError']> {
|
||||
return [
|
||||
'test-error-title',
|
||||
'test-error-message',
|
||||
];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it } from 'vitest';
|
||||
import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/AppInitializationLogger';
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
|
||||
@@ -11,8 +11,6 @@ describe('AppInitializationLogger', () => {
|
||||
// act
|
||||
await sut.bootstrap();
|
||||
// assert
|
||||
expect(loggerStub.callHistory).to.have.lengthOf(1);
|
||||
expect(loggerStub.callHistory[0].args).to.have.lengthOf(1);
|
||||
expect(loggerStub.callHistory[0].args[0]).to.include(marker);
|
||||
loggerStub.assertLogsContainMessagePart('info', marker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { determineDialogBasedOnEnvironment, WindowDialogCreationFunction, BrowserDialogCreationFunction } from '@/presentation/components/Shared/Hooks/Dialog/ClientDialogFactory';
|
||||
import {
|
||||
createEnvironmentSpecificLoggedDialog, WindowDialogCreationFunction,
|
||||
BrowserDialogCreationFunction, DialogLoggingDecorator,
|
||||
} from '@/presentation/components/Shared/Hooks/Dialog/ClientDialogFactory';
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
|
||||
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
|
||||
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
|
||||
describe('ClientDialogFactory', () => {
|
||||
describe('determineDialogBasedOnEnvironment', () => {
|
||||
describe('non-desktop environment', () => {
|
||||
it('returns browser dialog', () => {
|
||||
// arrange
|
||||
const expectedDialog = new DialogStub();
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
||||
.withBrowserDialogFactory(() => expectedDialog);
|
||||
describe('createEnvironmentSpecificLoggedDialog', () => {
|
||||
describe('dialog selection based on environment', () => {
|
||||
describe('when in non-desktop environment', () => {
|
||||
it('provides a browser dialog', () => {
|
||||
// arrange
|
||||
const expectedDialog = new DialogStub();
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
||||
.withBrowserDialogFactory(() => expectedDialog);
|
||||
|
||||
// act
|
||||
const actualDialog = context.createDialogForTest();
|
||||
// act
|
||||
const actualDialog = context.createDialogForTest();
|
||||
|
||||
// assert
|
||||
expect(expectedDialog).to.equal(actualDialog);
|
||||
// assert
|
||||
expect(expectedDialog).to.equal(actualDialog);
|
||||
});
|
||||
});
|
||||
describe('when in desktop environment', () => {
|
||||
it('provides a window-injected dialog', () => {
|
||||
// arrange
|
||||
const expectedDialog = new DialogStub();
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||
.withWindowInjectedDialogFactory(() => expectedDialog);
|
||||
|
||||
// act
|
||||
const actualDialog = context.createDialogForTest();
|
||||
|
||||
// assert
|
||||
expect(expectedDialog).to.equal(actualDialog);
|
||||
});
|
||||
it('throws error if window-injected dialog is not available', () => {
|
||||
// arrange
|
||||
const expectedError = 'Failed to retrieve Dialog API from window object in desktop environment.';
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||
.withWindowInjectedDialogFactory(() => undefined);
|
||||
|
||||
// act
|
||||
const act = () => context.createDialogForTest();
|
||||
|
||||
// assert
|
||||
const actualError = collectExceptionMessage(act);
|
||||
expect(actualError).to.include(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('desktop environment', () => {
|
||||
it('returns window-injected dialog', () => {
|
||||
describe('dialog decoration with logging', () => {
|
||||
it('returns a dialog decorated with logging', () => {
|
||||
// arrange
|
||||
const expectedDialog = new DialogStub();
|
||||
const expectedLoggingDialogStub = new DialogStub();
|
||||
const decoratorStub: DialogLoggingDecorator = () => expectedLoggingDialogStub;
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||
.withWindowInjectedDialogFactory(() => expectedDialog);
|
||||
.withDialogLoggingDecorator(decoratorStub);
|
||||
|
||||
// act
|
||||
const actualDialog = context.createDialogForTest();
|
||||
|
||||
// assert
|
||||
expect(expectedDialog).to.equal(actualDialog);
|
||||
expect(expectedLoggingDialogStub).to.equal(actualDialog);
|
||||
});
|
||||
it('throws error when window-injected dialog is unavailable', () => {
|
||||
it('applies logging decorator to the provided dialog', () => {
|
||||
// arrange
|
||||
const expectedError = 'The Dialog API could not be retrieved from the window object.';
|
||||
const expectedDialog = new DialogStub();
|
||||
let actualDecoratedDialog: Dialog | undefined;
|
||||
const decoratorStub: DialogLoggingDecorator = (dialog) => {
|
||||
actualDecoratedDialog = dialog;
|
||||
return new DialogStub();
|
||||
};
|
||||
const context = new DialogCreationTestSetup()
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
|
||||
.withWindowInjectedDialogFactory(() => undefined);
|
||||
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
|
||||
.withBrowserDialogFactory(() => expectedDialog)
|
||||
.withDialogLoggingDecorator(decoratorStub);
|
||||
|
||||
// act
|
||||
const act = () => context.createDialogForTest();
|
||||
context.createDialogForTest();
|
||||
|
||||
// assert
|
||||
const actualError = collectExceptionMessage(act);
|
||||
expect(actualError).to.include(expectedError);
|
||||
expect(expectedDialog).to.equal(actualDecoratedDialog);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +101,8 @@ class DialogCreationTestSetup {
|
||||
|
||||
private windowInjectedDialogFactory: WindowDialogCreationFunction = () => new DialogStub();
|
||||
|
||||
private dialogLoggingDecorator: DialogLoggingDecorator = (dialog) => dialog;
|
||||
|
||||
public withEnvironment(environment: RuntimeEnvironment): this {
|
||||
this.environment = environment;
|
||||
return this;
|
||||
@@ -78,9 +120,17 @@ class DialogCreationTestSetup {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDialogLoggingDecorator(
|
||||
dialogLoggingDecorator: DialogLoggingDecorator,
|
||||
): this {
|
||||
this.dialogLoggingDecorator = dialogLoggingDecorator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public createDialogForTest() {
|
||||
return determineDialogBasedOnEnvironment(
|
||||
return createEnvironmentSpecificLoggedDialog(
|
||||
this.environment,
|
||||
this.dialogLoggingDecorator,
|
||||
this.windowInjectedDialogFactory,
|
||||
this.browserDialogFactory,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||
import { CodeRunOutcome, CodeRunner } from '@/application/CodeRunner/CodeRunner';
|
||||
|
||||
export class CodeRunnerStub implements CodeRunner {
|
||||
public runCode(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
public runCode(): Promise<CodeRunOutcome> {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { Dialog } from '@/presentation/common/Dialog';
|
||||
import { Dialog, SaveFileOutcome } from '@/presentation/common/Dialog';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class DialogStub implements Dialog {
|
||||
public saveFile(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
export class DialogStub
|
||||
extends StubWithObservableMethodCalls<Dialog>
|
||||
implements Dialog {
|
||||
public saveFile(...args: Parameters<Dialog['saveFile']>): Promise<SaveFileOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'saveFile',
|
||||
args: [...args],
|
||||
});
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public showError(...args: Parameters<Dialog['showError']>): void {
|
||||
this.registerMethodCall({
|
||||
methodName: 'showError',
|
||||
args: [...args],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FilenameGenerator } from '@/infrastructure/CodeRunner/Creation/Filename/FilenameGenerator';
|
||||
import { ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||
import { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class FilenameGeneratorStub
|
||||
@@ -7,7 +7,7 @@ export class FilenameGeneratorStub
|
||||
implements FilenameGenerator {
|
||||
private filename = `[${FilenameGeneratorStub.name}]file-name-stub`;
|
||||
|
||||
public generateFilename(scriptFileNameParts: ScriptFileNameParts): string {
|
||||
public generateFilename(scriptFileNameParts: ScriptFilenameParts): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'generateFilename',
|
||||
args: [scriptFileNameParts],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
import { FunctionKeys, isString } from '@/TypeHelpers';
|
||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements Logger {
|
||||
@@ -29,4 +31,27 @@ export class LoggerStub extends StubWithObservableMethodCalls<Logger> implements
|
||||
args: params,
|
||||
});
|
||||
}
|
||||
|
||||
public assertLogsContainMessagePart(
|
||||
methodName: FunctionKeys<Logger>,
|
||||
expectedLogMessagePart: string,
|
||||
) {
|
||||
const loggedMessages = this.getLoggedMessages(methodName);
|
||||
expect(
|
||||
loggedMessages.some((m) => m.includes(expectedLogMessagePart)),
|
||||
formatAssertionMessage([
|
||||
`Log function: ${methodName}`,
|
||||
`Expected log message part: ${expectedLogMessagePart}`,
|
||||
'Actual log messages:',
|
||||
loggedMessages.join('\n- '),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private getLoggedMessages(methodName: FunctionKeys<Logger>): string[] {
|
||||
const calls = this.callHistory.filter((m) => m.methodName === methodName);
|
||||
const loggedItems = calls.flatMap((call) => call.args);
|
||||
const stringLogs = loggedItems.filter((message): message is string => isString(message));
|
||||
return stringLogs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
import { ScriptDirectoryOutcome, ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
|
||||
export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
||||
private directoryPath = `[${ScriptDirectoryProviderStub.name}]scriptDirectory`;
|
||||
@@ -8,7 +8,10 @@ export class ScriptDirectoryProviderStub implements ScriptDirectoryProvider {
|
||||
return this;
|
||||
}
|
||||
|
||||
public provideScriptDirectory(): Promise<string> {
|
||||
return Promise.resolve(this.directoryPath);
|
||||
public provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
directoryAbsolutePath: this.directoryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ScriptFileCreator, ScriptFileNameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||
import { ScriptFileCreationOutcome, ScriptFileCreator, ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/ScriptFileCreator';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ScriptFileCreatorStub
|
||||
@@ -13,12 +13,15 @@ export class ScriptFileCreatorStub
|
||||
|
||||
public createScriptFile(
|
||||
contents: string,
|
||||
scriptFileNameParts: ScriptFileNameParts,
|
||||
): Promise<string> {
|
||||
scriptFileNameParts: ScriptFilenameParts,
|
||||
): Promise<ScriptFileCreationOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'createScriptFile',
|
||||
args: [contents, scriptFileNameParts],
|
||||
});
|
||||
return Promise.resolve(this.createdFilePath);
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
scriptFileAbsolutePath: this.createdFilePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import { ScriptFileExecutionOutcome, ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export class ScriptFileExecutorStub
|
||||
extends StubWithObservableMethodCalls<ScriptFileExecutor>
|
||||
implements ScriptFileExecutor {
|
||||
public executeScriptFile(filePath: string): Promise<void> {
|
||||
public executeScriptFile(filePath: string): Promise<ScriptFileExecutionOutcome> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'executeScriptFile',
|
||||
args: [filePath],
|
||||
});
|
||||
return Promise.resolve();
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user