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:
@@ -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',
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user