This commit upgrades TypeScript to the latest version 5.3 and introduces `verbatimModuleSyntax` in line with the official Vue guide recommendatinos (vuejs/docs#2592). By enforcing `import type` for type-only imports, this commit improves code clarity and supports tooling optimization, ensuring imports are only bundled when necessary for runtime. Changes: - Bump TypeScript to 5.3.3 across the project. - Adjust import statements to utilize `import type` where applicable, promoting cleaner and more efficient code.
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { FileType, type SaveFileErrorType } from '@/presentation/common/Dialog';
|
|
import {
|
|
type ElectronFileDialogOperations, NodeElectronSaveFileDialog, type NodePathOperations,
|
|
} from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
|
|
import type { Logger } from '@/application/Common/Log/Logger';
|
|
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
|
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
|
|
import { FileReadbackVerificationErrors, FileWriteOperationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
|
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
|
|
import { NodePathOperationsStub } from './NodePathOperationsStub';
|
|
|
|
describe('NodeElectronSaveFileDialog', () => {
|
|
describe('dialog options', () => {
|
|
it('correct title', async () => {
|
|
// arrange
|
|
const expectedFileName = 'expected-file-name';
|
|
const electronMock = new ElectronFileDialogOperationsStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock)
|
|
.withDefaultFilename(expectedFileName);
|
|
// act
|
|
await context.saveFile();
|
|
// assert
|
|
assertDialogOptionMatchesExpectedValue(expectedFileName, (opts) => opts.title, electronMock);
|
|
});
|
|
it('correct properties', async () => {
|
|
// arrange
|
|
const expectedProperties = [
|
|
'createDirectory',
|
|
'showOverwriteConfirmation',
|
|
];
|
|
const electronMock = new ElectronFileDialogOperationsStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock);
|
|
// act
|
|
await context.saveFile();
|
|
// assert
|
|
assertDialogOptionMatchesExpectedValue(
|
|
expectedProperties,
|
|
(opts) => opts.properties,
|
|
electronMock,
|
|
);
|
|
});
|
|
it('correct default path', async () => {
|
|
// arrange
|
|
const pathSegmentSeparator = '_{test-separator}_';
|
|
const expectedFileName = 'expected-file-name';
|
|
const expectedParentDirectory = 'expected-downloads-path';
|
|
const expectedFilePath = [
|
|
expectedParentDirectory,
|
|
expectedFileName,
|
|
].join(pathSegmentSeparator);
|
|
const electronMock = new ElectronFileDialogOperationsStub()
|
|
.withUserDownloadsPath(expectedParentDirectory);
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock)
|
|
.withDefaultFilename(expectedFileName)
|
|
.withNode(new NodePathOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
|
|
// act
|
|
await context.saveFile();
|
|
// assert
|
|
assertDialogOptionMatchesExpectedValue(
|
|
expectedFilePath,
|
|
(opts) => opts.defaultPath,
|
|
electronMock,
|
|
);
|
|
});
|
|
describe('correct file type filters', () => {
|
|
const defaultFilter: Electron.FileFilter = {
|
|
name: 'All Files',
|
|
extensions: ['*'],
|
|
};
|
|
const testScenarios: Record<FileType, Electron.FileFilter[]> = {
|
|
[FileType.BatchFile]: [
|
|
defaultFilter,
|
|
{
|
|
name: 'Batch Files',
|
|
extensions: ['bat', 'cmd'],
|
|
},
|
|
],
|
|
[FileType.ShellScript]: [
|
|
defaultFilter,
|
|
{
|
|
name: 'Shell Scripts',
|
|
extensions: ['sh', 'bash', 'zsh'],
|
|
},
|
|
],
|
|
};
|
|
Object.entries(testScenarios).forEach(([fileTypeKey, expectedFilters]) => {
|
|
const fileType = Number(fileTypeKey) as FileType;
|
|
it(`applies correct filters for ${FileType[fileType]}`, async () => {
|
|
// arrange
|
|
const electronMock = new ElectronFileDialogOperationsStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withFileType(fileType)
|
|
.withElectron(electronMock);
|
|
// act
|
|
await context.saveFile();
|
|
// assert
|
|
const sortFilters = (
|
|
filters: Electron.FileFilter[],
|
|
) => filters.sort((a, b) => a.name.localeCompare(b.name));
|
|
const expectedSortedFilters = sortFilters(expectedFilters);
|
|
assertDialogOptionMatchesExpectedValue(
|
|
expectedSortedFilters,
|
|
(opts) => sortFilters(opts.filters ?? []),
|
|
electronMock,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
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 fileWriterStub = new ReadbackFileWriterStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock)
|
|
.withFileWriter(fileWriterStub);
|
|
|
|
// act
|
|
await context.saveFile();
|
|
|
|
// assert
|
|
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
|
|
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 fileWriterStub = new ReadbackFileWriterStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock)
|
|
.withFileContents(expectedFileContents)
|
|
.withFileWriter(fileWriterStub);
|
|
|
|
// act
|
|
await context.saveFile();
|
|
|
|
// assert
|
|
const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
|
|
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 fileWriterStub = new ReadbackFileWriterStub();
|
|
const context = new SaveFileDialogTestSetup()
|
|
.withElectron(electronMock)
|
|
.withFileWriter(fileWriterStub);
|
|
|
|
// act
|
|
await context.saveFile();
|
|
|
|
// assert
|
|
const saveFileCall = fileWriterStub.callHistory.find((c) => c.methodName === 'writeAndVerifyFile');
|
|
expect(saveFileCall).to.equal(undefined);
|
|
});
|
|
it('logs cancelation', 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);
|
|
|
|
// 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);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
interface SaveFileFailureTestScenario {
|
|
readonly description: string;
|
|
readonly expectedErrorType: SaveFileErrorType;
|
|
readonly expectedErrorMessage: string;
|
|
readonly expectLogs: boolean,
|
|
buildFaultyContext(
|
|
setup: SaveFileDialogTestSetup,
|
|
errorMessage: string,
|
|
): SaveFileDialogTestSetup;
|
|
}
|
|
const testScenarios: ReadonlyArray<SaveFileFailureTestScenario> = [
|
|
...FileWriteOperationErrors.map((writeError): SaveFileFailureTestScenario => ({
|
|
description: `file writing failure: ${writeError}`,
|
|
expectedErrorType: 'FileCreationError',
|
|
expectedErrorMessage: 'Error when writing to file',
|
|
expectLogs: false,
|
|
buildFaultyContext: (setup, errorMessage) => {
|
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
|
const fileWriterStub = new ReadbackFileWriterStub();
|
|
fileWriterStub.configureFailure(writeError, errorMessage);
|
|
return setup
|
|
.withElectron(electronMock)
|
|
.withFileWriter(fileWriterStub);
|
|
},
|
|
})),
|
|
...FileReadbackVerificationErrors.map((verificationError): SaveFileFailureTestScenario => ({
|
|
description: `file verification failure: ${verificationError}`,
|
|
expectedErrorType: 'FileReadbackVerificationError',
|
|
expectedErrorMessage: 'Error when verifying the file',
|
|
expectLogs: false,
|
|
buildFaultyContext: (setup, errorMessage) => {
|
|
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
|
|
const fileWriterStub = new ReadbackFileWriterStub();
|
|
fileWriterStub.configureFailure(verificationError, errorMessage);
|
|
return setup
|
|
.withElectron(electronMock)
|
|
.withFileWriter(fileWriterStub);
|
|
},
|
|
})),
|
|
{
|
|
description: 'user path retrieval failure',
|
|
expectedErrorType: 'DialogDisplayError',
|
|
expectedErrorMessage: 'Error when retrieving user path',
|
|
expectLogs: true,
|
|
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',
|
|
expectLogs: true,
|
|
buildFaultyContext: (setup, errorMessage) => {
|
|
const nodeMock = new NodePathOperationsStub();
|
|
nodeMock.join = () => {
|
|
throw new Error(errorMessage);
|
|
};
|
|
return setup
|
|
.withNode(nodeMock);
|
|
},
|
|
},
|
|
{
|
|
description: 'dialog display failure',
|
|
expectedErrorType: 'DialogDisplayError',
|
|
expectedErrorMessage: 'Error when showing save dialog',
|
|
expectLogs: true,
|
|
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.',
|
|
expectLogs: true,
|
|
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, expectLogs,
|
|
}) => {
|
|
it(`handles error - ${description}`, async () => {
|
|
// arrange
|
|
const context = buildFaultyContext(
|
|
new SaveFileDialogTestSetup(),
|
|
expectedErrorMessage,
|
|
);
|
|
|
|
// act
|
|
const { success, error } = await context.saveFile();
|
|
|
|
// 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 SaveFileDialogTestSetup()
|
|
.withLogger(loggerStub),
|
|
expectedErrorMessage,
|
|
);
|
|
|
|
// act
|
|
await context.saveFile();
|
|
|
|
// assert
|
|
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
class SaveFileDialogTestSetup {
|
|
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
|
|
|
|
private filename = `${SaveFileDialogTestSetup.name} filename`;
|
|
|
|
private fileType = FileType.BatchFile;
|
|
|
|
private logger: Logger = new LoggerStub();
|
|
|
|
private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub();
|
|
|
|
private node: NodePathOperations = new NodePathOperationsStub();
|
|
|
|
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
|
|
|
|
public withElectron(electron: ElectronFileDialogOperations): this {
|
|
this.electron = electron;
|
|
return this;
|
|
}
|
|
|
|
public withNode(node: NodePathOperations): this {
|
|
this.node = node;
|
|
return this;
|
|
}
|
|
|
|
public withFileWriter(fileWriter: ReadbackFileWriter): this {
|
|
this.fileWriter = fileWriter;
|
|
return this;
|
|
}
|
|
|
|
public withLogger(logger: Logger): this {
|
|
this.logger = logger;
|
|
return this;
|
|
}
|
|
|
|
public withDefaultFilename(defaultFilename: string): this {
|
|
this.filename = defaultFilename;
|
|
return this;
|
|
}
|
|
|
|
public withFileContents(fileContents: string): this {
|
|
this.fileContents = fileContents;
|
|
return this;
|
|
}
|
|
|
|
public withFileType(fileType: FileType): this {
|
|
this.fileType = fileType;
|
|
return this;
|
|
}
|
|
|
|
public saveFile() {
|
|
const dialog = new NodeElectronSaveFileDialog(
|
|
this.logger,
|
|
this.electron,
|
|
this.node,
|
|
this.fileWriter,
|
|
);
|
|
return dialog.saveFile(
|
|
this.fileContents,
|
|
this.filename,
|
|
this.fileType,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertDialogOptionMatchesExpectedValue<T>(
|
|
expectedValue: T,
|
|
getActualOption: (opts: Electron.SaveDialogOptions) => T | undefined,
|
|
electronMock: ElectronFileDialogOperationsStub,
|
|
): void {
|
|
const showDialogCalls = electronMock.callHistory.filter((c) => c.methodName === 'showSaveDialog');
|
|
expect(showDialogCalls).to.have.lengthOf(1);
|
|
const showDialogCall = showDialogCalls[0];
|
|
expectExists(showDialogCall);
|
|
const [options] = showDialogCall.args;
|
|
expectExists(options);
|
|
const actualValue = getActualOption(options);
|
|
expect(actualValue).to.deep.equal(expectedValue);
|
|
}
|