Add AD detection on desktop app #264, #304

This commit addresses issues #264 and #304, where users were not
receiving error messages when script execution failed due to
antivirus intervention, particularly with Microsoft Defender.
Now, desktop app users will see a detailed error message with
guidance on next steps if script saving or execution fails due
to antivirus removal.

Key changes:

- Implement a check to detect failure in file writing,
  including reading the written file back. This method effectively
  detects antivirus interventions, as the read operation triggers
  an antivirus scan, leading to file deletion by the antivirus.
- Introduce a specific error message for scenarios where an
  antivirus intervention is detected.
This commit is contained in:
undergroundwires
2024-01-16 22:26:28 +01:00
parent 756c736e21
commit f03fc24098
23 changed files with 841 additions and 142 deletions

31
2 Normal file
View File

@@ -0,0 +1,31 @@
Show error on AV removal on desktop $264, $304
This solves $264 where users do not get error messages when running
script file fails due to antivirus intervention (it being blocking the
script file as soon as privacy.sexy generates it to run it). Now if the
desktop app users tries to save or run a script file and it afils due to
antivirus removal, they'll get a special error message with guiding next
steps.
- Add additional check to able to fail if the file writing fails. This
includes trying to reading the written file back as suggested in $304.
This successfully detects antivirus (Defender) intervation as read
file operation triggers the antivirus scan that deletes the file.
- Show directory and file path in error messages as suggested in $304.
- Show an error message with more detailed information if an antivirus
is detected.
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date: Tue Jan 16 16:23:08 2024 +0100
#
# On branch master
# Your branch is ahead of 'origin/master' by 1 commit.
# (use "git push" to publish your local commits)
#
# Changes to be committed:
# modified: ../../application/CodeRunner/CodeRunner.ts
# new file: NodeReliableFileWriter.ts
# new file: ReliableFileWriter.ts
#

View File

@@ -29,7 +29,9 @@ There are different types of tests executed:
- Evaluate individual components in isolation. - Evaluate individual components in isolation.
- Located in [`./tests/unit`](./../tests/unit). - Located in [`./tests/unit`](./../tests/unit).
- Achieve isolation using [stubs](./../tests/unit/shared/Stubs). - Achieve isolation using stubs where you place:
- Common stubs in [`./shared/Stubs`](./../tests/unit/shared/Stubs),
- Component-specific stubs in same folder as test file.
- Include Vue component tests, enabled by `@vue/test-utils`. - Include Vue component tests, enabled by `@vue/test-utils`.
#### Unit tests naming #### Unit tests naming

View File

@@ -5,16 +5,17 @@ export interface CodeRunner {
): Promise<CodeRunOutcome>; ): Promise<CodeRunOutcome>;
} }
export type CodeRunOutcome = SuccessfulCodeRun | FailedCodeRun;
export type CodeRunErrorType = export type CodeRunErrorType =
| 'FileWriteError' | 'FileWriteError'
| 'FileReadbackVerificationError'
| 'FilePathGenerationError' | 'FilePathGenerationError'
| 'UnsupportedOperatingSystem' | 'UnsupportedOperatingSystem'
| 'FileExecutionError' | 'FileExecutionError'
| 'DirectoryCreationError' | 'DirectoryCreationError'
| 'UnexpectedError'; | 'UnexpectedError';
export type CodeRunOutcome = SuccessfulCodeRun | FailedCodeRun;
interface CodeRunStatus { interface CodeRunStatus {
readonly success: boolean; readonly success: boolean;
readonly error?: CodeRunError; readonly error?: CodeRunError;

View File

@@ -1,6 +1,8 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { Logger } from '@/application/Common/Log/Logger'; import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { SystemOperations } from '../System/SystemOperations'; import { SystemOperations } from '../System/SystemOperations';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations'; import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
import { FilenameGenerator } from './Filename/FilenameGenerator'; import { FilenameGenerator } from './Filename/FilenameGenerator';
@@ -14,6 +16,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(), private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
@@ -65,18 +68,20 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
filePath: string, filePath: string,
contents: string, contents: string,
): Promise<FileWriteOutcome> { ): Promise<FileWriteOutcome> {
try { const {
this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`); success, error,
await this.system.fileSystem.writeToFile(filePath, contents); } = await this.fileWriter.writeAndVerifyFile(filePath, contents);
this.logger.info(`File created successfully at ${filePath}`); if (success) {
return { success: true }; return { success: true };
} catch (error) { }
return { return {
success: false, success: false,
error: this.handleException(error, 'FileWriteError'), error: {
message: error.message,
type: FileReadbackVerificationErrors.find((e) => e === error.type) ? 'FileReadbackVerificationError' : 'FileWriteError',
},
}; };
} }
}
private handleException( private handleException(
exception: Error, exception: Error,

View File

@@ -1,5 +1,5 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { chmod, mkdir, writeFile } from 'node:fs/promises'; import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { app } from 'electron/main'; import { app } from 'electron/main';
import { import {
@@ -46,10 +46,6 @@ export class NodeElectronSystemOperations implements SystemOperations {
// when `recursive` is true, or empty return value. // when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530 // See https://github.com/nodejs/node/pull/31530
}, },
writeToFile: (
filePath: string,
data: string,
) => writeFile(filePath, data),
}; };
public readonly command: CommandOps = { public readonly command: CommandOps = {

View File

@@ -20,5 +20,4 @@ export interface CommandOps {
export interface FileSystemOps { export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>; setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>; createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
writeToFile(filePath: string, data: string): Promise<void>;
} }

View File

@@ -1,11 +1,12 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { app, dialog } from 'electron/main'; import { app, dialog } from 'electron/main';
import { Logger } from '@/application/Common/Log/Logger'; import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { import {
FileType, SaveFileError, SaveFileErrorType, SaveFileOutcome, FileType, SaveFileError, SaveFileErrorType, SaveFileOutcome,
} from '@/presentation/common/Dialog'; } from '@/presentation/common/Dialog';
import { FileReadbackVerificationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog'; import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog { export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
@@ -15,10 +16,8 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
getUserDownloadsPath: () => app.getPath('downloads'), getUserDownloadsPath: () => app.getPath('downloads'),
showSaveDialog: dialog.showSaveDialog.bind(dialog), showSaveDialog: dialog.showSaveDialog.bind(dialog),
}, },
private readonly node: NodeFileOperations = { private readonly node: NodeFileOperations = { join },
join, private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
writeFile,
},
) { } ) { }
public async saveFile( public async saveFile(
@@ -55,20 +54,20 @@ export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
filePath: string, filePath: string,
fileContents: string, fileContents: string,
): Promise<SaveFileOutcome> { ): Promise<SaveFileOutcome> {
try { const {
this.logger.info(`Saving file: ${filePath}`); success, error,
await this.node.writeFile(filePath, fileContents); } = await this.fileWriter.writeAndVerifyFile(filePath, fileContents);
this.logger.info(`File saved: ${filePath}`); if (success) {
return { return { success: true };
success: true, }
};
} catch (error) {
return { return {
success: false, success: false,
error: this.handleException(error, 'FileCreationError'), error: {
message: error.message,
type: FileReadbackVerificationErrors.find((e) => e === error.type) ? 'FileReadbackVerificationError' : 'FileCreationError',
},
}; };
} }
}
private async showSaveFileDialog( private async showSaveFileDialog(
defaultFilename: string, defaultFilename: string,
@@ -139,7 +138,6 @@ export interface ElectronFileDialogOperations {
export interface NodeFileOperations { export interface NodeFileOperations {
readonly join: typeof join; readonly join: typeof join;
writeFile(file: string, data: string): Promise<void>;
} }
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] { function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {

View File

@@ -0,0 +1,120 @@
import { writeFile, access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../Log/ElectronLogger';
import {
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
FileWriteOutcome, SuccessfulFileWrite,
} from './ReadbackFileWriter';
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
export class NodeReadbackFileWriter implements ReadbackFileWriter {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly fileSystem: FileReadWriteOperations = {
writeFile,
readFile: (path, encoding) => readFile(path, encoding),
access,
},
) { }
public async writeAndVerifyFile(
filePath: string,
fileContents: string,
): Promise<FileWriteOutcome> {
this.logger.info(`Starting file write and verification process for: ${filePath}`);
const fileWritePipelineActions: ReadonlyArray<() => Promise<FileWriteOutcome>> = [
() => this.createOrOverwriteFile(filePath, fileContents),
() => this.verifyFileExistsWithoutReading(filePath),
/*
Reading the file contents back, we can detect if the file has been altered or
removed post-creation. Removal of scripts when reading back is seen by some antivirus
software when it falsely identifies a script as harmful.
*/
() => this.verifyFileContentsByReading(filePath, fileContents),
];
for (const action of fileWritePipelineActions) {
const actionOutcome = await action(); // eslint-disable-line no-await-in-loop
if (!actionOutcome.success) {
return actionOutcome;
}
}
return this.reportSuccess(`File successfully written and verified: ${filePath}`);
}
private async createOrOverwriteFile(
filePath: string,
fileContents: string,
): Promise<FileWriteOutcome> {
try {
this.logger.info(`Creating file at ${filePath}, size: ${fileContents.length} characters`);
await this.fileSystem.writeFile(filePath, fileContents, FILE_ENCODING);
return this.reportSuccess('Created file.');
} catch (error) {
return this.reportFailure('WriteOperationFailed', error);
}
}
private async verifyFileExistsWithoutReading(
filePath: string,
): Promise<FileWriteOutcome> {
try {
await this.fileSystem.access(filePath, constants.F_OK);
return this.reportSuccess('Verified file existence without reading.');
} catch (error) {
return this.reportFailure('FileExistenceVerificationFailed', error);
}
}
private async verifyFileContentsByReading(
filePath: string,
expectedFileContents: string,
): Promise<FileWriteOutcome> {
try {
const actualFileContents = await this.fileSystem.readFile(filePath, FILE_ENCODING);
if (actualFileContents !== expectedFileContents) {
return this.reportFailure(
'ContentVerificationFailed',
[
'The contents of the written file do not match the expected contents.',
'Written file contents do not match the expected file contents',
`File path: ${filePath}`,
`Expected total characters: ${actualFileContents.length}`,
`Actual total characters: ${expectedFileContents.length}`,
].join('\n'),
);
}
return this.reportSuccess('Verified file content by reading.');
} catch (error) {
return this.reportFailure('ReadVerificationFailed', error);
}
}
private reportFailure(
errorType: FileWriteErrorType,
error: Error | string,
): FailedFileWrite {
this.logger.error('Error saving file', errorType, error);
return {
success: false,
error: {
type: errorType,
message: typeof error === 'string' ? error : error.message,
},
};
}
private reportSuccess(successAction: string): SuccessfulFileWrite {
this.logger.info(`Successful file save: ${successAction}`);
return {
success: true,
};
}
}
export interface FileReadWriteOperations {
readonly writeFile: typeof writeFile;
readonly access: typeof access;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
}

View File

@@ -0,0 +1,59 @@
/**
* It defines the contract for file writing operations with an added layer of
* verification. This approach is useful in environments where file write operations
* might be silently intercepted or manipulated by external factors, such as antivirus software.
*
* This additional verification provides a more reliable and transparent file writing
* process, enhancing the application's resilience against external disruptions and
* improving the overall user experience. It enables the application to notify users
* of potential issues, such as antivirus interventions, and offer guidance on how to
* resolve them.
*/
export interface ReadbackFileWriter {
writeAndVerifyFile(filePath: string, fileContents: string): Promise<FileWriteOutcome>;
}
export type FileWriteOutcome = SuccessfulFileWrite | FailedFileWrite;
export type FileWriteErrorType =
| UnionOfConstArray<typeof FileWriteOperationErrors>
| UnionOfConstArray<typeof FileReadbackVerificationErrors>;
export const FileWriteOperationErrors = [
'WriteOperationFailed',
] as const;
export const FileReadbackVerificationErrors = [
'FileExistenceVerificationFailed',
'ContentVerificationFailed',
/*
This error indicates a failure in verifying the contents of a written file.
This error often occurs when antivirus software falsely identifies a script as harmful and
either alters or removes it during the readback process. This verification step is crucial
for detecting and handling such antivirus interventions.
*/
'ReadVerificationFailed',
] as const;
interface FileWriteStatus {
readonly success: boolean;
readonly error?: FileWriteError;
}
export interface SuccessfulFileWrite extends FileWriteStatus {
readonly success: true;
readonly error?: undefined;
}
export interface FailedFileWrite extends FileWriteStatus {
readonly success: false;
readonly error: FileWriteError;
}
export interface FileWriteError {
readonly type: FileWriteErrorType;
readonly message: string;
}
type UnionOfConstArray<T extends ReadonlyArray<unknown>> = T[number];

View File

@@ -32,4 +32,5 @@ export interface SaveFileError {
export type SaveFileErrorType = export type SaveFileErrorType =
| 'FileCreationError' | 'FileCreationError'
| 'FileReadbackVerificationError'
| 'DialogDisplayError'; | 'DialogDisplayError';

View File

@@ -12,6 +12,7 @@ import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { Dialog } from '@/presentation/common/Dialog'; import { Dialog } from '@/presentation/common/Dialog';
import { CodeRunError } from '@/application/CodeRunner/CodeRunner';
import IconButton from './IconButton.vue'; import IconButton from './IconButton.vue';
export default defineComponent({ export default defineComponent({
@@ -37,7 +38,7 @@ export default defineComponent({
currentContext.state.collection.scripting.fileExtension, currentContext.state.collection.scripting.fileExtension,
); );
if (!success) { if (!success) {
showScriptRunError(dialog, `${error.type}: ${error.message}`); showScriptRunError(dialog, error);
} }
} }
@@ -57,8 +58,18 @@ function getCanRunState(
return isRunningAsDesktopApplication && isRunningOnSelectedOs; return isRunningAsDesktopApplication && isRunningOnSelectedOs;
} }
function showScriptRunError(dialog: Dialog, technicalDetails: string) { function showScriptRunError(dialog: Dialog, error: CodeRunError) {
const technicalDetails = `[${error.type}] ${error.message}`;
dialog.showError( dialog.showError(
...(
error.type === 'FileReadbackVerificationError'
? createAntivirusErrorDialog(technicalDetails)
: createGenericErrorDialog(technicalDetails)),
);
}
function createGenericErrorDialog(technicalDetails: string): Parameters<Dialog['showError']> {
return [
'Error Running Script', 'Error Running Script',
[ [
'We encountered an issue while running the script.', 'We encountered an issue while running the script.',
@@ -67,13 +78,44 @@ function showScriptRunError(dialog: Dialog, technicalDetails: string) {
'Here are some steps you can take:', 'Here are some steps you can take:',
'- Confirm that you have the necessary permissions to execute scripts on your system.', '- Confirm that you have the necessary permissions to execute scripts on your system.',
'- Check if there is sufficient disk space and system resources available.', '- Check if there is sufficient disk space and system resources available.',
'- Antivirus or security software can sometimes mistakenly block script execution. If you suspect this, verify your security settings, or temporarily disable the security software to see if that resolves the issue.', [
'- Antivirus or security software can sometimes mistakenly block script execution.',
'Verify your security settings, or temporarily disable the security software to see if that resolves the issue.',
'privacy.sexy is secure, transparent, and open-source, but the scripts might still be mistakenly flagged by antivirus software.',
].join(' '),
'- If possible, try running a different script to determine if the issue is specific to a particular script.', '- If possible, try running a different script to determine if the issue is specific to a particular script.',
'- Should the problem persist, reach out to the community for further assistance.', '- Should the problem persist, reach out to the community for further assistance.',
'\n', '\n',
'For your reference, here are the technical details of the error:', 'Technical Details:',
technicalDetails, technicalDetails,
].join('\n'), ].join('\n'),
); ];
}
function createAntivirusErrorDialog(technicalDetails: string): Parameters<Dialog['showError']> {
return [
'Potential Antivirus Intervention',
[
[
'We\'ve encountered a problem which may be due to your antivirus software intervening.',
'privacy.sexy is secure, transparent, and open-source, but the scripts might still be mistakenly flagged by antivirus software such as Defender.',
].join(' '),
'\n',
'To address this, you can:',
'1. Temporarily disable your antivirus (real-time protection) or add an exclusion for privacy.sexy scripts.',
'2. Re-try running or downloading the script.',
'3. If the issue persists, check your antivirus logs for more details and consider reporting this as a false positive to your antivirus provider.',
'\n',
'To handle false warnings in Defender: Open "Virus & threat protection" from the "Start" menu.',
'\n',
[
'Remember to re-enable your antivirus protection as soon as possible for your security.',
'For more guidance, refer to your antivirus documentation.',
].join(' '),
'\n',
'Technical Details:',
technicalDetails,
].join('\n'),
];
} }
</script> </script>

View File

@@ -20,7 +20,7 @@ import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue'
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename'; import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
import { Dialog, FileType } from '@/presentation/common/Dialog'; import { Dialog, FileType, SaveFileError } from '@/presentation/common/Dialog';
import IconButton from '../IconButton.vue'; import IconButton from '../IconButton.vue';
import RunInstructions from './RunInstructions/RunInstructions.vue'; import RunInstructions from './RunInstructions/RunInstructions.vue';
@@ -45,7 +45,7 @@ export default defineComponent({
getType(currentState.value.collection.scripting.language), getType(currentState.value.collection.scripting.language),
); );
if (!success) { if (!success) {
showScriptSaveError(dialog, `${error.type}: ${error.message}`); showScriptSaveError(dialog, error);
return; return;
} }
areInstructionsVisible.value = true; areInstructionsVisible.value = true;
@@ -78,8 +78,18 @@ function buildFilename(scripting: IScriptingDefinition) {
return ScriptFilename; return ScriptFilename;
} }
function showScriptSaveError(dialog: Dialog, technicalDetails: string) { function showScriptSaveError(dialog: Dialog, error: SaveFileError) {
const technicalDetails = `[${error.type}] ${error.message}`;
dialog.showError( dialog.showError(
...(
error.type === 'FileReadbackVerificationError'
? createAntivirusErrorDialog(technicalDetails)
: createGenericErrorDialog(technicalDetails)),
);
}
function createGenericErrorDialog(technicalDetails: string): Parameters<Dialog['showError']> {
return [
'Error Saving Script', 'Error Saving Script',
[ [
'An error occurred while saving the script.', 'An error occurred while saving the script.',
@@ -95,6 +105,32 @@ function showScriptSaveError(dialog: Dialog, technicalDetails: string) {
'Technical Details:', 'Technical Details:',
technicalDetails, technicalDetails,
].join('\n'), ].join('\n'),
); ];
}
function createAntivirusErrorDialog(technicalDetails: string): Parameters<Dialog['showError']> {
return [
'Potential Antivirus Intervention',
[
[
'It seems your antivirus software might have blocked the saving of the script.',
'privacy.sexy is secure, transparent, and open-source, but the scripts might still be mistakenly flagged by antivirus software such as Defender.',
].join(' '),
'\n',
'To resolve this, consider:',
'1. Checking your antivirus for any blocking notifications and allowing the script.',
'2. Temporarily disabling real-time protection or adding an exclusion for privacy.sexy scripts.',
'3. Re-attempting to save the script.',
'4. If the problem continues, review your antivirus logs for more details.',
'\n',
'To handle false warnings in Defender: Open "Virus & threat protection" from the "Start" menu.',
'\n',
'Always ensure to re-enable your antivirus protection promptly.',
'For more guidance, refer to your antivirus documentation.',
'\n',
'Technical Details:',
technicalDetails,
].join('\n'),
];
} }
</script> </script>

View File

@@ -49,11 +49,15 @@
</p> </p>
<p> <p>
These false positives are common for scripts that modify system settings. These false positives are common for scripts that modify system settings.
privacy.sexy is secure, transparent, and open-source.
</p> </p>
<p> <p>
To handle false warnings in Microsoft Defender: To handle false warnings in Microsoft Defender:
<ol> <ol>
<li>Open <strong>Virus & threat protection</strong> from the Start menu.</li> <li>
Open <strong>Virus & threat protection</strong> from
the <strong>Start</strong> menu.
</li>
<li> <li>
Locate the event in <strong>Protection history</strong> Locate the event in <strong>Protection history</strong>
that pertains to the script. that pertains to the script.

View File

@@ -119,7 +119,7 @@ describe('ProjectInformation', () => {
}); });
}); });
describe('correct retrieval of download URL for every supported operating system', () => { describe('correct retrieval of download URL for every supported operating system', () => {
const testCases: Record<SupportedOperatingSystem, { const testScenarios: Record<SupportedOperatingSystem, {
readonly expected: string, readonly expected: string,
readonly repositoryUrl: string, readonly repositoryUrl: string,
readonly version: string, readonly version: string,
@@ -143,7 +143,7 @@ describe('ProjectInformation', () => {
AllSupportedOperatingSystems.forEach((operatingSystem) => { AllSupportedOperatingSystems.forEach((operatingSystem) => {
it(`should return the expected download URL for ${OperatingSystem[operatingSystem]}`, () => { it(`should return the expected download URL for ${OperatingSystem[operatingSystem]}`, () => {
// arrange // arrange
const { expected, version, repositoryUrl } = testCases[operatingSystem]; const { expected, version, repositoryUrl } = testScenarios[operatingSystem];
const sut = new ProjectInformationBuilder() const sut = new ProjectInformationBuilder()
.withVersion(new VersionStub(version)) .withVersion(new VersionStub(version))
.withRepositoryUrl(repositoryUrl) .withRepositoryUrl(repositoryUrl)

View File

@@ -169,7 +169,7 @@ describe('PersistentDirectoryProvider', () => {
expect(error.message).to.include(expectedErrorMessage); expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType); expect(error.type).to.equal(expectedErrorType);
}); });
it(`logs error: ${description}`, async () => { it(`logs error - ${description}`, async () => {
// arrange // arrange
const loggerStub = new LoggerStub(); const loggerStub = new LoggerStub();
const context = buildFaultyContext( const context = buildFaultyContext(

View File

@@ -15,6 +15,8 @@ import { ScriptFilenameParts } from '@/infrastructure/CodeRunner/Creation/Script
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { expectTrue } from '@tests/shared/Assertions/ExpectTrue'; import { expectTrue } from '@tests/shared/Assertions/ExpectTrue';
import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import { CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, FileWriteOperationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
describe('ScriptFileCreationOrchestrator', () => { describe('ScriptFileCreationOrchestrator', () => {
describe('createScriptFile', () => { describe('createScriptFile', () => {
@@ -116,17 +118,16 @@ describe('ScriptFileCreationOrchestrator', () => {
describe('file writing', () => { describe('file writing', () => {
it('writes to generated file path', async () => { it('writes to generated file path', async () => {
// arrange // arrange
const filesystem = new FileSystemOpsStub(); const fileWriter = new ReadbackFileWriterStub();
const context = new ScriptFileCreatorTestSetup() const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub() .withFileWriter(fileWriter);
.withFileSystem(filesystem));
// act // act
const { success, scriptFileAbsolutePath } = await context.createScriptFile(); const { success, scriptFileAbsolutePath } = await context.createScriptFile();
// assert // assert
expectTrue(success); expectTrue(success);
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); const calls = fileWriter.callHistory.filter((call) => call.methodName === 'writeAndVerifyFile');
expect(calls.length).to.equal(1); expect(calls.length).to.equal(1);
const [actualFilePath] = calls[0].args; const [actualFilePath] = calls[0].args;
expect(actualFilePath).to.equal(scriptFileAbsolutePath); expect(actualFilePath).to.equal(scriptFileAbsolutePath);
@@ -134,23 +135,23 @@ describe('ScriptFileCreationOrchestrator', () => {
it('writes script content to file', async () => { it('writes script content to file', async () => {
// arrange // arrange
const expectedCode = 'expected-code'; const expectedCode = 'expected-code';
const filesystem = new FileSystemOpsStub(); const fileWriter = new ReadbackFileWriterStub();
const context = new ScriptFileCreatorTestSetup() const context = new ScriptFileCreatorTestSetup()
.withSystem(new SystemOperationsStub().withFileSystem(filesystem)) .withFileWriter(fileWriter)
.withFileContents(expectedCode); .withFileContents(expectedCode);
// act // act
await context.createScriptFile(); await context.createScriptFile();
// assert // assert
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); const calls = fileWriter.callHistory.filter((call) => call.methodName === 'writeAndVerifyFile');
expect(calls.length).to.equal(1); expect(calls.length).to.equal(1);
const [, actualData] = calls[0].args; const [, actualData] = calls[0].args;
expect(actualData).to.equal(expectedCode); expect(actualData).to.equal(expectedCode);
}); });
}); });
describe('error handling', () => { describe('error handling', () => {
const testScenarios: ReadonlyArray<{ interface FileCreationFailureTestScenario {
readonly description: string; readonly description: string;
readonly expectedErrorType: CodeRunErrorType; readonly expectedErrorType: CodeRunErrorType;
readonly expectedErrorMessage: string; readonly expectedErrorMessage: string;
@@ -160,7 +161,8 @@ describe('ScriptFileCreationOrchestrator', () => {
errorMessage: string, errorMessage: string,
errorType: CodeRunErrorType, errorType: CodeRunErrorType,
): ScriptFileCreatorTestSetup; ): ScriptFileCreatorTestSetup;
}> = [ }
const testScenarios: readonly FileCreationFailureTestScenario[] = [
{ {
description: 'path combination failure', description: 'path combination failure',
expectedErrorType: 'FilePathGenerationError', expectedErrorType: 'FilePathGenerationError',
@@ -174,19 +176,31 @@ describe('ScriptFileCreationOrchestrator', () => {
return setup.withSystem(new SystemOperationsStub().withLocation(locationStub)); return setup.withSystem(new SystemOperationsStub().withLocation(locationStub));
}, },
}, },
{ ...FileWriteOperationErrors.map((writeError): FileCreationFailureTestScenario => ({
description: 'file writing failure', description: `file writing failure: ${writeError}`,
expectedErrorType: 'FileWriteError', expectedErrorType: 'FileWriteError',
expectedErrorMessage: 'Error when writing to file', expectedErrorMessage: 'Error when writing to file',
expectLogs: true, expectLogs: false,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileSystemOpsStub(); const fileWriterStub = new ReadbackFileWriterStub();
fileSystemStub.writeToFile = () => { fileWriterStub.configureFailure(writeError, errorMessage);
throw new Error(errorMessage); return setup.withFileWriter(fileWriterStub);
};
return setup.withSystem(new SystemOperationsStub().withFileSystem(fileSystemStub));
}, },
})),
...FileReadbackVerificationErrors.map(
(verificationError): FileCreationFailureTestScenario => (
{
description: `file verification failure: ${verificationError}`,
expectedErrorType: 'FileReadbackVerificationError',
expectedErrorMessage: 'Error when verifying the file',
expectLogs: false,
buildFaultyContext: (setup, errorMessage) => {
const fileWriterStub = new ReadbackFileWriterStub();
fileWriterStub.configureFailure(verificationError, errorMessage);
return setup.withFileWriter(fileWriterStub);
}, },
}),
),
{ {
description: 'filename generation failure', description: 'filename generation failure',
expectedErrorType: 'FilePathGenerationError', expectedErrorType: 'FilePathGenerationError',
@@ -239,7 +253,7 @@ describe('ScriptFileCreationOrchestrator', () => {
expect(error.type).to.equal(expectedErrorType); expect(error.type).to.equal(expectedErrorType);
}); });
if (expectLogs) { if (expectLogs) {
it(`logs error: ${description}`, async () => { it(`logs error - ${description}`, async () => {
// arrange // arrange
const loggerStub = new LoggerStub(); const loggerStub = new LoggerStub();
const context = buildFaultyContext( const context = buildFaultyContext(
@@ -270,6 +284,8 @@ class ScriptFileCreatorTestSetup {
private logger: Logger = new LoggerStub(); private logger: Logger = new LoggerStub();
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
private fileContents = `[${ScriptFileCreatorTestSetup.name}] script file contents`; private fileContents = `[${ScriptFileCreatorTestSetup.name}] script file contents`;
private filenameParts: ScriptFilenameParts = { private filenameParts: ScriptFilenameParts = {
@@ -307,11 +323,17 @@ class ScriptFileCreatorTestSetup {
return this; return this;
} }
public withFileWriter(fileWriter: ReadbackFileWriter): this {
this.fileWriter = fileWriter;
return this;
}
public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> { public createScriptFile(): ReturnType<ScriptFileCreationOrchestrator['createScriptFile']> {
const creator = new ScriptFileCreationOrchestrator( const creator = new ScriptFileCreationOrchestrator(
this.system, this.system,
this.filenameGenerator, this.filenameGenerator,
this.directoryProvider, this.directoryProvider,
this.fileWriter,
this.logger, this.logger,
); );
return creator.createScriptFile(this.fileContents, this.filenameParts); return creator.createScriptFile(this.fileContents, this.filenameParts);

View File

@@ -4,8 +4,10 @@ import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOpera
import { Logger } from '@/application/Common/Log/Logger'; import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ReadbackFileWriterStub } from '@tests/unit/shared/Stubs/ReadbackFileWriterStub';
import { FileReadbackVerificationErrors, FileWriteOperationErrors, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub'; import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
import { NodeFileOperationsStub } from './NodeFileOperationsStub'; import { NodePathOperationsStub } from './NodePathOperationsStub';
describe('NodeElectronSaveFileDialog', () => { describe('NodeElectronSaveFileDialog', () => {
describe('dialog options', () => { describe('dialog options', () => {
@@ -53,7 +55,7 @@ describe('NodeElectronSaveFileDialog', () => {
const context = new SaveFileDialogTestSetup() const context = new SaveFileDialogTestSetup()
.withElectron(electronMock) .withElectron(electronMock)
.withDefaultFilename(expectedFileName) .withDefaultFilename(expectedFileName)
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator)); .withNode(new NodePathOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
// act // act
await context.saveFile(); await context.saveFile();
// assert // assert
@@ -118,16 +120,16 @@ describe('NodeElectronSaveFileDialog', () => {
const electronMock = new ElectronFileDialogOperationsStub() const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled) .withMimicUserCancel(isCancelled)
.withUserSelectedFilePath(expectedFilePath); .withUserSelectedFilePath(expectedFilePath);
const nodeMock = new NodeFileOperationsStub(); const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup() const context = new SaveFileDialogTestSetup()
.withElectron(electronMock) .withElectron(electronMock)
.withNode(nodeMock); .withFileWriter(fileWriterStub);
// act // act
await context.saveFile(); await context.saveFile();
// assert // assert
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile'); const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCalls).to.have.lengthOf(1); expect(saveFileCalls).to.have.lengthOf(1);
const [actualFilePath] = saveFileCalls[0].args; const [actualFilePath] = saveFileCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath); expect(actualFilePath).to.equal(expectedFilePath);
@@ -138,17 +140,17 @@ describe('NodeElectronSaveFileDialog', () => {
const isCancelled = false; const isCancelled = false;
const electronMock = new ElectronFileDialogOperationsStub() const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled); .withMimicUserCancel(isCancelled);
const nodeMock = new NodeFileOperationsStub(); const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup() const context = new SaveFileDialogTestSetup()
.withElectron(electronMock) .withElectron(electronMock)
.withFileContents(expectedFileContents) .withFileContents(expectedFileContents)
.withNode(nodeMock); .withFileWriter(fileWriterStub);
// act // act
await context.saveFile(); await context.saveFile();
// assert // assert
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile'); const saveFileCalls = fileWriterStub.callHistory.filter((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCalls).to.have.lengthOf(1); expect(saveFileCalls).to.have.lengthOf(1);
const [,actualFileContents] = saveFileCalls[0].args; const [,actualFileContents] = saveFileCalls[0].args;
expect(actualFileContents).to.equal(expectedFileContents); expect(actualFileContents).to.equal(expectedFileContents);
@@ -171,16 +173,16 @@ describe('NodeElectronSaveFileDialog', () => {
const isCancelled = true; const isCancelled = true;
const electronMock = new ElectronFileDialogOperationsStub() const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled); .withMimicUserCancel(isCancelled);
const nodeMock = new NodeFileOperationsStub(); const fileWriterStub = new ReadbackFileWriterStub();
const context = new SaveFileDialogTestSetup() const context = new SaveFileDialogTestSetup()
.withElectron(electronMock) .withElectron(electronMock)
.withNode(nodeMock); .withFileWriter(fileWriterStub);
// act // act
await context.saveFile(); await context.saveFile();
// assert // assert
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile'); const saveFileCall = fileWriterStub.callHistory.find((c) => c.methodName === 'writeAndVerifyFile');
expect(saveFileCall).to.equal(undefined); expect(saveFileCall).to.equal(undefined);
}); });
it('logs cancelation info', async () => { it('logs cancelation info', async () => {
@@ -219,32 +221,50 @@ describe('NodeElectronSaveFileDialog', () => {
}); });
describe('error handling', () => { describe('error handling', () => {
const testScenarios: ReadonlyArray<{ interface SaveFileFailureTestScenario {
readonly description: string; readonly description: string;
readonly expectedErrorType: SaveFileErrorType; readonly expectedErrorType: SaveFileErrorType;
readonly expectedErrorMessage: string; readonly expectedErrorMessage: string;
readonly expectLogs: boolean,
buildFaultyContext( buildFaultyContext(
setup: SaveFileDialogTestSetup, setup: SaveFileDialogTestSetup,
errorMessage: string, errorMessage: string,
): SaveFileDialogTestSetup; ): SaveFileDialogTestSetup;
}> = [ }
{ const testScenarios: ReadonlyArray<SaveFileFailureTestScenario> = [
description: 'file writing failure', ...FileWriteOperationErrors.map((writeError): SaveFileFailureTestScenario => ({
description: `file writing failure: ${writeError}`,
expectedErrorType: 'FileCreationError', expectedErrorType: 'FileCreationError',
expectedErrorMessage: 'Error when writing file', expectedErrorMessage: 'Error when writing to file',
expectLogs: false,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false); const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
const nodeMock = new NodeFileOperationsStub(); const fileWriterStub = new ReadbackFileWriterStub();
nodeMock.writeFile = () => Promise.reject(new Error(errorMessage)); fileWriterStub.configureFailure(writeError, errorMessage);
return setup return setup
.withElectron(electronMock) .withElectron(electronMock)
.withNode(nodeMock); .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', description: 'user path retrieval failure',
expectedErrorType: 'DialogDisplayError', expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when retrieving user path', expectedErrorMessage: 'Error when retrieving user path',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false); const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.getUserDownloadsPath = () => { electronMock.getUserDownloadsPath = () => {
@@ -258,8 +278,9 @@ describe('NodeElectronSaveFileDialog', () => {
description: 'path combination failure', description: 'path combination failure',
expectedErrorType: 'DialogDisplayError', expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when combining paths', expectedErrorMessage: 'Error when combining paths',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const nodeMock = new NodeFileOperationsStub(); const nodeMock = new NodePathOperationsStub();
nodeMock.join = () => { nodeMock.join = () => {
throw new Error(errorMessage); throw new Error(errorMessage);
}; };
@@ -271,6 +292,7 @@ describe('NodeElectronSaveFileDialog', () => {
description: 'dialog display failure', description: 'dialog display failure',
expectedErrorType: 'DialogDisplayError', expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Error when showing save dialog', expectedErrorMessage: 'Error when showing save dialog',
expectLogs: true,
buildFaultyContext: (setup, errorMessage) => { buildFaultyContext: (setup, errorMessage) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false); const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage)); electronMock.showSaveDialog = () => Promise.reject(new Error(errorMessage));
@@ -282,6 +304,7 @@ describe('NodeElectronSaveFileDialog', () => {
description: 'unexpected dialog return value failure', description: 'unexpected dialog return value failure',
expectedErrorType: 'DialogDisplayError', expectedErrorType: 'DialogDisplayError',
expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.', expectedErrorMessage: 'Unexpected Error: File path is undefined after save dialog completion.',
expectLogs: true,
buildFaultyContext: (setup) => { buildFaultyContext: (setup) => {
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false); const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
electronMock.showSaveDialog = () => Promise.resolve({ electronMock.showSaveDialog = () => Promise.resolve({
@@ -294,7 +317,7 @@ describe('NodeElectronSaveFileDialog', () => {
}, },
]; ];
testScenarios.forEach(({ testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext, description, expectedErrorType, expectedErrorMessage, buildFaultyContext, expectLogs,
}) => { }) => {
it(`handles error - ${description}`, async () => { it(`handles error - ${description}`, async () => {
// arrange // arrange
@@ -312,7 +335,8 @@ describe('NodeElectronSaveFileDialog', () => {
expect(error.message).to.include(expectedErrorMessage); expect(error.message).to.include(expectedErrorMessage);
expect(error.type).to.equal(expectedErrorType); expect(error.type).to.equal(expectedErrorType);
}); });
it(`logs error: ${description}`, async () => { if (expectLogs) {
it(`logs error - ${description}`, async () => {
// arrange // arrange
const loggerStub = new LoggerStub(); const loggerStub = new LoggerStub();
const context = buildFaultyContext( const context = buildFaultyContext(
@@ -327,6 +351,7 @@ describe('NodeElectronSaveFileDialog', () => {
// assert // assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage); loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
}); });
}
}); });
}); });
}); });
@@ -342,7 +367,9 @@ class SaveFileDialogTestSetup {
private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub(); private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub();
private node: NodeFileOperations = new NodeFileOperationsStub(); private node: NodeFileOperations = new NodePathOperationsStub();
private fileWriter: ReadbackFileWriter = new ReadbackFileWriterStub();
public withElectron(electron: ElectronFileDialogOperations): this { public withElectron(electron: ElectronFileDialogOperations): this {
this.electron = electron; this.electron = electron;
@@ -354,6 +381,11 @@ class SaveFileDialogTestSetup {
return this; return this;
} }
public withFileWriter(fileWriter: ReadbackFileWriter): this {
this.fileWriter = fileWriter;
return this;
}
public withLogger(logger: Logger): this { public withLogger(logger: Logger): this {
this.logger = logger; this.logger = logger;
return this; return this;
@@ -375,7 +407,12 @@ class SaveFileDialogTestSetup {
} }
public saveFile() { public saveFile() {
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node); const dialog = new NodeElectronSaveFileDialog(
this.logger,
this.electron,
this.node,
this.fileWriter,
);
return dialog.saveFile( return dialog.saveFile(
this.fileContents, this.fileContents,
this.filename, this.filename,

View File

@@ -1,29 +0,0 @@
import { NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
export class NodeFileOperationsStub
extends StubWithObservableMethodCalls<NodeFileOperations>
implements NodeFileOperations {
private pathSegmentSeparator = `[${NodeFileOperationsStub.name} path segment separator]`;
public join(...paths: string[]): string {
this.registerMethodCall({
methodName: 'join',
args: [...paths],
});
return paths.join(this.pathSegmentSeparator);
}
public writeFile(file: string, data: string): Promise<void> {
this.registerMethodCall({
methodName: 'writeFile',
args: [file, data],
});
return Promise.resolve();
}
public withPathSegmentSeparator(pathSegmentSeparator: string): this {
this.pathSegmentSeparator = pathSegmentSeparator;
return this;
}
}

View File

@@ -0,0 +1,21 @@
import { NodeFileOperations as NodePathOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
export class NodePathOperationsStub
extends StubWithObservableMethodCalls<NodePathOperations>
implements NodePathOperations {
private pathSegmentSeparator = `[${NodePathOperationsStub.name} path segment separator]`;
public join(...paths: string[]): string {
this.registerMethodCall({
methodName: 'join',
args: [...paths],
});
return paths.join(this.pathSegmentSeparator);
}
public withPathSegmentSeparator(pathSegmentSeparator: string): this {
this.pathSegmentSeparator = pathSegmentSeparator;
return this;
}
}

View File

@@ -0,0 +1,34 @@
import { FileReadWriteOperations } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
export class FileReadWriteOperationsStub
extends StubWithObservableMethodCalls<FileReadWriteOperations>
implements FileReadWriteOperations {
private readonly writtenFiles: Record<string, string> = {};
public writeFile = (filePath: string, fileContents: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'writeFile',
args: [filePath, fileContents, encoding],
});
this.writtenFiles[filePath] = fileContents;
return Promise.resolve();
};
public access = (...args: Parameters<FileReadWriteOperations['access']>) => {
this.registerMethodCall({
methodName: 'access',
args: [...args],
});
return Promise.resolve();
};
public readFile = (filePath: string, encoding: NodeJS.BufferEncoding) => {
this.registerMethodCall({
methodName: 'readFile',
args: [filePath, encoding],
});
const fileContents = this.writtenFiles[filePath];
return Promise.resolve(fileContents);
};
}

View File

@@ -0,0 +1,299 @@
import { constants } from 'node:fs';
import { describe, it, expect } from 'vitest';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { FunctionKeys } from '@/TypeHelpers';
import { sequenceEqual } from '@/application/Common/Array';
import { FileWriteErrorType } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { FileReadWriteOperations, NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { FileReadWriteOperationsStub } from './FileReadWriteOperationsStub';
describe('NodeReadbackFileWriter', () => {
describe('writeAndVerifyFile', () => {
describe('successful write and verify operations', () => {
it('confirms successful operation', async () => {
// arrange
const context = new NodeReadbackFileWriterTestSetup();
// act
const { success } = await context.writeAndVerifyFile();
// assert
expect(success).to.equal(true);
});
describe('file write operations', () => {
it('writes to specified path', async () => {
// arrange
const expectedFilePath = 'test.txt';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFilePath(expectedFilePath)
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [actualFilePath] = fileWriteCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('writes specified contents', async () => {
// arrange
const expectedFileContents = 'expected file contents';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFileContents(expectedFileContents);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [,actualFileContents] = fileWriteCalls[0].args;
expect(actualFileContents).to.equal(expectedFileContents);
});
it('uses correct encoding', async () => {
// arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileWriteCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'writeFile');
expect(fileWriteCalls).to.have.lengthOf(1);
const [,,actualEncoding] = fileWriteCalls[0].args;
expect(actualEncoding).to.equal(expectedEncoding);
});
});
describe('existence verification', () => {
it('checks correct path', async () => {
// arrange
const expectedFilePath = 'test-file-path';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath);
// act
await context.writeAndVerifyFile();
// assert
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
expect(accessCalls).to.have.lengthOf(1);
const [actualFilePath] = accessCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('uses correct mode', async () => {
// arrange
const expectedMode = constants.F_OK;
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const accessCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'access');
expect(accessCalls).to.have.lengthOf(1);
const [,actualMode] = accessCalls[0].args;
expect(actualMode).to.equal(expectedMode);
});
});
describe('content verification', () => {
it('reads from correct path', async () => {
// arrange
const expectedFilePath = 'expected-file-path.txt';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub)
.withFilePath(expectedFilePath);
// act
await context.writeAndVerifyFile();
// assert
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
expect(fileReadCalls).to.have.lengthOf(1);
const [actualFilePath] = fileReadCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('uses correct encoding', async () => {
// arrange
const expectedEncoding: NodeJS.BufferEncoding = 'utf-8';
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const fileReadCalls = fileSystemStub.callHistory.filter((c) => c.methodName === 'readFile');
expect(fileReadCalls).to.have.lengthOf(1);
const [,actualEncoding] = fileReadCalls[0].args;
expect(actualEncoding).to.equal(expectedEncoding);
});
});
it('executes file system operations in correct sequence', async () => {
// arrange
const expectedOrder: ReadonlyArray<FunctionKeys<FileReadWriteOperations>> = [
'writeFile',
'access',
'readFile',
];
const fileSystemStub = new FileReadWriteOperationsStub();
const context = new NodeReadbackFileWriterTestSetup()
.withFileSystem(fileSystemStub);
// act
await context.writeAndVerifyFile();
// assert
const actualOrder = fileSystemStub.callHistory.map((c) => c.methodName);
expect(sequenceEqual(expectedOrder, actualOrder)).to.equal(true);
});
});
describe('error handling', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectedErrorType: FileWriteErrorType;
readonly expectedErrorMessage: string;
buildFaultyContext(
setup: NodeReadbackFileWriterTestSetup,
errorMessage: string,
): NodeReadbackFileWriterTestSetup;
}> = [
{
description: 'writing failure',
expectedErrorType: 'WriteOperationFailed',
expectedErrorMessage: 'Error when writing file',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.writeFile = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'existence verification error',
expectedErrorType: 'FileExistenceVerificationFailed',
expectedErrorMessage: 'Access denied',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.access = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'reading failure',
expectedErrorType: 'ReadVerificationFailed',
expectedErrorMessage: 'Read error',
buildFaultyContext: (setup, errorMessage) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.readFile = () => Promise.reject(errorMessage);
return setup
.withFileSystem(fileSystemStub);
},
},
{
description: 'content match failure',
expectedErrorType: 'ContentVerificationFailed',
expectedErrorMessage: 'The contents of the written file do not match the expected contents.',
buildFaultyContext: (setup) => {
const fileSystemStub = new FileReadWriteOperationsStub();
fileSystemStub.readFile = () => Promise.resolve('different contents');
return setup
.withFileSystem(fileSystemStub);
},
},
];
testScenarios.forEach(({
description, expectedErrorType, expectedErrorMessage, buildFaultyContext,
}) => {
it(`handles error - ${description}`, async () => {
// arrange
const context = buildFaultyContext(
new NodeReadbackFileWriterTestSetup(),
expectedErrorMessage,
);
// act
const { success, error } = await context.writeAndVerifyFile();
// 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 NodeReadbackFileWriterTestSetup()
.withLogger(loggerStub),
expectedErrorMessage,
);
// act
await context.writeAndVerifyFile();
// assert
loggerStub.assertLogsContainMessagePart('error', expectedErrorMessage);
});
});
});
});
});
class NodeReadbackFileWriterTestSetup {
private logger: Logger = new LoggerStub();
private fileSystem: FileReadWriteOperations = new FileReadWriteOperationsStub();
private filePath = '/test/file/path.txt';
private fileContents = 'test file contents';
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
public withFileSystem(fileSystem: FileReadWriteOperations): this {
this.fileSystem = fileSystem;
return this;
}
public withFilePath(filePath: string): this {
this.filePath = filePath;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public writeAndVerifyFile(): ReturnType<NodeReadbackFileWriter['writeAndVerifyFile']> {
const writer = new NodeReadbackFileWriter(
this.logger,
this.fileSystem,
);
return writer.writeAndVerifyFile(
this.filePath,
this.fileContents,
);
}
}

View File

@@ -19,12 +19,4 @@ export class FileSystemOpsStub
}); });
return Promise.resolve(); return Promise.resolve();
} }
public writeToFile(filePath: string, data: string): Promise<void> {
this.registerMethodCall({
methodName: 'writeToFile',
args: [filePath, data],
});
return Promise.resolve();
}
} }

View File

@@ -0,0 +1,29 @@
import { FileWriteErrorType, FileWriteOutcome, ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class ReadbackFileWriterStub
extends StubWithObservableMethodCalls<ReadbackFileWriter>
implements ReadbackFileWriter {
private outcome: FileWriteOutcome = { success: true };
public writeAndVerifyFile(
...args: Parameters<ReadbackFileWriter['writeAndVerifyFile']>
): Promise<FileWriteOutcome> {
this.registerMethodCall({
methodName: 'writeAndVerifyFile',
args: [...args],
});
return Promise.resolve(this.outcome);
}
public configureFailure(errorType: FileWriteErrorType, message: string): this {
this.outcome = {
success: false,
error: {
type: errorType,
message: `[${ReadbackFileWriterStub.name}] ${message}`,
},
};
return this;
}
}