From 6ada8d425c4a7df88490756187c84b5c57ed1dcc Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Wed, 17 Jan 2024 23:59:05 +0100 Subject: [PATCH] Improve script error dialogs #304 - Include the script's directory path #304. - Exclude Windows-specific instructions on non-Windows OS. - Standardize language across dialogs for consistency. Other supporting changes: - Add script diagnostics data collection from main process. - Document script file storage and execution tamper protection in SECURITY.md. - Remove redundant comment in `NodeReadbackFileWriter`. - Centralize error display for uniformity and simplicity. - Simpify `WindowVariablesValidator` to omit checks when not on the renderer process. - Improve and centralize Electron environment detection. - Use more emphatic language (don't worry) in error messages. --- SECURITY.md | 4 + docs/desktop-vs-web-features.md | 21 +- .../ScriptDiagnosticsCollector.ts | 10 + .../NodeReadbackFileWriter.ts | 5 - .../ContextIsolatedElectronDetector.ts | 54 +++ .../Electron/ElectronEnvironmentDetector.ts | 6 + .../RuntimeEnvironmentFactory.ts | 45 +- .../ScriptEnvironmentDiagnosticsCollector.ts | 20 + .../WindowVariables/WindowVariables.ts | 2 + .../WindowVariablesValidator.ts | 48 ++- .../bootstrapping/DependencyProvider.ts | 5 + .../Code/CodeButtons/CodeRunButton.vue | 72 +--- .../Code/CodeButtons/Save/CodeSaveButton.vue | 67 +-- .../Steps/Platforms/WindowsInstructions.vue | 2 +- .../Code/CodeButtons/ScriptErrorDialog.ts | 205 ++++++++++ .../Hooks/UseScriptDiagnosticsCollector.ts | 9 + .../electron/main/IpcRegistration.ts | 11 +- .../ContextBridging/RendererApiProvider.ts | 11 +- .../IpcBridging/IpcChannelDefinitions.ts | 2 + src/presentation/index.html | 7 +- src/presentation/injectionSymbols.ts | 2 + .../ContextIsolatedElectronDetector.spec.ts | 164 ++++++++ .../RuntimeEnvironmentFactory.spec.ts | 98 ++--- .../WindowVariablesValidator.spec.ts | 384 +++++++++--------- ...iptEnvironmentDiagnosticsCollector.spec.ts | 65 +++ .../bootstrapping/DependencyProvider.spec.ts | 1 + .../CodeButtons/ScriptErrorDialog.spec.ts | 149 +++++++ .../UseScriptDiagnosticsCollector.spec.ts | 27 ++ .../electron/main/IpcRegistration.spec.ts | 26 +- .../RendererApiProvider.spec.ts | 13 +- .../shared/IpcChannelDefinitions.spec.ts | 4 + .../Stubs/ElectronEnvironmentDetectorStub.ts | 26 ++ .../Stubs/ScriptDiagnosticsCollectorStub.ts | 25 ++ .../unit/shared/Stubs/WindowVariablesStub.ts | 12 + 34 files changed, 1167 insertions(+), 435 deletions(-) create mode 100644 src/application/ScriptDiagnostics/ScriptDiagnosticsCollector.ts create mode 100644 src/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.ts create mode 100644 src/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector.ts create mode 100644 src/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.ts create mode 100644 src/presentation/components/Code/CodeButtons/ScriptErrorDialog.ts create mode 100644 src/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.ts create mode 100644 tests/unit/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.spec.ts create mode 100644 tests/unit/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.spec.ts create mode 100644 tests/unit/presentation/components/Code/CodeButtons/ScriptErrorDialog.spec.ts create mode 100644 tests/unit/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.spec.ts create mode 100644 tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub.ts create mode 100644 tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub.ts diff --git a/SECURITY.md b/SECURITY.md index 33132c60..91f30d94 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,6 +41,10 @@ privacy.sexy adopts a defense in depth strategy to protect users on multiple lay The desktop application operates without persistent administrative or `sudo` privileges, reinforcing its security posture. It requests elevation of privileges for system modifications with explicit user consent and logs every action taken with high privileges. This approach actively minimizes potential security risks by limiting privileged operations and aligning with the principle of least privilege. +- **Secure Script Execution/Storage:** + Before executing any script, the desktop application stores a copy to allow antivirus software to perform scans. This safeguards against + any unwanted modifications. Furthermore, the application incorporates integrity checks for tamper protection. If the script file differs from + the user's selected script, the application will not execute or save the script, ensuring the processing of authentic scripts. ### Update Security and Integrity diff --git a/docs/desktop-vs-web-features.md b/docs/desktop-vs-web-features.md index a5492834..6565d150 100644 --- a/docs/desktop-vs-web-features.md +++ b/docs/desktop-vs-web-features.md @@ -10,7 +10,8 @@ This table highlights differences between the desktop and web versions of `priva | [Logging](#logging) | 🟢 Available | 🔴 Not available | | [Script execution](#script-execution) | 🟢 Available | 🔴 Not available | | [Error handling](#error-handling) | 🟢 Advanced | 🟡 Limited | -| [Native dialogs](#error-handling) | 🟢 Available | 🔴 Not available | +| [Native dialogs](#native-dialogs) | 🟢 Available | 🔴 Not available | +| [Secure script execution/storage](#secure-script-executionstorage) | 🟢 Available | 🔴 Not available | ## Feature descriptions @@ -74,3 +75,21 @@ In contrast, the web version has more basic error handling due to browser limita The desktop version uses native dialogs, offering more features and reliability compared to the browser's file system dialogs. These native dialogs provide a more integrated and user-friendly experience, aligning with the operating system's standard interface and functionalities. + +### Secure script execution/storage + +**Integrity checks:** + +The desktop version of privacy.sexy implements robust integrity checks for both script execution and storage. +Featuring tamper protection, the application actively verifies the integrity of script files before executing or saving them. +If the actual contents of a script file do not align with the expected contents, the application refuses to execute or save the script. +This proactive approach ensures only unaltered and verified scripts undergo processing, thereby enhancing both security and reliability. +Due to browser constraints, this feature is absent in the web version. + +**Error handling:** + +In scenarios where script execution or storage encounters failure, the desktop application initiates automated troubleshooting and self-healing processes. +It also guides users through potential issues with filesystem or third-party software, such as antivirus interventions. +Specifically, the application is capable of identifying when antivirus software blocks or removes a script, providing users with tailored error messages +and detailed resolution steps. This level of proactive error handling and user guidance enhances the application's security and reliability, +offering a feature not achievable in the web version due to browser limitations. diff --git a/src/application/ScriptDiagnostics/ScriptDiagnosticsCollector.ts b/src/application/ScriptDiagnostics/ScriptDiagnosticsCollector.ts new file mode 100644 index 00000000..0f578d08 --- /dev/null +++ b/src/application/ScriptDiagnostics/ScriptDiagnosticsCollector.ts @@ -0,0 +1,10 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export interface ScriptDiagnosticsCollector { + collectDiagnosticInformation(): Promise; +} + +export interface ScriptDiagnosticData { + readonly scriptsDirectoryAbsolutePath?: string; + readonly currentOperatingSystem?: OperatingSystem; +} diff --git a/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts b/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts index e559502f..ea5042ed 100644 --- a/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts +++ b/src/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter.ts @@ -27,11 +27,6 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter { const fileWritePipelineActions: ReadonlyArray<() => Promise> = [ () => 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) { diff --git a/src/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.ts b/src/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.ts new file mode 100644 index 00000000..8566573b --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.ts @@ -0,0 +1,54 @@ +import { ElectronEnvironmentDetector, ElectronProcessType } from './ElectronEnvironmentDetector'; + +export class ContextIsolatedElectronDetector implements ElectronEnvironmentDetector { + constructor( + private readonly nodeProcessAccessor: NodeProcessAccessor = () => globalThis?.process, + private readonly userAgentAccessor: UserAgentAccessor = () => globalThis?.navigator?.userAgent, + ) { } + + public isRunningInsideElectron(): boolean { + return isNodeProcessElectronBased(this.nodeProcessAccessor) + || isUserAgentElectronBased(this.userAgentAccessor); + } + + public determineElectronProcessType(): ElectronProcessType { + const isNodeAccessible = isNodeProcessElectronBased(this.nodeProcessAccessor); + const isBrowserAccessible = isUserAgentElectronBased(this.userAgentAccessor); + if (!isNodeAccessible && !isBrowserAccessible) { + throw new Error('Unable to determine the Electron process type. Neither Node.js nor browser-based Electron contexts were detected.'); + } + if (isNodeAccessible && isBrowserAccessible) { + return 'preloader'; // Only preloader can access both Node.js and browser contexts in Electron with context isolation. + } + if (isNodeAccessible) { + return 'main'; + } + return 'renderer'; + } +} + +export type NodeProcessAccessor = () => NodeJS.Process | undefined; + +function isNodeProcessElectronBased(nodeProcessAccessor: NodeProcessAccessor): boolean { + const nodeProcess = nodeProcessAccessor(); + if (!nodeProcess) { + return false; + } + if (nodeProcess.versions.electron) { + // Electron populates `nodeProcess.versions.electron` with its version, see https://web.archive.org/web/20240113162837/https://www.electronjs.org/docs/latest/api/process#processversionselectron-readonly. + return true; + } + return false; +} + +export type UserAgentAccessor = () => string | undefined; + +function isUserAgentElectronBased( + userAgentAccessor: UserAgentAccessor, +): boolean { + const userAgent = userAgentAccessor(); + if (userAgent?.includes('Electron')) { + return true; + } + return false; +} diff --git a/src/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector.ts b/src/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector.ts new file mode 100644 index 00000000..2a6dbaa5 --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector.ts @@ -0,0 +1,6 @@ +export interface ElectronEnvironmentDetector { + isRunningInsideElectron(): boolean; + determineElectronProcessType(): ElectronProcessType; +} + +export type ElectronProcessType = 'main' | 'preloader' | 'renderer'; diff --git a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.ts b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.ts index 73bda1fc..c27d96c7 100644 --- a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.ts +++ b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.ts @@ -1,49 +1,32 @@ +import { ElectronEnvironmentDetector } from './Electron/ElectronEnvironmentDetector'; import { BrowserRuntimeEnvironment } from './Browser/BrowserRuntimeEnvironment'; import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment'; import { RuntimeEnvironment } from './RuntimeEnvironment'; +import { ContextIsolatedElectronDetector } from './Electron/ContextIsolatedElectronDetector'; -export const CurrentEnvironment = determineAndCreateRuntimeEnvironment({ - window: globalThis.window, - process: globalThis.process, -}); +export const CurrentEnvironment = determineAndCreateRuntimeEnvironment(globalThis.window); export function determineAndCreateRuntimeEnvironment( - globalAccessor: GlobalPropertiesAccessor, + globalWindow: Window | undefined | null = globalThis.window, + electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(), browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = ( window, ) => new BrowserRuntimeEnvironment(window), - nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = ( - process: NodeJS.Process, - ) => new NodeRuntimeEnvironment(process), + nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = () => new NodeRuntimeEnvironment(), ): RuntimeEnvironment { - if (isElectronMainProcess(globalAccessor.process)) { - return nodeEnvironmentFactory(globalAccessor.process); + if ( + electronDetector.isRunningInsideElectron() + && electronDetector.determineElectronProcessType() === 'main') { + return nodeEnvironmentFactory(); } - const { window } = globalAccessor; - if (!window) { + if (!globalWindow) { throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.'); } - return browserEnvironmentFactory(window); -} - -function isElectronMainProcess( - nodeProcess: NodeJS.Process | undefined, -): nodeProcess is NodeJS.Process { - // Electron populates `nodeProcess.versions.electron` with its version, see https://web.archive.org/web/20240113162837/https://www.electronjs.org/docs/latest/api/process#processversionselectron-readonly. - if (!nodeProcess) { - return false; - } - if (nodeProcess.versions.electron) { - return true; - } - return false; + return browserEnvironmentFactory(globalWindow); } export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment; -export type NodeRuntimeEnvironmentFactory = (process: NodeJS.Process) => NodeRuntimeEnvironment; +export type NodeRuntimeEnvironmentFactory = () => NodeRuntimeEnvironment; -export interface GlobalPropertiesAccessor { - readonly window: Window | undefined; - readonly process: NodeJS.Process | undefined; -} +export type GlobalWindowAccessor = Window | undefined; diff --git a/src/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.ts b/src/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.ts new file mode 100644 index 00000000..1436cc1d --- /dev/null +++ b/src/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.ts @@ -0,0 +1,20 @@ +import { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; +import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider'; +import { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider'; + +export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector { + constructor( + private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), + private readonly environment: RuntimeEnvironment = CurrentEnvironment, + ) { } + + public async collectDiagnosticInformation(): Promise { + const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory(); + return { + scriptsDirectoryAbsolutePath: directoryAbsolutePath, + currentOperatingSystem: this.environment.os, + }; + } +} diff --git a/src/infrastructure/WindowVariables/WindowVariables.ts b/src/infrastructure/WindowVariables/WindowVariables.ts index 0912f5ba..dde89a68 100644 --- a/src/infrastructure/WindowVariables/WindowVariables.ts +++ b/src/infrastructure/WindowVariables/WindowVariables.ts @@ -2,6 +2,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { Logger } from '@/application/Common/Log/Logger'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { Dialog } from '@/presentation/common/Dialog'; +import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; /* Primary entry point for platform-specific injections */ export interface WindowVariables { @@ -10,4 +11,5 @@ export interface WindowVariables { readonly os?: OperatingSystem; readonly log?: Logger; readonly dialog?: Dialog; + readonly scriptDiagnosticsCollector?: ScriptDiagnosticsCollector; } diff --git a/src/infrastructure/WindowVariables/WindowVariablesValidator.ts b/src/infrastructure/WindowVariables/WindowVariablesValidator.ts index 751809e3..f8d3584a 100644 --- a/src/infrastructure/WindowVariables/WindowVariablesValidator.ts +++ b/src/infrastructure/WindowVariables/WindowVariablesValidator.ts @@ -1,3 +1,5 @@ +import { ContextIsolatedElectronDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector'; +import { ElectronEnvironmentDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject, @@ -7,7 +9,14 @@ import { WindowVariables } from './WindowVariables'; /** * Checks for consistency in runtime environment properties injected by Electron preloader. */ -export function validateWindowVariables(variables: Partial) { +export function validateWindowVariables( + variables: Partial, + electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(), +) { + if (!electronDetector.isRunningInsideElectron() + || electronDetector.determineElectronProcessType() !== 'renderer') { + return; + } if (!isPlainObject(variables)) { throw new Error('window is not an object'); } @@ -20,12 +29,11 @@ export function validateWindowVariables(variables: Partial) { function* testEveryProperty(variables: Partial): Iterable { const tests: Record>, boolean> = { os: testOperatingSystem(variables.os), - isRunningAsDesktopApplication: testIsRunningAsDesktopApplication( - variables.isRunningAsDesktopApplication, - ), + isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(variables), codeRunner: testCodeRunner(variables), log: testLogger(variables), dialog: testDialog(variables), + scriptDiagnosticsCollector: testScriptDiagnosticsCollector(variables), }; for (const [propertyName, testResult] of Object.entries(tests)) { @@ -49,30 +57,30 @@ function testOperatingSystem(os: unknown): boolean { } function testLogger(variables: Partial): boolean { - if (!variables.isRunningAsDesktopApplication) { - return true; - } - return isPlainObject(variables.log); + return isPlainObject(variables.log) + && isFunction(variables.log.debug) + && isFunction(variables.log.info) + && isFunction(variables.log.error) + && isFunction(variables.log.warn); } function testCodeRunner(variables: Partial): boolean { - if (!variables.isRunningAsDesktopApplication) { - return true; - } return isPlainObject(variables.codeRunner) && isFunction(variables.codeRunner.runCode); } -function testIsRunningAsDesktopApplication(isRunningAsDesktopApplication: unknown): boolean { - if (isRunningAsDesktopApplication === undefined) { - return true; - } - return isBoolean(isRunningAsDesktopApplication); +function testIsRunningAsDesktopApplication(variables: Partial): boolean { + return isBoolean(variables.isRunningAsDesktopApplication) + && variables.isRunningAsDesktopApplication === true; } function testDialog(variables: Partial): boolean { - if (!variables.isRunningAsDesktopApplication) { - return true; - } - return isPlainObject(variables.dialog); + return isPlainObject(variables.dialog) + && isFunction(variables.dialog.saveFile) + && isFunction(variables.dialog.showError); +} + +function testScriptDiagnosticsCollector(variables: Partial): boolean { + return isPlainObject(variables.scriptDiagnosticsCollector) + && isFunction(variables.scriptDiagnosticsCollector.collectDiagnosticInformation); } diff --git a/src/presentation/bootstrapping/DependencyProvider.ts b/src/presentation/bootstrapping/DependencyProvider.ts index 0d78387d..c474e240 100644 --- a/src/presentation/bootstrapping/DependencyProvider.ts +++ b/src/presentation/bootstrapping/DependencyProvider.ts @@ -15,6 +15,7 @@ import { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger' import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { useDialog } from '@/presentation/components/Shared/Hooks/Dialog/UseDialog'; +import { useScriptDiagnosticsCollector } from '@/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector'; export function provideDependencies( context: IApplicationContext, @@ -72,6 +73,10 @@ export function provideDependencies( InjectionKeys.useDialog, useDialog, ), + useScriptDiagnosticsCollector: (di) => di.provide( + InjectionKeys.useScriptDiagnosticsCollector, + useScriptDiagnosticsCollector, + ), }; registerAll(Object.values(resolvers), api); } diff --git a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue index 6ee908c3..8d3a0faa 100644 --- a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue +++ b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue @@ -11,9 +11,8 @@ import { defineComponent, computed } from 'vue'; import { injectKey } from '@/presentation/injectionSymbols'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { Dialog } from '@/presentation/common/Dialog'; -import { CodeRunError } from '@/application/CodeRunner/CodeRunner'; import IconButton from './IconButton.vue'; +import { createScriptErrorDialog } from './ScriptErrorDialog'; export default defineComponent({ components: { @@ -24,6 +23,7 @@ export default defineComponent({ const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment); const { codeRunner } = injectKey((keys) => keys.useCodeRunner); const { dialog } = injectKey((keys) => keys.useDialog); + const { scriptDiagnosticsCollector } = injectKey((keys) => keys.useScriptDiagnosticsCollector); const canRun = computed(() => getCanRunState( currentState.value.os, @@ -38,7 +38,12 @@ export default defineComponent({ currentContext.state.collection.scripting.fileExtension, ); if (!success) { - showScriptRunError(dialog, error); + dialog.showError(...(await createScriptErrorDialog({ + errorContext: 'run', + errorType: error.type, + errorMessage: error.message, + isFileReadbackError: error.type === 'FileReadbackVerificationError', + }, scriptDiagnosticsCollector))); } } @@ -57,65 +62,4 @@ function getCanRunState( const isRunningOnSelectedOs = selectedOs === hostOs; return isRunningAsDesktopApplication && isRunningOnSelectedOs; } - -function showScriptRunError(dialog: Dialog, error: CodeRunError) { - const technicalDetails = `[${error.type}] ${error.message}`; - dialog.showError( - ...( - error.type === 'FileReadbackVerificationError' - ? createAntivirusErrorDialog(technicalDetails) - : createGenericErrorDialog(technicalDetails)), - ); -} - -function createGenericErrorDialog(technicalDetails: string): Parameters { - return [ - 'Error Running Script', - [ - 'We encountered an issue while running the script.', - 'This could be due to a variety of factors such as system permissions, resource constraints, or security software interventions.', - '\n', - 'Here are some steps you can take:', - '- Confirm that you have the necessary permissions to execute scripts on your system.', - '- Check if there is sufficient disk space and system resources available.', - [ - '- 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.', - '- Should the problem persist, reach out to the community for further assistance.', - '\n', - 'Technical Details:', - technicalDetails, - ].join('\n'), - ]; -} - -function createAntivirusErrorDialog(technicalDetails: string): Parameters { - 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'), - ]; -} diff --git a/src/presentation/components/Code/CodeButtons/Save/CodeSaveButton.vue b/src/presentation/components/Code/CodeButtons/Save/CodeSaveButton.vue index d43ae653..8bc0a0d9 100644 --- a/src/presentation/components/Code/CodeButtons/Save/CodeSaveButton.vue +++ b/src/presentation/components/Code/CodeButtons/Save/CodeSaveButton.vue @@ -20,8 +20,9 @@ import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue' import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename'; -import { Dialog, FileType, SaveFileError } from '@/presentation/common/Dialog'; +import { FileType } from '@/presentation/common/Dialog'; import IconButton from '../IconButton.vue'; +import { createScriptErrorDialog } from '../ScriptErrorDialog'; import RunInstructions from './RunInstructions/RunInstructions.vue'; export default defineComponent({ @@ -34,6 +35,7 @@ export default defineComponent({ const { currentState } = injectKey((keys) => keys.useCollectionState); const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment); const { dialog } = injectKey((keys) => keys.useDialog); + const { scriptDiagnosticsCollector } = injectKey((keys) => keys.useScriptDiagnosticsCollector); const areInstructionsVisible = ref(false); const filename = computed(() => buildFilename(currentState.value.collection.scripting)); @@ -45,7 +47,12 @@ export default defineComponent({ getType(currentState.value.collection.scripting.language), ); if (!success) { - showScriptSaveError(dialog, error); + dialog.showError(...(await createScriptErrorDialog({ + errorContext: 'save', + errorType: error.type, + errorMessage: error.message, + isFileReadbackError: error.type === 'FileReadbackVerificationError', + }, scriptDiagnosticsCollector))); return; } areInstructionsVisible.value = true; @@ -77,60 +84,4 @@ function buildFilename(scripting: IScriptingDefinition) { } return ScriptFilename; } - -function showScriptSaveError(dialog: Dialog, error: SaveFileError) { - const technicalDetails = `[${error.type}] ${error.message}`; - dialog.showError( - ...( - error.type === 'FileReadbackVerificationError' - ? createAntivirusErrorDialog(technicalDetails) - : createGenericErrorDialog(technicalDetails)), - ); -} - -function createGenericErrorDialog(technicalDetails: string): Parameters { - return [ - 'Error Saving Script', - [ - 'An error occurred while saving the script.', - 'This issue may arise from insufficient permissions, limited disk space, or interference from security software.', - '\n', - 'To address this:', - '- Verify your permissions for the selected save directory.', - '- Check available disk space.', - '- Review your antivirus or security settings; adding an exclusion for privacy.sexy might be necessary.', - '- Try saving the script to a different location or modifying your selection.', - '- If the problem persists, reach out to the community for further assistance.', - '\n', - 'Technical Details:', - technicalDetails, - ].join('\n'), - ]; -} - -function createAntivirusErrorDialog(technicalDetails: string): Parameters { - 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'), - ]; -} diff --git a/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/Platforms/WindowsInstructions.vue b/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/Platforms/WindowsInstructions.vue index 732216a0..9d7f6d6a 100644 --- a/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/Platforms/WindowsInstructions.vue +++ b/src/presentation/components/Code/CodeButtons/Save/RunInstructions/Steps/Platforms/WindowsInstructions.vue @@ -49,7 +49,7 @@

These false positives are common for scripts that modify system settings. - privacy.sexy is secure, transparent, and open-source. + Don't worry; privacy.sexy is secure, transparent, and open-source.

To handle false warnings in Microsoft Defender: diff --git a/src/presentation/components/Code/CodeButtons/ScriptErrorDialog.ts b/src/presentation/components/Code/CodeButtons/ScriptErrorDialog.ts new file mode 100644 index 00000000..840adbd1 --- /dev/null +++ b/src/presentation/components/Code/CodeButtons/ScriptErrorDialog.ts @@ -0,0 +1,205 @@ +import { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { Dialog } from '@/presentation/common/Dialog'; + +export async function createScriptErrorDialog( + information: ScriptErrorDetails, + scriptDiagnosticsCollector: ScriptDiagnosticsCollector | undefined, +): Promise> { + const diagnostics = await scriptDiagnosticsCollector?.collectDiagnosticInformation(); + if (information.isFileReadbackError) { + return createAntivirusErrorDialog(information, diagnostics); + } + return createGenericErrorDialog(information, diagnostics); +} + +export interface ScriptErrorDetails { + readonly errorContext: 'run' | 'save'; + readonly errorType: string; + readonly errorMessage: string; + readonly isFileReadbackError: boolean; +} + +function createGenericErrorDialog( + information: ScriptErrorDetails, + diagnostics: ScriptDiagnosticData | undefined, +): Parameters { + return [ + selectBasedOnErrorContext({ + runningScript: 'Error Running Script', + savingScript: 'Error Saving Script', + }, information), + [ + selectBasedOnErrorContext({ + runningScript: 'An error occurred while running the script.', + savingScript: 'An error occurred while saving the script.', + }, information), + 'This error could be caused by insufficient permissions, limited disk space, or security software interference.', + '\n', + generateUnorderedSolutionList({ + title: 'To address this, you can:', + solutions: [ + 'Check if there is enough disk space and system resources are available.', + selectBasedOnDirectoryPath({ + withoutDirectoryPath: 'Verify your access rights to the script\'s folder.', + withDirectoryPath: (directory) => `Verify your access rights to the script's folder: "${directory}".`, + }, diagnostics), + [ + 'Check if antivirus or security software has mistakenly blocked the script.', + 'Don\'t worry; privacy.sexy is secure, transparent, and open-source, but the scripts might still be mistakenly flagged by antivirus software.', + 'Temporarily disabling the security software may resolve this.', + ].join(' '), + selectBasedOnErrorContext({ + runningScript: 'Confirm that you have the necessary permissions to execute scripts on your system.', + savingScript: 'Try saving the script to a different location.', + }, information), + generateTryDifferentSelectionAdvice(information), + 'If the problem persists, reach out to the community for further assistance.', + ], + }), + '\n', + generateTechnicalDetails(information), + ].join('\n'), + ]; +} + +function createAntivirusErrorDialog( + information: ScriptErrorDetails, + diagnostics: ScriptDiagnosticData | undefined, +): Parameters { + const defenderSteps = generateDefenderSteps(information, diagnostics); + return [ + 'Possible Antivirus Script Block', + [ + [ + 'It seems your antivirus software might have removed the script.', + 'Don\'t worry; privacy.sexy is secure, transparent, and open-source, but the scripts might still be mistakenly flagged by antivirus software.', + ].join(' '), + '\n', + selectBasedOnErrorContext({ + savingScript: generateOrderedSolutionList({ + title: 'To address this, you can:', + solutions: [ + 'Check your antivirus for any blocking notifications and allow the script.', + 'Disable antivirus or security software temporarily or add an exclusion.', + 'Save the script again.', + ], + }), + runningScript: generateOrderedSolutionList({ + title: 'To address this, you can:', + solutions: [ + selectBasedOnDirectoryPath({ + withoutDirectoryPath: 'Disable antivirus or security software temporarily or add an exclusion.', + withDirectoryPath: (directory) => `Disable antivirus or security software temporarily or add a directory exclusion for scripts executed from: "${directory}".`, + }, diagnostics), + 'Run the script again.', + ], + }), + }, information), + defenderSteps ? `\n${defenderSteps}\n` : '\n', + [ + 'It\'s important to re-enable your antivirus protection after resolving the issue for your security.', + 'For more guidance, refer to your antivirus documentation.', + ].join(' '), + '\n', + generateUnorderedSolutionList({ + title: 'If the problem persists:', + solutions: [ + generateTryDifferentSelectionAdvice(information), + 'Consider reporting this as a false positive to your antivirus provider.', + 'Review your antivirus logs for more details.', + 'Reach out to the community for further assistance.', + ], + }), + '\n', + generateTechnicalDetails(information), + ].join('\n'), + ]; +} + +interface SolutionListOptions { + readonly solutions: readonly string[]; + readonly title: string; +} + +function generateUnorderedSolutionList(options: SolutionListOptions) { + return [ + options.title, + ...options.solutions.map((step) => `- ${step}`), + ].join('\n'); +} + +function generateTechnicalDetails(information: ScriptErrorDetails) { + const maxErrorMessageCharacters = 100; + const trimmedErrorMessage = information.errorMessage.length > maxErrorMessageCharacters + ? `${information.errorMessage.substring(0, maxErrorMessageCharacters - 3)}...` + : information.errorMessage; + return `Technical Details: [${information.errorType}] ${trimmedErrorMessage}`; +} + +function generateTryDifferentSelectionAdvice(information: ScriptErrorDetails) { + return selectBasedOnErrorContext({ + runningScript: 'Run a different script selection to check if the problem is script-specific.', + savingScript: 'Save a different script selection to check if the problem is script-specific.', + }, information); +} + +function selectBasedOnDirectoryPath( + options: { + readonly withoutDirectoryPath: T, + withDirectoryPath: (directoryPath: string) => T, + }, + diagnostics: ScriptDiagnosticData | undefined, +): T { + if (!diagnostics?.scriptsDirectoryAbsolutePath) { + return options.withoutDirectoryPath; + } + return options.withDirectoryPath(diagnostics.scriptsDirectoryAbsolutePath); +} + +function generateOrderedSolutionList(options: SolutionListOptions): string { + return [ + options.title, + ...options.solutions.map((step, index) => `${index + 1}. ${step}`), + ].join('\n'); +} + +function generateDefenderSteps( + information: ScriptErrorDetails, + diagnostics: ScriptDiagnosticData | undefined, +): string | undefined { + if (diagnostics?.currentOperatingSystem !== OperatingSystem.Windows) { + return undefined; + } + return generateOrderedSolutionList({ + title: 'To handle false warnings in Defender:', + solutions: [ + 'Open "Virus & threat protection" via the "Start" menu.', + 'Open "Manage settings" under "Virus & threat protection settings" heading.', + ...selectBasedOnErrorContext({ + savingScript: [ + 'Disable "Real-time protection" or add an exclusion by selecting "Add or remove exclusions".', + ], + runningScript: selectBasedOnDirectoryPath({ + withoutDirectoryPath: [ + 'Disable real-time protection or add exclusion for scripts.', + ], + withDirectoryPath: (directory) => [ + 'Open "Add or remove exclusions" under "Add or remove exclusions".', + `Add directory exclusion for "${directory}".`, + ], + }, diagnostics), + }, information), + ], + }); +} + +function selectBasedOnErrorContext(options: { + readonly savingScript: T; + readonly runningScript: T; +}, information: ScriptErrorDetails): T { + if (information.errorContext === 'run') { + return options.runningScript; + } + return options.savingScript; +} diff --git a/src/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.ts b/src/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.ts new file mode 100644 index 00000000..633eba45 --- /dev/null +++ b/src/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.ts @@ -0,0 +1,9 @@ +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; + +export function useScriptDiagnosticsCollector( + window: Partial = globalThis.window, +) { + return { + scriptDiagnosticsCollector: window?.scriptDiagnosticsCollector, + }; +} diff --git a/src/presentation/electron/main/IpcRegistration.ts b/src/presentation/electron/main/IpcRegistration.ts index 6f806b95..dbcf6e00 100644 --- a/src/presentation/electron/main/IpcRegistration.ts +++ b/src/presentation/electron/main/IpcRegistration.ts @@ -3,17 +3,22 @@ import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { Dialog } from '@/presentation/common/Dialog'; import { ElectronDialog } from '@/infrastructure/Dialog/Electron/ElectronDialog'; import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel'; +import { ScriptEnvironmentDiagnosticsCollector } from '@/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector'; +import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import { registerIpcChannel } from '../shared/IpcBridging/IpcProxy'; import { ChannelDefinitionKey, IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions'; export function registerAllIpcChannels( + registrar: IpcChannelRegistrar = registerIpcChannel, createCodeRunner: CodeRunnerFactory = () => new ScriptFileCodeRunner(), createDialog: DialogFactory = () => new ElectronDialog(), - registrar: IpcChannelRegistrar = registerIpcChannel, + createScriptDiagnosticsCollector + : ScriptDiagnosticsCollectorFactory = () => new ScriptEnvironmentDiagnosticsCollector(), ) { const ipcInstanceCreators: IpcChannelRegistrars = { CodeRunner: () => createCodeRunner(), Dialog: () => createDialog(), + ScriptDiagnosticsCollector: () => createScriptDiagnosticsCollector(), }; Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => { try { @@ -26,9 +31,11 @@ export function registerAllIpcChannels( }); } +export type IpcChannelRegistrar = typeof registerIpcChannel; + export type CodeRunnerFactory = () => CodeRunner; export type DialogFactory = () => Dialog; -export type IpcChannelRegistrar = typeof registerIpcChannel; +export type ScriptDiagnosticsCollectorFactory = () => ScriptDiagnosticsCollector; type RegistrationChannel = (typeof IpcChannelDefinitions)[T]; type ExtractChannelServiceType = T extends IpcChannel ? U : never; diff --git a/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts b/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts index 633c7aa8..9dc3cdb1 100644 --- a/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts +++ b/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts @@ -7,10 +7,10 @@ import { IpcChannelDefinitions } from '../../shared/IpcBridging/IpcChannelDefini import { createSecureFacade } from './SecureFacadeCreator'; export function provideWindowVariables( - createLogger: LoggerFactory = () => createElectronLogger(), - convertToOs = convertPlatformToOs, createApiFacade: ApiFacadeFactory = createSecureFacade, ipcConsumerCreator: IpcConsumerProxyCreator = createIpcConsumerProxy, + convertToOs = convertPlatformToOs, + createLogger: LoggerFactory = () => createElectronLogger(), ): WindowVariables { // Enforces mandatory variable availability at compile time const variables: RequiredWindowVariables = { @@ -19,6 +19,9 @@ export function provideWindowVariables( os: convertToOs(process.platform), codeRunner: ipcConsumerCreator(IpcChannelDefinitions.CodeRunner), dialog: ipcConsumerCreator(IpcChannelDefinitions.Dialog), + scriptDiagnosticsCollector: ipcConsumerCreator( + IpcChannelDefinitions.ScriptDiagnosticsCollector, + ), }; return variables; } @@ -26,8 +29,8 @@ export function provideWindowVariables( type RequiredWindowVariables = PartiallyRequired; type PartiallyRequired = Required> & Pick; -export type LoggerFactory = () => Logger; - export type ApiFacadeFactory = typeof createSecureFacade; export type IpcConsumerProxyCreator = typeof createIpcConsumerProxy; + +export type LoggerFactory = () => Logger; diff --git a/src/presentation/electron/shared/IpcBridging/IpcChannelDefinitions.ts b/src/presentation/electron/shared/IpcBridging/IpcChannelDefinitions.ts index 5e11a5a2..93792cd7 100644 --- a/src/presentation/electron/shared/IpcBridging/IpcChannelDefinitions.ts +++ b/src/presentation/electron/shared/IpcBridging/IpcChannelDefinitions.ts @@ -1,11 +1,13 @@ import { FunctionKeys } from '@/TypeHelpers'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { Dialog } from '@/presentation/common/Dialog'; +import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import { IpcChannel } from './IpcChannel'; export const IpcChannelDefinitions = { CodeRunner: defineElectronIpcChannel('code-run', ['runCode']), Dialog: defineElectronIpcChannel

('dialogs', ['showError', 'saveFile']), + ScriptDiagnosticsCollector: defineElectronIpcChannel('script-diagnostics-collector', ['collectDiagnosticInformation']), } as const; export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions; diff --git a/src/presentation/index.html b/src/presentation/index.html index 6023bb5d..ba11cf19 100644 --- a/src/presentation/index.html +++ b/src/presentation/index.html @@ -51,8 +51,11 @@

Problem loading page

-

The page does not work without JavaScript enabled. Please enable it to use privacy.sexy. There's no shady stuff - as 100% of the website is open source.

+

+ The page does not work without JavaScript enabled. + Please enable it to use privacy.sexy. + Don't worry; privacy.sexy is secure, transparent, and open-source. +

diff --git a/src/presentation/injectionSymbols.ts b/src/presentation/injectionSymbols.ts index 2854d664..83da9d57 100644 --- a/src/presentation/injectionSymbols.ts +++ b/src/presentation/injectionSymbols.ts @@ -9,6 +9,7 @@ import type { useUserSelectionState } from '@/presentation/components/Shared/Hoo import type { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger'; import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner'; import type { useDialog } from './components/Shared/Hooks/Dialog/UseDialog'; +import type { useScriptDiagnosticsCollector } from './components/Shared/Hooks/UseScriptDiagnosticsCollector'; export const InjectionKeys = { useCollectionState: defineTransientKey>('useCollectionState'), @@ -21,6 +22,7 @@ export const InjectionKeys = { useLogger: defineTransientKey>('useLogger'), useCodeRunner: defineTransientKey>('useCodeRunner'), useDialog: defineTransientKey>('useDialog'), + useScriptDiagnosticsCollector: defineTransientKey>('useScriptDiagnostics'), }; export interface InjectionKeyWithLifetime { diff --git a/tests/unit/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.spec.ts new file mode 100644 index 00000000..e7aed655 --- /dev/null +++ b/tests/unit/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest'; +import { ContextIsolatedElectronDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector'; +import { ElectronProcessType } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector'; + +describe('ContextIsolatedElectronDetector', () => { + describe('isRunningInsideElectron', () => { + describe('detects Electron environment correctly', () => { + it('returns true on Electron main process', () => { + // arrange + const expectedValue = true; + const process = createProcessStub({ isElectron: true }); + const userAgent = undefined; + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.isRunningInsideElectron(); + // assert + expect(actualValue).to.equal(expectedValue); + }); + it('returns true on Electron preloader process', () => { + // arrange + const expectedValue = true; + const process = createProcessStub({ isElectron: true }); + const userAgent = getElectronUserAgent(); + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.isRunningInsideElectron(); + // assert + expect(actualValue).to.equal(expectedValue); + }); + it('returns true on Electron renderer process', () => { + // arrange + const expectedValue = true; + const process = undefined; + const userAgent = getElectronUserAgent(); + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.isRunningInsideElectron(); + // assert + expect(actualValue).to.equal(expectedValue); + }); + it('returns false on non-Electron environment', () => { + // arrange + const expectedValue = false; + const process = undefined; + const userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'; // non-Electron + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.isRunningInsideElectron(); + // assert + expect(actualValue).to.equal(expectedValue); + }); + }); + describe('determineElectronProcessType', () => { + it('gets Electron process type as main', () => { + // arrange + const expectedProcessType: ElectronProcessType = 'main'; + const process = createProcessStub({ isElectron: true }); + const userAgent = undefined; + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.determineElectronProcessType(); + // assert + expect(actualValue).to.equal(expectedProcessType); + }); + it('gets Electron process type as preloader', () => { + // arrange + const expectedProcessType: ElectronProcessType = 'preloader'; + const process = createProcessStub({ isElectron: true }); + const userAgent = getElectronUserAgent(); + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.determineElectronProcessType(); + // assert + expect(actualValue).to.equal(expectedProcessType); + }); + it('gets Electron process type as renderer', () => { + // arrange + const expectedProcessType: ElectronProcessType = 'renderer'; + const process = undefined; + const userAgent = getElectronUserAgent(); + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const actualValue = detector.determineElectronProcessType(); + // assert + expect(actualValue).to.equal(expectedProcessType); + }); + it('throws non-Electron environment', () => { + // arrange + const expectedError = 'Unable to determine the Electron process type. Neither Node.js nor browser-based Electron contexts were detected.'; + const process = undefined; + const userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'; // non-Electron + const detector = new ContextIsolatedElectronDetectorBuilder() + .withProcess(process) + .withUserAgent(userAgent) + .build(); + // act + const act = () => detector.determineElectronProcessType(); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); +}); + +class ContextIsolatedElectronDetectorBuilder { + private process: NodeJS.Process | undefined; + + private userAgent: string | undefined; + + public withProcess(process: NodeJS.Process | undefined): this { + this.process = process; + return this; + } + + public withUserAgent(userAgent: string | undefined): this { + this.userAgent = userAgent; + return this; + } + + public build(): ContextIsolatedElectronDetector { + return new ContextIsolatedElectronDetector( + () => this.process, + () => this.userAgent, + ); + } +} + +function getElectronUserAgent() { + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.54 Electron/27.0.0 Safari/537.36'; +} + +function createProcessStub(options?: { + readonly isElectron: boolean; +}): NodeJS.Process { + if (options?.isElectron === true) { + return { + versions: { + electron: '28.1.3', + } as NodeJS.ProcessVersions, + } as NodeJS.Process; + } + return {} as NodeJS.Process; +} diff --git a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.spec.ts index 01ec5d3f..1aee15b0 100644 --- a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.spec.ts +++ b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory.spec.ts @@ -1,58 +1,53 @@ import { describe, it, expect } from 'vitest'; import { - BrowserRuntimeEnvironmentFactory, GlobalPropertiesAccessor, NodeRuntimeEnvironmentFactory, + BrowserRuntimeEnvironmentFactory, NodeRuntimeEnvironmentFactory, determineAndCreateRuntimeEnvironment, } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; +import { ElectronEnvironmentDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector'; +import { ElectronEnvironmentDetectorStub } from '@tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub'; describe('RuntimeEnvironmentFactory', () => { describe('determineAndCreateRuntimeEnvironment', () => { describe('Node environment creation', () => { - it('selects Node environment if Electron main process detected', () => { + it('creates Node environment in Electron main process', () => { // arrange - const processStub = createProcessStub({ - versions: { - electron: '28.1.3', - } as NodeJS.ProcessVersions, - }); const expectedEnvironment = new RuntimeEnvironmentStub(); + const mainProcessDetector = new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('main'); const context = new RuntimeEnvironmentFactoryTestSetup() - .withGlobalProcess(processStub) + .withElectronEnvironmentDetector(mainProcessDetector) .withNodeEnvironmentFactory(() => expectedEnvironment); // act const actualEnvironment = context.buildEnvironment(); // assert expect(actualEnvironment).to.equal(expectedEnvironment); }); - it('passes correct process to Node environment factory', () => { + }); + describe('browser environment creation', () => { + it('creates browser environment in Electron renderer process', () => { // arrange - const expectedProcess = createProcessStub({ - versions: { - electron: '28.1.3', - } as NodeJS.ProcessVersions, - }); - let actualProcess: GlobalProcess; - const nodeEnvironmentFactoryMock: NodeRuntimeEnvironmentFactory = (providedProcess) => { - actualProcess = providedProcess; - return new RuntimeEnvironmentStub(); - }; + const expectedEnvironment = new RuntimeEnvironmentStub(); + const rendererProcessDetector = new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('renderer'); + const windowStub = createWindowStub(); const context = new RuntimeEnvironmentFactoryTestSetup() - .withGlobalProcess(expectedProcess) - .withNodeEnvironmentFactory(nodeEnvironmentFactoryMock); + .withElectronEnvironmentDetector(rendererProcessDetector) + .withGlobalWindow(windowStub) + .withBrowserEnvironmentFactory(() => expectedEnvironment); // act - context.buildEnvironment(); + const actualEnvironment = context.buildEnvironment(); // assert - expect(actualProcess).to.equal(expectedProcess); + expect(actualEnvironment).to.equal(expectedEnvironment); }); - }); - describe('browser environment creation', () => { - it('selects browser environment if Electron main process not detected', () => { + it('creates browser environment in Electron preloader process', () => { // arrange const expectedEnvironment = new RuntimeEnvironmentStub(); - const undefinedProcess: GlobalProcess = undefined; + const preloaderProcessDetector = new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('preloader'); const windowStub = createWindowStub(); const context = new RuntimeEnvironmentFactoryTestSetup() - .withGlobalProcess(undefinedProcess) + .withElectronEnvironmentDetector(preloaderProcessDetector) .withGlobalWindow(windowStub) .withBrowserEnvironmentFactory(() => expectedEnvironment); // act @@ -60,21 +55,22 @@ describe('RuntimeEnvironmentFactory', () => { // assert expect(actualEnvironment).to.equal(expectedEnvironment); }); - it('passes correct window to browser environment factory', () => { + it('provides correct window to browser environment factory', () => { // arrange const expectedWindow = createWindowStub({ isRunningAsDesktopApplication: undefined, }); - let actualWindow: GlobalWindow; + let actualWindow: Window | undefined; const browserEnvironmentFactoryMock : BrowserRuntimeEnvironmentFactory = (providedWindow) => { actualWindow = providedWindow; return new RuntimeEnvironmentStub(); }; - const undefinedProcess: GlobalProcess = undefined; + const nonElectronDetector = new ElectronEnvironmentDetectorStub() + .withNonElectronEnvironment(); const context = new RuntimeEnvironmentFactoryTestSetup() .withGlobalWindow(expectedWindow) - .withGlobalProcess(undefinedProcess) + .withElectronEnvironmentDetector(nonElectronDetector) .withBrowserEnvironmentFactory(browserEnvironmentFactoryMock); // act context.buildEnvironment(); @@ -82,14 +78,15 @@ describe('RuntimeEnvironmentFactory', () => { expect(actualWindow).to.equal(expectedWindow); }); }); - it('throws error when both window and process are undefined', () => { + it('throws error without global window in non-Electron environment', () => { // arrange - const undefinedWindow: GlobalWindow = undefined; - const undefinedProcess: GlobalProcess = undefined; const expectedError = 'Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.'; + const nullWindow = null; + const nonElectronDetector = new ElectronEnvironmentDetectorStub() + .withNonElectronEnvironment(); const context = new RuntimeEnvironmentFactoryTestSetup() - .withGlobalProcess(undefinedProcess) - .withGlobalWindow(undefinedWindow); + .withElectronEnvironmentDetector(nonElectronDetector) + .withGlobalWindow(nullWindow); // act const act = () => context.buildEnvironment(); // assert @@ -104,16 +101,11 @@ function createWindowStub(partialWindowProperties?: Partial): Window { } as Window; } -function createProcessStub(partialProcessProperties?: Partial): NodeJS.Process { - return { - ...partialProcessProperties, - } as NodeJS.Process; -} - export class RuntimeEnvironmentFactoryTestSetup { - private globalWindow: GlobalWindow = createWindowStub(); + private globalWindow: Window | undefined | null = createWindowStub(); - private globalProcess: GlobalProcess = createProcessStub(); + private electronEnvironmentDetector + : ElectronEnvironmentDetector = new ElectronEnvironmentDetectorStub(); private browserEnvironmentFactory : BrowserRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub(); @@ -121,13 +113,13 @@ export class RuntimeEnvironmentFactoryTestSetup { private nodeEnvironmentFactory : NodeRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub(); - public withGlobalWindow(globalWindow: GlobalWindow): this { + public withGlobalWindow(globalWindow: Window | undefined | null): this { this.globalWindow = globalWindow; return this; } - public withGlobalProcess(globalProcess: GlobalProcess): this { - this.globalProcess = globalProcess; + public withElectronEnvironmentDetector(detector: ElectronEnvironmentDetector): this { + this.electronEnvironmentDetector = detector; return this; } @@ -147,16 +139,10 @@ export class RuntimeEnvironmentFactoryTestSetup { public buildEnvironment(): ReturnType { return determineAndCreateRuntimeEnvironment( - { - window: this.globalWindow, - process: this.globalProcess, - }, + this.globalWindow, + this.electronEnvironmentDetector, this.browserEnvironmentFactory, this.nodeEnvironmentFactory, ); } } - -type GlobalWindow = GlobalPropertiesAccessor['window']; - -type GlobalProcess = GlobalPropertiesAccessor['process']; diff --git a/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts index 30c9587b..7a3cfedf 100644 --- a/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts +++ b/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts @@ -2,222 +2,238 @@ import { describe, it, expect } from 'vitest'; import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { getAbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub'; import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub'; import { PropertyKeys } from '@/TypeHelpers'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub'; +import { ScriptDiagnosticsCollectorStub } from '@tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub'; +import { ElectronEnvironmentDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector'; +import { ElectronEnvironmentDetectorStub } from '@tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub'; describe('WindowVariablesValidator', () => { describe('validateWindowVariables', () => { - describe('validates window type', () => { - itEachInvalidObjectValue((invalidObjectValue) => { - // arrange - const expectedError = 'window is not an object'; - const window: Partial = invalidObjectValue as never; - // act - const act = () => validateWindowVariables(window); - // assert - expect(act).to.throw(expectedError); - }); + it('throws an error with a description of all invalid properties', () => { + // arrange + const invalidOs = 'invalid' as unknown as OperatingSystem; + const invalidIsRunningAsDesktopApplication = 'not a boolean' as never; + const expectedError = getExpectedError( + { + name: 'os', + value: invalidOs, + }, + { + name: 'isRunningAsDesktopApplication', + value: invalidIsRunningAsDesktopApplication, + }, + ); + const input = new WindowVariablesStub() + .withOs(invalidOs) + .withIsRunningAsDesktopApplication(invalidIsRunningAsDesktopApplication); + const context = new ValidateWindowVariablesTestSetup() + .withWindowVariables(input); + // act + const act = () => context.validateWindowVariables(); + // assert + expect(act).to.throw(expectedError); }); - - describe('property validations', () => { - it('throws an error with a description of all invalid properties', () => { - // arrange - const invalidOs = 'invalid' as unknown as OperatingSystem; - const invalidIsRunningAsDesktopApplication = 'not a boolean' as never; - const expectedError = getExpectedError( - { - name: 'os', - object: invalidOs, - }, - { - name: 'isRunningAsDesktopApplication', - object: invalidIsRunningAsDesktopApplication, - }, - ); - const input = new WindowVariablesStub() - .withOs(invalidOs) - .withIsRunningAsDesktopApplication(invalidIsRunningAsDesktopApplication); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.throw(expectedError); - }); - - describe('`os` property', () => { - it('throws an error when os is not a number', () => { - // arrange - const invalidOs = 'Linux' as unknown as OperatingSystem; - const expectedError = getExpectedError( - { - name: 'os', - object: invalidOs, - }, - ); - const input = new WindowVariablesStub() - .withOs(invalidOs); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.throw(expectedError); - }); - - it('throws an error for an invalid numeric os value', () => { + describe('when not in Electron renderer process', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly environment: ElectronEnvironmentDetector; + }> = [ + { + description: 'skips in non-Electron environments', + environment: new ElectronEnvironmentDetectorStub() + .withNonElectronEnvironment(), + }, + { + description: 'skips in Electron main process', + environment: new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('main'), + }, + { + description: 'skips in Electron preloader process', + environment: new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('preloader'), + }, + ]; + testScenarios.forEach(({ description, environment }) => { + it(description, () => { // arrange - const invalidOs = Number.MAX_SAFE_INTEGER; - const expectedError = getExpectedError( - { - name: 'os', - object: invalidOs, - }, - ); + const invalidOs = 'invalid' as unknown as OperatingSystem; const input = new WindowVariablesStub() .withOs(invalidOs); + const context = new ValidateWindowVariablesTestSetup() + .withElectronDetector(environment) + .withWindowVariables(input); // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.throw(expectedError); - }); - - it('does not throw for a missing os value', () => { - // arrange - const input = new WindowVariablesStub() - .withIsRunningAsDesktopApplication(true) - .withOs(undefined); - // act - const act = () => validateWindowVariables(input); + const act = () => context.validateWindowVariables(); // assert expect(act).to.not.throw(); }); }); + }); - describe('`isRunningAsDesktopApplication` property', () => { - it('does not throw when true with valid services', () => { - // arrange - const windowVariables = new WindowVariablesStub(); - const windowVariableConfigurators: Record< // Ensure types match for compile-time checking - PropertyKeys>, - (stub: WindowVariablesStub) => WindowVariablesStub> = { - isRunningAsDesktopApplication: (s) => s.withIsRunningAsDesktopApplication(true), - codeRunner: (s) => s.withCodeRunner(new CodeRunnerStub()), - os: (s) => s.withOs(OperatingSystem.Windows), - log: (s) => s.withLog(new LoggerStub()), - dialog: (s) => s.withDialog(new DialogStub()), - }; - Object - .values(windowVariableConfigurators) - .forEach((configure) => configure(windowVariables)); - // act - const act = () => validateWindowVariables(windowVariables); - // assert - expect(act).to.not.throw(); - }); - - describe('does not throw when false without services', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const absentCodeRunner = absentValue; - const input = new WindowVariablesStub() - .withIsRunningAsDesktopApplication(undefined) - .withCodeRunner(absentCodeRunner); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.not.throw(); - }, { excludeNull: true }); + describe('does not throw when a property is valid', () => { + const testScenarios: Record>, ReadonlyArray<{ + readonly description: string; + readonly validValue: unknown; + }>> = { + isRunningAsDesktopApplication: [{ + description: 'accepts boolean true', + validValue: true, + }], + os: [ + { + description: 'accepts undefined', + validValue: undefined, + }, + { + description: 'accepts valid enum value', + validValue: OperatingSystem.WindowsPhone, + }, + ], + codeRunner: [{ + description: 'accepts an object', + validValue: new CodeRunnerStub(), + }], + log: [{ + description: 'accepts an object', + validValue: new LoggerStub(), + }], + dialog: [{ + description: 'accepts an object', + validValue: new DialogStub(), + }], + scriptDiagnosticsCollector: [{ + description: 'accepts an object', + validValue: new ScriptDiagnosticsCollectorStub(), + }], + }; + Object.entries(testScenarios).forEach(([propertyKey, validValueScenarios]) => { + describe(propertyKey, () => { + validValueScenarios.forEach(({ description, validValue }) => { + it(description, () => { + // arrange + const input = new WindowVariablesStub(); + input[propertyKey] = validValue; + const context = new ValidateWindowVariablesTestSetup() + .withWindowVariables(input); + // act + const act = () => context.validateWindowVariables(); + // assert + expect(act).to.not.throw(); + }); + }); }); }); - - describe('`codeRunner` property', () => { - expectObjectOnDesktop('codeRunner'); - }); - - describe('`log` property', () => { - expectObjectOnDesktop('log'); - }); }); - it('does not throw for a valid object', () => { - const input = new WindowVariablesStub(); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.not.throw(); + describe('throws an error when a property is invalid', () => { + interface InvalidValueTestCase { + readonly description: string; + readonly invalidValue: unknown; + } + const testScenarios: Record< + PropertyKeys>, + ReadonlyArray> = { + isRunningAsDesktopApplication: [ + { + description: 'rejects false', + invalidValue: false, + }, + { + description: 'rejects undefined', + invalidValue: undefined, + }, + ], + os: [ + { + description: 'rejects non-numeric', + invalidValue: 'Linux', + }, + { + description: 'rejects out-of-range', + invalidValue: Number.MAX_SAFE_INTEGER, + }, + ], + codeRunner: getInvalidObjectValueTestCases(), + log: getInvalidObjectValueTestCases(), + dialog: getInvalidObjectValueTestCases(), + scriptDiagnosticsCollector: getInvalidObjectValueTestCases(), + }; + Object.entries(testScenarios).forEach(([propertyKey, validValueScenarios]) => { + describe(propertyKey, () => { + validValueScenarios.forEach(({ description, invalidValue }) => { + it(description, () => { + // arrange + const expectedErrorMessage = getExpectedError({ + name: propertyKey as keyof WindowVariables, + value: invalidValue, + }); + const input = new WindowVariablesStub(); + input[propertyKey] = invalidValue; + const context = new ValidateWindowVariablesTestSetup() + .withWindowVariables(input); + // act + const act = () => context.validateWindowVariables(); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + }); + }); + }); + function getInvalidObjectValueTestCases(): InvalidValueTestCase[] { + return [ + { + description: 'rejects string', + invalidValue: 'invalid object', + }, + { + description: 'rejects array of objects', + invalidValue: [{}, {}], + }, + ...getAbsentObjectTestCases().map((testCase) => ({ + description: `rejects absent: ${testCase.valueName}`, + invalidValue: testCase.absentValue, + })), + ]; + } }); }); }); -function expectObjectOnDesktop(key: keyof WindowVariables) { - describe('validates object type on desktop', () => { - itEachInvalidObjectValue((invalidObjectValue) => { - // arrange - const isOnDesktop = true; - const invalidObject = invalidObjectValue as T; - const expectedError = getExpectedError({ - name: key, - object: invalidObject, - }); - const input: WindowVariables = { - ...new WindowVariablesStub(), - isRunningAsDesktopApplication: isOnDesktop, - [key]: invalidObject, - }; - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.throw(expectedError); - }); - }); - describe('does not validate object type when not on desktop', () => { - itEachInvalidObjectValue((invalidObjectValue) => { - // arrange - const invalidObject = invalidObjectValue as T; - const input: WindowVariables = { - ...new WindowVariablesStub(), - isRunningAsDesktopApplication: undefined, - [key]: invalidObject, - }; - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.not.throw(); - }); - }); -} +class ValidateWindowVariablesTestSetup { + private electronDetector: ElectronEnvironmentDetector = new ElectronEnvironmentDetectorStub() + .withElectronEnvironment('renderer'); -function itEachInvalidObjectValue(runner: (invalidObjectValue: T) => void) { - const testCases: Array<{ - readonly name: string; - readonly value: T; - }> = [ - { - name: 'given string', - value: 'invalid object' as unknown as T, - }, - { - name: 'given array of objects', - value: [{}, {}] as unknown as T, - }, - ...getAbsentObjectTestCases().map((testCase) => ({ - name: `given absent: ${testCase.valueName}`, - value: testCase.absentValue as unknown as T, - })), - ]; - testCases.forEach((testCase) => { - it(testCase.name, () => { - runner(testCase.value); - }); - }); + private windowVariables: WindowVariables = new WindowVariablesStub(); + + public withWindowVariables(windowVariables: WindowVariables): this { + this.windowVariables = windowVariables; + return this; + } + + public withElectronDetector(electronDetector: ElectronEnvironmentDetector): this { + this.electronDetector = electronDetector; + return this; + } + + public validateWindowVariables() { + return validateWindowVariables( + this.windowVariables, + this.electronDetector, + ); + } } function getExpectedError(...unexpectedObjects: Array<{ readonly name: keyof WindowVariables; - readonly object: unknown; + readonly value: unknown; }>) { const errors = unexpectedObjects - .map(({ name, object }) => `Unexpected ${name} (${typeof object})`); + .map(({ name, value: object }) => `Unexpected ${name} (${typeof object})`); return errors.join('\n'); } diff --git a/tests/unit/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.spec.ts b/tests/unit/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.spec.ts new file mode 100644 index 00000000..d26f94c4 --- /dev/null +++ b/tests/unit/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { ScriptDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/ScriptDirectoryProvider'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { ScriptEnvironmentDiagnosticsCollector } from '@/infrastructure/ScriptDiagnostics/ScriptEnvironmentDiagnosticsCollector'; +import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; +import { ScriptDirectoryProviderStub } from '@tests/unit/shared/Stubs/ScriptDirectoryProviderStub'; +import { OperatingSystem } from '@/domain/OperatingSystem'; + +describe('ScriptEnvironmentDiagnosticsCollector', () => { + it('collects operating system path correctly', async () => { + // arrange + const expectedOperatingSystem = OperatingSystem.KaiOS; + const environment = new RuntimeEnvironmentStub() + .withOs(expectedOperatingSystem); + const collector = new CollectorBuilder() + .withEnvironment(environment) + .build(); + + // act + const diagnosticData = await collector.collectDiagnosticInformation(); + + // assert + const actualOperatingSystem = diagnosticData.currentOperatingSystem; + expect(actualOperatingSystem).to.equal(expectedOperatingSystem); + }); + it('collects path correctly', async () => { + // arrange + const expectedScriptsPath = '/expected/scripts/path'; + const directoryProvider = new ScriptDirectoryProviderStub() + .withDirectoryPath(expectedScriptsPath); + const collector = new CollectorBuilder() + .withScriptDirectoryProvider(directoryProvider) + .build(); + + // act + const diagnosticData = await collector.collectDiagnosticInformation(); + + // assert + const actualScriptsPath = diagnosticData.scriptsDirectoryAbsolutePath; + expect(actualScriptsPath).to.equal(expectedScriptsPath); + }); +}); + +class CollectorBuilder { + private directoryProvider: ScriptDirectoryProvider = new ScriptDirectoryProviderStub(); + + private environment: RuntimeEnvironment = new RuntimeEnvironmentStub(); + + public withEnvironment(environment: RuntimeEnvironment): this { + this.environment = environment; + return this; + } + + public withScriptDirectoryProvider(directoryProvider: ScriptDirectoryProvider): this { + this.directoryProvider = directoryProvider; + return this; + } + + public build() { + return new ScriptEnvironmentDiagnosticsCollector( + this.directoryProvider, + this.environment, + ); + } +} diff --git a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts index 090bcb8c..84ab11f5 100644 --- a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts +++ b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts @@ -20,6 +20,7 @@ describe('DependencyProvider', () => { useLogger: createTransientTests(), useCodeRunner: createTransientTests(), useDialog: createTransientTests(), + useScriptDiagnosticsCollector: createTransientTests(), }; Object.entries(testCases).forEach(([key, runTests]) => { const registeredKey = InjectionKeys[key].key; diff --git a/tests/unit/presentation/components/Code/CodeButtons/ScriptErrorDialog.spec.ts b/tests/unit/presentation/components/Code/CodeButtons/ScriptErrorDialog.spec.ts new file mode 100644 index 00000000..337cc986 --- /dev/null +++ b/tests/unit/presentation/components/Code/CodeButtons/ScriptErrorDialog.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { Dialog } from '@/presentation/common/Dialog'; +import { ScriptErrorDetails, createScriptErrorDialog } from '@/presentation/components/Code/CodeButtons/ScriptErrorDialog'; +import { expectExists } from '@tests/shared/Assertions/ExpectExists'; +import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems'; +import { ScriptDiagnosticsCollectorStub } from '@tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub'; + +describe('ScriptErrorDialog', () => { + describe('handles readback error type', () => { + it('handles file readback error', async () => { + // arrange + const errorDetails = createErrorDetails({ isFileReadbackError: true }); + const context = new CreateScriptErrorDialogTestSetup() + .withDetails(errorDetails); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + it('handles generic error', async () => { + // arrange + const errorDetails = createErrorDetails({ isFileReadbackError: false }); + const context = new CreateScriptErrorDialogTestSetup() + .withDetails(errorDetails); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + }); + + describe('handles supported operatingSystems', () => { + AllSupportedOperatingSystems.forEach((operatingSystem) => { + it(`${OperatingSystem[operatingSystem]}`, async () => { + // arrange + const diagnostics = new ScriptDiagnosticsCollectorStub() + .withOperatingSystem(operatingSystem); + const context = new CreateScriptErrorDialogTestSetup() + .withDiagnostics(diagnostics); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + }); + }); + + it('handles undefined diagnostics collector', async () => { + const diagnostics = undefined; + const context = new CreateScriptErrorDialogTestSetup() + .withDiagnostics(diagnostics); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + + it('handles undefined operating system', async () => { + // arrange + const undefinedOperatingSystem = undefined; + const diagnostics = new ScriptDiagnosticsCollectorStub() + .withOperatingSystem(undefinedOperatingSystem); + const context = new CreateScriptErrorDialogTestSetup() + .withDiagnostics(diagnostics); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + + it('handles directory path', async () => { + // arrange + const undefinedScriptsDirectory = undefined; + const diagnostics = new ScriptDiagnosticsCollectorStub() + .withScriptDirectoryPath(undefinedScriptsDirectory); + const context = new CreateScriptErrorDialogTestSetup() + .withDiagnostics(diagnostics); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + + describe('handles all contexts', () => { + const possibleContexts: ScriptErrorDetails['errorContext'][] = ['run', 'save']; + possibleContexts.forEach((dialogContext) => { + it(`${dialogContext} context`, async () => { + // arrange + const undefinedScriptsDirectory = undefined; + const diagnostics = new ScriptDiagnosticsCollectorStub() + .withScriptDirectoryPath(undefinedScriptsDirectory); + const context = new CreateScriptErrorDialogTestSetup() + .withDiagnostics(diagnostics); + // act + const dialog = await context.createScriptErrorDialog(); + // assert + assertValidDialog(dialog); + }); + }); + }); +}); + +function assertValidDialog(dialog: Parameters): void { + expectExists(dialog); + const [title, message] = dialog; + expectExists(title); + expect(title).to.have.length.greaterThan(1); + expectExists(message); + expect(message).to.have.length.greaterThan(1); +} + +function createErrorDetails(partialDetails?: Partial): ScriptErrorDetails { + const defaultDetails: ScriptErrorDetails = { + errorContext: 'run', + errorType: 'test-error-type', + errorMessage: 'test error message', + isFileReadbackError: false, + }; + return { + ...defaultDetails, + ...partialDetails, + }; +} + +class CreateScriptErrorDialogTestSetup { + private details: ScriptErrorDetails = createErrorDetails(); + + private diagnostics: + ScriptDiagnosticsCollector | undefined = new ScriptDiagnosticsCollectorStub(); + + public withDetails(details: ScriptErrorDetails): this { + this.details = details; + return this; + } + + public withDiagnostics(diagnostics: ScriptDiagnosticsCollector | undefined): this { + this.diagnostics = diagnostics; + return this; + } + + public createScriptErrorDialog() { + return createScriptErrorDialog( + this.details, + this.diagnostics, + ); + } +} diff --git a/tests/unit/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.spec.ts new file mode 100644 index 00000000..50bb4b95 --- /dev/null +++ b/tests/unit/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { useScriptDiagnosticsCollector } from '@/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector'; +import { ScriptDiagnosticsCollectorStub } from '@tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub'; +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; + +describe('useScriptDiagnosticsCollector', () => { + it('returns undefined if collector is not present on the window object', () => { + // arrange + const emptyWindow = {} as WindowVariables; + // act + const { scriptDiagnosticsCollector } = useScriptDiagnosticsCollector(emptyWindow); + // assert + expect(scriptDiagnosticsCollector).to.equal(undefined); + }); + + it('returns the scriptDiagnosticsCollector when it is present on the window object', () => { + // arrange + const expectedCollector = new ScriptDiagnosticsCollectorStub(); + const windowWithVariable: Partial = { + scriptDiagnosticsCollector: expectedCollector, + } as Partial; + // act + const { scriptDiagnosticsCollector } = useScriptDiagnosticsCollector(windowWithVariable); + // assert + expect(scriptDiagnosticsCollector).to.equal(expectedCollector); + }); +}); diff --git a/tests/unit/presentation/electron/main/IpcRegistration.spec.ts b/tests/unit/presentation/electron/main/IpcRegistration.spec.ts index 2dc16cfb..3b0c783a 100644 --- a/tests/unit/presentation/electron/main/IpcRegistration.spec.ts +++ b/tests/unit/presentation/electron/main/IpcRegistration.spec.ts @@ -2,12 +2,14 @@ import { describe, it, expect } from 'vitest'; import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub'; import { ChannelDefinitionKey, IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions'; import { - CodeRunnerFactory, DialogFactory, IpcChannelRegistrar, registerAllIpcChannels, + CodeRunnerFactory, DialogFactory, IpcChannelRegistrar, + ScriptDiagnosticsCollectorFactory, registerAllIpcChannels, } from '@/presentation/electron/main/IpcRegistration'; import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub'; +import { ScriptDiagnosticsCollectorStub } from '../../../shared/Stubs/ScriptDiagnosticsCollectorStub'; describe('IpcRegistration', () => { describe('registerAllIpcChannels', () => { @@ -44,6 +46,13 @@ describe('IpcRegistration', () => { expectedInstance, }; })(), + ScriptDiagnosticsCollector: (() => { + const expectedInstance = new ScriptDiagnosticsCollectorStub(); + return { + buildContext: (c) => c.witScriptDiagnosticsCollectorFactory(() => expectedInstance), + expectedInstance, + }; + })(), }; Object.entries(testScenarios).forEach(([ key, { buildContext, expectedInstance }, @@ -79,11 +88,14 @@ describe('IpcRegistration', () => { }); class IpcRegistrationTestSetup { + private registrar: IpcChannelRegistrar = () => { /* NOOP */ }; + private codeRunnerFactory: CodeRunnerFactory = () => new CodeRunnerStub(); private dialogFactory: DialogFactory = () => new DialogStub(); - private registrar: IpcChannelRegistrar = () => { /* NOOP */ }; + private scriptDiagnosticsCollectorFactory + : ScriptDiagnosticsCollectorFactory = () => new ScriptDiagnosticsCollectorStub(); public withRegistrar(registrar: IpcChannelRegistrar): this { this.registrar = registrar; @@ -100,11 +112,19 @@ class IpcRegistrationTestSetup { return this; } + public witScriptDiagnosticsCollectorFactory( + scriptDiagnosticsCollectorFactory: ScriptDiagnosticsCollectorFactory, + ): this { + this.scriptDiagnosticsCollectorFactory = scriptDiagnosticsCollectorFactory; + return this; + } + public run() { registerAllIpcChannels( + this.registrar, this.codeRunnerFactory, this.dialogFactory, - this.registrar, + this.scriptDiagnosticsCollectorFactory, ); } } diff --git a/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts index d3ea04b8..58e9c44b 100644 --- a/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts +++ b/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts @@ -15,7 +15,9 @@ describe('RendererApiProvider', () => { setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext; readonly expectedValue: unknown; } - const testScenarios: Record>, WindowVariableTestCase> = { + const testScenarios: Record< + PropertyKeys>, + WindowVariableTestCase> = { isRunningAsDesktopApplication: { description: 'returns true', setupContext: (context) => context, @@ -32,9 +34,12 @@ describe('RendererApiProvider', () => { })(), log: expectFacade({ instance: new LoggerStub(), - setupContext: (c, logger) => c.withLogger(logger), + setupContext: (c, instance) => c.withLogger(instance), }), dialog: expectIpcConsumer(IpcChannelDefinitions.Dialog), + scriptDiagnosticsCollector: expectIpcConsumer( + IpcChannelDefinitions.ScriptDiagnosticsCollector, + ), }; Object.entries(testScenarios).forEach(( [property, { description, setupContext, expectedValue }], @@ -109,10 +114,10 @@ class RendererApiProviderTestContext { public provideWindowVariables() { return provideWindowVariables( - () => this.log, - () => this.os, this.apiFacadeCreator, this.ipcConsumerCreator, + () => this.os, + () => this.log, ); } } diff --git a/tests/unit/presentation/electron/shared/IpcChannelDefinitions.spec.ts b/tests/unit/presentation/electron/shared/IpcChannelDefinitions.spec.ts index 31da0fbe..04dccb61 100644 --- a/tests/unit/presentation/electron/shared/IpcChannelDefinitions.spec.ts +++ b/tests/unit/presentation/electron/shared/IpcChannelDefinitions.spec.ts @@ -16,6 +16,10 @@ describe('IpcChannelDefinitions', () => { expectedNamespace: 'dialogs', expectedAccessibleMembers: ['saveFile'], }, + ScriptDiagnosticsCollector: { + expectedNamespace: 'script-diagnostics-collector', + expectedAccessibleMembers: ['collectDiagnosticInformation'], + }, }; Object.entries(testScenarios).forEach(( [definitionKey, { expectedNamespace, expectedAccessibleMembers }], diff --git a/tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub.ts b/tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub.ts new file mode 100644 index 00000000..e7faef7c --- /dev/null +++ b/tests/unit/shared/Stubs/ElectronEnvironmentDetectorStub.ts @@ -0,0 +1,26 @@ +import { ElectronEnvironmentDetector, ElectronProcessType } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector'; + +export class ElectronEnvironmentDetectorStub implements ElectronEnvironmentDetector { + private isInsideElectron = true; + + public process: ElectronProcessType = 'renderer'; + + public isRunningInsideElectron(): boolean { + return this.isInsideElectron; + } + + public determineElectronProcessType(): ElectronProcessType { + return this.process; + } + + public withNonElectronEnvironment(): this { + this.isInsideElectron = false; + return this; + } + + public withElectronEnvironment(process: ElectronProcessType): this { + this.isInsideElectron = true; + this.process = process; + return this; + } +} diff --git a/tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub.ts b/tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub.ts new file mode 100644 index 00000000..a2e4b969 --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptDiagnosticsCollectorStub.ts @@ -0,0 +1,25 @@ +import { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export class ScriptDiagnosticsCollectorStub implements ScriptDiagnosticsCollector { + private operatingSystem: OperatingSystem | undefined = OperatingSystem.Windows; + + private scriptDirectoryPath: string | undefined = '/test/scripts/directory/path'; + + public withOperatingSystem(operatingSystem: OperatingSystem | undefined): this { + this.operatingSystem = operatingSystem; + return this; + } + + public withScriptDirectoryPath(scriptDirectoryPath: string | undefined): this { + this.scriptDirectoryPath = scriptDirectoryPath; + return this; + } + + public collectDiagnosticInformation(): Promise { + return Promise.resolve({ + scriptsDirectoryAbsolutePath: this.scriptDirectoryPath, + currentOperatingSystem: this.operatingSystem, + }); + } +} diff --git a/tests/unit/shared/Stubs/WindowVariablesStub.ts b/tests/unit/shared/Stubs/WindowVariablesStub.ts index 68a8e69c..f770013d 100644 --- a/tests/unit/shared/Stubs/WindowVariablesStub.ts +++ b/tests/unit/shared/Stubs/WindowVariablesStub.ts @@ -3,9 +3,11 @@ import { Logger } from '@/application/Common/Log/Logger'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { Dialog } from '@/presentation/common/Dialog'; +import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import { LoggerStub } from './LoggerStub'; import { CodeRunnerStub } from './CodeRunnerStub'; import { DialogStub } from './DialogStub'; +import { ScriptDiagnosticsCollectorStub } from './ScriptDiagnosticsCollectorStub'; export class WindowVariablesStub implements WindowVariables { public codeRunner?: CodeRunner = new CodeRunnerStub(); @@ -18,6 +20,16 @@ export class WindowVariablesStub implements WindowVariables { public dialog?: Dialog = new DialogStub(); + public scriptDiagnosticsCollector? + : ScriptDiagnosticsCollector = new ScriptDiagnosticsCollectorStub(); + + public withScriptDiagnosticsCollector( + scriptDiagnosticsCollector: ScriptDiagnosticsCollector, + ): this { + this.scriptDiagnosticsCollector = scriptDiagnosticsCollector; + return this; + } + public withLog(log: Logger): this { this.log = log; return this;