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.
This commit is contained in:
undergroundwires
2024-01-17 23:59:05 +01:00
parent f03fc24098
commit 6ada8d425c
34 changed files with 1182 additions and 450 deletions

View File

@@ -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 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 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. 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 ### Update Security and Integrity

View File

@@ -10,7 +10,8 @@ This table highlights differences between the desktop and web versions of `priva
| [Logging](#logging) | 🟢 Available | 🔴 Not available | | [Logging](#logging) | 🟢 Available | 🔴 Not available |
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available | | [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
| [Error handling](#error-handling) | 🟢 Advanced | 🟡 Limited | | [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 ## 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. 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. 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.

View File

@@ -0,0 +1,10 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface ScriptDiagnosticsCollector {
collectDiagnosticInformation(): Promise<ScriptDiagnosticData>;
}
export interface ScriptDiagnosticData {
readonly scriptsDirectoryAbsolutePath?: string;
readonly currentOperatingSystem?: OperatingSystem;
}

View File

@@ -27,11 +27,6 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
const fileWritePipelineActions: ReadonlyArray<() => Promise<FileWriteOutcome>> = [ const fileWritePipelineActions: ReadonlyArray<() => Promise<FileWriteOutcome>> = [
() => this.createOrOverwriteFile(filePath, fileContents), () => this.createOrOverwriteFile(filePath, fileContents),
() => this.verifyFileExistsWithoutReading(filePath), () => 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), () => this.verifyFileContentsByReading(filePath, fileContents),
]; ];
for (const action of fileWritePipelineActions) { for (const action of fileWritePipelineActions) {

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
export interface ElectronEnvironmentDetector {
isRunningInsideElectron(): boolean;
determineElectronProcessType(): ElectronProcessType;
}
export type ElectronProcessType = 'main' | 'preloader' | 'renderer';

View File

@@ -1,49 +1,32 @@
import { ElectronEnvironmentDetector } from './Electron/ElectronEnvironmentDetector';
import { BrowserRuntimeEnvironment } from './Browser/BrowserRuntimeEnvironment'; import { BrowserRuntimeEnvironment } from './Browser/BrowserRuntimeEnvironment';
import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment'; import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment';
import { RuntimeEnvironment } from './RuntimeEnvironment'; import { RuntimeEnvironment } from './RuntimeEnvironment';
import { ContextIsolatedElectronDetector } from './Electron/ContextIsolatedElectronDetector';
export const CurrentEnvironment = determineAndCreateRuntimeEnvironment({ export const CurrentEnvironment = determineAndCreateRuntimeEnvironment(globalThis.window);
window: globalThis.window,
process: globalThis.process,
});
export function determineAndCreateRuntimeEnvironment( export function determineAndCreateRuntimeEnvironment(
globalAccessor: GlobalPropertiesAccessor, globalWindow: Window | undefined | null = globalThis.window,
electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(),
browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = ( browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = (
window, window,
) => new BrowserRuntimeEnvironment(window), ) => new BrowserRuntimeEnvironment(window),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = ( nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = () => new NodeRuntimeEnvironment(),
process: NodeJS.Process,
) => new NodeRuntimeEnvironment(process),
): RuntimeEnvironment { ): RuntimeEnvironment {
if (isElectronMainProcess(globalAccessor.process)) { if (
return nodeEnvironmentFactory(globalAccessor.process); electronDetector.isRunningInsideElectron()
&& electronDetector.determineElectronProcessType() === 'main') {
return nodeEnvironmentFactory();
} }
const { window } = globalAccessor; if (!globalWindow) {
if (!window) {
throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.'); throw new Error('Unsupported runtime environment: The current context is neither a recognized browser nor a desktop environment.');
} }
return browserEnvironmentFactory(window); return browserEnvironmentFactory(globalWindow);
}
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;
} }
export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment; export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = (process: NodeJS.Process) => NodeRuntimeEnvironment; export type NodeRuntimeEnvironmentFactory = () => NodeRuntimeEnvironment;
export interface GlobalPropertiesAccessor { export type GlobalWindowAccessor = Window | undefined;
readonly window: Window | undefined;
readonly process: NodeJS.Process | undefined;
}

View File

@@ -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<ScriptDiagnosticData> {
const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory();
return {
scriptsDirectoryAbsolutePath: directoryAbsolutePath,
currentOperatingSystem: this.environment.os,
};
}
}

View File

@@ -2,6 +2,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { Logger } from '@/application/Common/Log/Logger'; import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog'; import { Dialog } from '@/presentation/common/Dialog';
import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
/* Primary entry point for platform-specific injections */ /* Primary entry point for platform-specific injections */
export interface WindowVariables { export interface WindowVariables {
@@ -10,4 +11,5 @@ export interface WindowVariables {
readonly os?: OperatingSystem; readonly os?: OperatingSystem;
readonly log?: Logger; readonly log?: Logger;
readonly dialog?: Dialog; readonly dialog?: Dialog;
readonly scriptDiagnosticsCollector?: ScriptDiagnosticsCollector;
} }

View File

@@ -1,3 +1,5 @@
import { ContextIsolatedElectronDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ContextIsolatedElectronDetector';
import { ElectronEnvironmentDetector } from '@/infrastructure/RuntimeEnvironment/Electron/ElectronEnvironmentDetector';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { import {
PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject, PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject,
@@ -7,7 +9,14 @@ import { WindowVariables } from './WindowVariables';
/** /**
* Checks for consistency in runtime environment properties injected by Electron preloader. * Checks for consistency in runtime environment properties injected by Electron preloader.
*/ */
export function validateWindowVariables(variables: Partial<WindowVariables>) { export function validateWindowVariables(
variables: Partial<WindowVariables>,
electronDetector: ElectronEnvironmentDetector = new ContextIsolatedElectronDetector(),
) {
if (!electronDetector.isRunningInsideElectron()
|| electronDetector.determineElectronProcessType() !== 'renderer') {
return;
}
if (!isPlainObject(variables)) { if (!isPlainObject(variables)) {
throw new Error('window is not an object'); throw new Error('window is not an object');
} }
@@ -20,12 +29,11 @@ export function validateWindowVariables(variables: Partial<WindowVariables>) {
function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> { function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> {
const tests: Record<PropertyKeys<Required<WindowVariables>>, boolean> = { const tests: Record<PropertyKeys<Required<WindowVariables>>, boolean> = {
os: testOperatingSystem(variables.os), os: testOperatingSystem(variables.os),
isRunningAsDesktopApplication: testIsRunningAsDesktopApplication( isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(variables),
variables.isRunningAsDesktopApplication,
),
codeRunner: testCodeRunner(variables), codeRunner: testCodeRunner(variables),
log: testLogger(variables), log: testLogger(variables),
dialog: testDialog(variables), dialog: testDialog(variables),
scriptDiagnosticsCollector: testScriptDiagnosticsCollector(variables),
}; };
for (const [propertyName, testResult] of Object.entries(tests)) { for (const [propertyName, testResult] of Object.entries(tests)) {
@@ -49,30 +57,30 @@ function testOperatingSystem(os: unknown): boolean {
} }
function testLogger(variables: Partial<WindowVariables>): boolean { function testLogger(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) { return isPlainObject(variables.log)
return true; && isFunction(variables.log.debug)
} && isFunction(variables.log.info)
return isPlainObject(variables.log); && isFunction(variables.log.error)
&& isFunction(variables.log.warn);
} }
function testCodeRunner(variables: Partial<WindowVariables>): boolean { function testCodeRunner(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.codeRunner) return isPlainObject(variables.codeRunner)
&& isFunction(variables.codeRunner.runCode); && isFunction(variables.codeRunner.runCode);
} }
function testIsRunningAsDesktopApplication(isRunningAsDesktopApplication: unknown): boolean { function testIsRunningAsDesktopApplication(variables: Partial<WindowVariables>): boolean {
if (isRunningAsDesktopApplication === undefined) { return isBoolean(variables.isRunningAsDesktopApplication)
return true; && variables.isRunningAsDesktopApplication === true;
}
return isBoolean(isRunningAsDesktopApplication);
} }
function testDialog(variables: Partial<WindowVariables>): boolean { function testDialog(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) { return isPlainObject(variables.dialog)
return true; && isFunction(variables.dialog.saveFile)
&& isFunction(variables.dialog.showError);
} }
return isPlainObject(variables.dialog);
function testScriptDiagnosticsCollector(variables: Partial<WindowVariables>): boolean {
return isPlainObject(variables.scriptDiagnosticsCollector)
&& isFunction(variables.scriptDiagnosticsCollector.collectDiagnosticInformation);
} }

View File

@@ -15,6 +15,7 @@ import { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger'
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner'; import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { useDialog } from '@/presentation/components/Shared/Hooks/Dialog/UseDialog'; import { useDialog } from '@/presentation/components/Shared/Hooks/Dialog/UseDialog';
import { useScriptDiagnosticsCollector } from '@/presentation/components/Shared/Hooks/UseScriptDiagnosticsCollector';
export function provideDependencies( export function provideDependencies(
context: IApplicationContext, context: IApplicationContext,
@@ -72,6 +73,10 @@ export function provideDependencies(
InjectionKeys.useDialog, InjectionKeys.useDialog,
useDialog, useDialog,
), ),
useScriptDiagnosticsCollector: (di) => di.provide(
InjectionKeys.useScriptDiagnosticsCollector,
useScriptDiagnosticsCollector,
),
}; };
registerAll(Object.values(resolvers), api); registerAll(Object.values(resolvers), api);
} }

View File

@@ -11,9 +11,8 @@
import { defineComponent, computed } from 'vue'; 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 { CodeRunError } from '@/application/CodeRunner/CodeRunner';
import IconButton from './IconButton.vue'; import IconButton from './IconButton.vue';
import { createScriptErrorDialog } from './ScriptErrorDialog';
export default defineComponent({ export default defineComponent({
components: { components: {
@@ -24,6 +23,7 @@ export default defineComponent({
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment); const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { codeRunner } = injectKey((keys) => keys.useCodeRunner); const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
const { dialog } = injectKey((keys) => keys.useDialog); const { dialog } = injectKey((keys) => keys.useDialog);
const { scriptDiagnosticsCollector } = injectKey((keys) => keys.useScriptDiagnosticsCollector);
const canRun = computed<boolean>(() => getCanRunState( const canRun = computed<boolean>(() => getCanRunState(
currentState.value.os, currentState.value.os,
@@ -38,7 +38,12 @@ export default defineComponent({
currentContext.state.collection.scripting.fileExtension, currentContext.state.collection.scripting.fileExtension,
); );
if (!success) { 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; const isRunningOnSelectedOs = selectedOs === hostOs;
return isRunningAsDesktopApplication && isRunningOnSelectedOs; 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<Dialog['showError']> {
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<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,8 +20,9 @@ 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, SaveFileError } from '@/presentation/common/Dialog'; import { FileType } from '@/presentation/common/Dialog';
import IconButton from '../IconButton.vue'; import IconButton from '../IconButton.vue';
import { createScriptErrorDialog } from '../ScriptErrorDialog';
import RunInstructions from './RunInstructions/RunInstructions.vue'; import RunInstructions from './RunInstructions/RunInstructions.vue';
export default defineComponent({ export default defineComponent({
@@ -34,6 +35,7 @@ export default defineComponent({
const { currentState } = injectKey((keys) => keys.useCollectionState); const { currentState } = injectKey((keys) => keys.useCollectionState);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment); const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { dialog } = injectKey((keys) => keys.useDialog); const { dialog } = injectKey((keys) => keys.useDialog);
const { scriptDiagnosticsCollector } = injectKey((keys) => keys.useScriptDiagnosticsCollector);
const areInstructionsVisible = ref(false); const areInstructionsVisible = ref(false);
const filename = computed<string>(() => buildFilename(currentState.value.collection.scripting)); const filename = computed<string>(() => buildFilename(currentState.value.collection.scripting));
@@ -45,7 +47,12 @@ export default defineComponent({
getType(currentState.value.collection.scripting.language), getType(currentState.value.collection.scripting.language),
); );
if (!success) { if (!success) {
showScriptSaveError(dialog, error); dialog.showError(...(await createScriptErrorDialog({
errorContext: 'save',
errorType: error.type,
errorMessage: error.message,
isFileReadbackError: error.type === 'FileReadbackVerificationError',
}, scriptDiagnosticsCollector)));
return; return;
} }
areInstructionsVisible.value = true; areInstructionsVisible.value = true;
@@ -77,60 +84,4 @@ function buildFilename(scripting: IScriptingDefinition) {
} }
return ScriptFilename; 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<Dialog['showError']> {
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<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,7 +49,7 @@
</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. Don't worry; 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:

View File

@@ -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<Parameters<Dialog['showError']>> {
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<Dialog['showError']> {
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<Dialog['showError']> {
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<T>(
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<T>(options: {
readonly savingScript: T;
readonly runningScript: T;
}, information: ScriptErrorDetails): T {
if (information.errorContext === 'run') {
return options.runningScript;
}
return options.savingScript;
}

View File

@@ -0,0 +1,9 @@
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
export function useScriptDiagnosticsCollector(
window: Partial<WindowVariables> = globalThis.window,
) {
return {
scriptDiagnosticsCollector: window?.scriptDiagnosticsCollector,
};
}

View File

@@ -3,17 +3,22 @@ import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog'; import { Dialog } from '@/presentation/common/Dialog';
import { ElectronDialog } from '@/infrastructure/Dialog/Electron/ElectronDialog'; import { ElectronDialog } from '@/infrastructure/Dialog/Electron/ElectronDialog';
import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel'; 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 { registerIpcChannel } from '../shared/IpcBridging/IpcProxy';
import { ChannelDefinitionKey, IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions'; import { ChannelDefinitionKey, IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions';
export function registerAllIpcChannels( export function registerAllIpcChannels(
registrar: IpcChannelRegistrar = registerIpcChannel,
createCodeRunner: CodeRunnerFactory = () => new ScriptFileCodeRunner(), createCodeRunner: CodeRunnerFactory = () => new ScriptFileCodeRunner(),
createDialog: DialogFactory = () => new ElectronDialog(), createDialog: DialogFactory = () => new ElectronDialog(),
registrar: IpcChannelRegistrar = registerIpcChannel, createScriptDiagnosticsCollector
: ScriptDiagnosticsCollectorFactory = () => new ScriptEnvironmentDiagnosticsCollector(),
) { ) {
const ipcInstanceCreators: IpcChannelRegistrars = { const ipcInstanceCreators: IpcChannelRegistrars = {
CodeRunner: () => createCodeRunner(), CodeRunner: () => createCodeRunner(),
Dialog: () => createDialog(), Dialog: () => createDialog(),
ScriptDiagnosticsCollector: () => createScriptDiagnosticsCollector(),
}; };
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => { Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
try { try {
@@ -26,9 +31,11 @@ export function registerAllIpcChannels(
}); });
} }
export type IpcChannelRegistrar = typeof registerIpcChannel;
export type CodeRunnerFactory = () => CodeRunner; export type CodeRunnerFactory = () => CodeRunner;
export type DialogFactory = () => Dialog; export type DialogFactory = () => Dialog;
export type IpcChannelRegistrar = typeof registerIpcChannel; export type ScriptDiagnosticsCollectorFactory = () => ScriptDiagnosticsCollector;
type RegistrationChannel<T extends ChannelDefinitionKey> = (typeof IpcChannelDefinitions)[T]; type RegistrationChannel<T extends ChannelDefinitionKey> = (typeof IpcChannelDefinitions)[T];
type ExtractChannelServiceType<T> = T extends IpcChannel<infer U> ? U : never; type ExtractChannelServiceType<T> = T extends IpcChannel<infer U> ? U : never;

View File

@@ -7,10 +7,10 @@ import { IpcChannelDefinitions } from '../../shared/IpcBridging/IpcChannelDefini
import { createSecureFacade } from './SecureFacadeCreator'; import { createSecureFacade } from './SecureFacadeCreator';
export function provideWindowVariables( export function provideWindowVariables(
createLogger: LoggerFactory = () => createElectronLogger(),
convertToOs = convertPlatformToOs,
createApiFacade: ApiFacadeFactory = createSecureFacade, createApiFacade: ApiFacadeFactory = createSecureFacade,
ipcConsumerCreator: IpcConsumerProxyCreator = createIpcConsumerProxy, ipcConsumerCreator: IpcConsumerProxyCreator = createIpcConsumerProxy,
convertToOs = convertPlatformToOs,
createLogger: LoggerFactory = () => createElectronLogger(),
): WindowVariables { ): WindowVariables {
// Enforces mandatory variable availability at compile time // Enforces mandatory variable availability at compile time
const variables: RequiredWindowVariables = { const variables: RequiredWindowVariables = {
@@ -19,6 +19,9 @@ export function provideWindowVariables(
os: convertToOs(process.platform), os: convertToOs(process.platform),
codeRunner: ipcConsumerCreator(IpcChannelDefinitions.CodeRunner), codeRunner: ipcConsumerCreator(IpcChannelDefinitions.CodeRunner),
dialog: ipcConsumerCreator(IpcChannelDefinitions.Dialog), dialog: ipcConsumerCreator(IpcChannelDefinitions.Dialog),
scriptDiagnosticsCollector: ipcConsumerCreator(
IpcChannelDefinitions.ScriptDiagnosticsCollector,
),
}; };
return variables; return variables;
} }
@@ -26,8 +29,8 @@ export function provideWindowVariables(
type RequiredWindowVariables = PartiallyRequired<WindowVariables, 'os' /* | 'anotherOptionalKey'.. */>; type RequiredWindowVariables = PartiallyRequired<WindowVariables, 'os' /* | 'anotherOptionalKey'.. */>;
type PartiallyRequired<T, K extends keyof T> = Required<Omit<T, K>> & Pick<T, K>; type PartiallyRequired<T, K extends keyof T> = Required<Omit<T, K>> & Pick<T, K>;
export type LoggerFactory = () => Logger;
export type ApiFacadeFactory = typeof createSecureFacade; export type ApiFacadeFactory = typeof createSecureFacade;
export type IpcConsumerProxyCreator = typeof createIpcConsumerProxy; export type IpcConsumerProxyCreator = typeof createIpcConsumerProxy;
export type LoggerFactory = () => Logger;

View File

@@ -1,11 +1,13 @@
import { FunctionKeys } from '@/TypeHelpers'; import { FunctionKeys } from '@/TypeHelpers';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog'; import { Dialog } from '@/presentation/common/Dialog';
import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import { IpcChannel } from './IpcChannel'; import { IpcChannel } from './IpcChannel';
export const IpcChannelDefinitions = { export const IpcChannelDefinitions = {
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']), CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['showError', 'saveFile']), Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['showError', 'saveFile']),
ScriptDiagnosticsCollector: defineElectronIpcChannel<ScriptDiagnosticsCollector>('script-diagnostics-collector', ['collectDiagnosticInformation']),
} as const; } as const;
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions; export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;

View File

@@ -51,8 +51,11 @@
</style> </style>
<div id="javascriptDisabled"> <div id="javascriptDisabled">
<h1>Problem loading page</h1> <h1>Problem loading page</h1>
<p>The page does not work without JavaScript enabled. Please enable it to use privacy.sexy. There's no shady stuff <p>
as 100% of the website is open source.</p> 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.
</p>
</div> </div>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>

View File

@@ -9,6 +9,7 @@ import type { useUserSelectionState } from '@/presentation/components/Shared/Hoo
import type { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger'; import type { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger';
import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner'; import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner';
import type { useDialog } from './components/Shared/Hooks/Dialog/UseDialog'; import type { useDialog } from './components/Shared/Hooks/Dialog/UseDialog';
import type { useScriptDiagnosticsCollector } from './components/Shared/Hooks/UseScriptDiagnosticsCollector';
export const InjectionKeys = { export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'), useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -21,6 +22,7 @@ export const InjectionKeys = {
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'), useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'), useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
useDialog: defineTransientKey<ReturnType<typeof useDialog>>('useDialog'), useDialog: defineTransientKey<ReturnType<typeof useDialog>>('useDialog'),
useScriptDiagnosticsCollector: defineTransientKey<ReturnType<typeof useScriptDiagnosticsCollector>>('useScriptDiagnostics'),
}; };
export interface InjectionKeyWithLifetime<T> { export interface InjectionKeyWithLifetime<T> {

View File

@@ -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;
}

View File

@@ -1,58 +1,38 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
BrowserRuntimeEnvironmentFactory, GlobalPropertiesAccessor, NodeRuntimeEnvironmentFactory, BrowserRuntimeEnvironmentFactory, NodeRuntimeEnvironmentFactory,
determineAndCreateRuntimeEnvironment, determineAndCreateRuntimeEnvironment,
} from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; 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('RuntimeEnvironmentFactory', () => {
describe('determineAndCreateRuntimeEnvironment', () => { describe('determineAndCreateRuntimeEnvironment', () => {
describe('Node environment creation', () => { describe('Node environment creation', () => {
it('selects Node environment if Electron main process detected', () => { it('creates Node environment in Electron main process', () => {
// arrange // arrange
const processStub = createProcessStub({
versions: {
electron: '28.1.3',
} as NodeJS.ProcessVersions,
});
const expectedEnvironment = new RuntimeEnvironmentStub(); const expectedEnvironment = new RuntimeEnvironmentStub();
const mainProcessDetector = new ElectronEnvironmentDetectorStub()
.withElectronEnvironment('main');
const context = new RuntimeEnvironmentFactoryTestSetup() const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(processStub) .withElectronEnvironmentDetector(mainProcessDetector)
.withNodeEnvironmentFactory(() => expectedEnvironment); .withNodeEnvironmentFactory(() => expectedEnvironment);
// act // act
const actualEnvironment = context.buildEnvironment(); const actualEnvironment = context.buildEnvironment();
// assert // assert
expect(actualEnvironment).to.equal(expectedEnvironment); expect(actualEnvironment).to.equal(expectedEnvironment);
}); });
it('passes correct process to Node environment factory', () => {
// 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 context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(expectedProcess)
.withNodeEnvironmentFactory(nodeEnvironmentFactoryMock);
// act
context.buildEnvironment();
// assert
expect(actualProcess).to.equal(expectedProcess);
});
}); });
describe('browser environment creation', () => { describe('browser environment creation', () => {
it('selects browser environment if Electron main process not detected', () => { it('creates browser environment in Electron renderer process', () => {
// arrange // arrange
const expectedEnvironment = new RuntimeEnvironmentStub(); const expectedEnvironment = new RuntimeEnvironmentStub();
const undefinedProcess: GlobalProcess = undefined; const rendererProcessDetector = new ElectronEnvironmentDetectorStub()
.withElectronEnvironment('renderer');
const windowStub = createWindowStub(); const windowStub = createWindowStub();
const context = new RuntimeEnvironmentFactoryTestSetup() const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(undefinedProcess) .withElectronEnvironmentDetector(rendererProcessDetector)
.withGlobalWindow(windowStub) .withGlobalWindow(windowStub)
.withBrowserEnvironmentFactory(() => expectedEnvironment); .withBrowserEnvironmentFactory(() => expectedEnvironment);
// act // act
@@ -60,21 +40,37 @@ describe('RuntimeEnvironmentFactory', () => {
// assert // assert
expect(actualEnvironment).to.equal(expectedEnvironment); expect(actualEnvironment).to.equal(expectedEnvironment);
}); });
it('passes correct window to browser environment factory', () => { it('creates browser environment in Electron preloader process', () => {
// arrange
const expectedEnvironment = new RuntimeEnvironmentStub();
const preloaderProcessDetector = new ElectronEnvironmentDetectorStub()
.withElectronEnvironment('preloader');
const windowStub = createWindowStub();
const context = new RuntimeEnvironmentFactoryTestSetup()
.withElectronEnvironmentDetector(preloaderProcessDetector)
.withGlobalWindow(windowStub)
.withBrowserEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
expect(actualEnvironment).to.equal(expectedEnvironment);
});
it('provides correct window to browser environment factory', () => {
// arrange // arrange
const expectedWindow = createWindowStub({ const expectedWindow = createWindowStub({
isRunningAsDesktopApplication: undefined, isRunningAsDesktopApplication: undefined,
}); });
let actualWindow: GlobalWindow; let actualWindow: Window | undefined;
const browserEnvironmentFactoryMock const browserEnvironmentFactoryMock
: BrowserRuntimeEnvironmentFactory = (providedWindow) => { : BrowserRuntimeEnvironmentFactory = (providedWindow) => {
actualWindow = providedWindow; actualWindow = providedWindow;
return new RuntimeEnvironmentStub(); return new RuntimeEnvironmentStub();
}; };
const undefinedProcess: GlobalProcess = undefined; const nonElectronDetector = new ElectronEnvironmentDetectorStub()
.withNonElectronEnvironment();
const context = new RuntimeEnvironmentFactoryTestSetup() const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalWindow(expectedWindow) .withGlobalWindow(expectedWindow)
.withGlobalProcess(undefinedProcess) .withElectronEnvironmentDetector(nonElectronDetector)
.withBrowserEnvironmentFactory(browserEnvironmentFactoryMock); .withBrowserEnvironmentFactory(browserEnvironmentFactoryMock);
// act // act
context.buildEnvironment(); context.buildEnvironment();
@@ -82,14 +78,15 @@ describe('RuntimeEnvironmentFactory', () => {
expect(actualWindow).to.equal(expectedWindow); 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 // 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 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() const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(undefinedProcess) .withElectronEnvironmentDetector(nonElectronDetector)
.withGlobalWindow(undefinedWindow); .withGlobalWindow(nullWindow);
// act // act
const act = () => context.buildEnvironment(); const act = () => context.buildEnvironment();
// assert // assert
@@ -104,16 +101,11 @@ function createWindowStub(partialWindowProperties?: Partial<Window>): Window {
} as Window; } as Window;
} }
function createProcessStub(partialProcessProperties?: Partial<NodeJS.Process>): NodeJS.Process {
return {
...partialProcessProperties,
} as NodeJS.Process;
}
export class RuntimeEnvironmentFactoryTestSetup { export class RuntimeEnvironmentFactoryTestSetup {
private globalWindow: GlobalWindow = createWindowStub(); private globalWindow: Window | undefined | null = createWindowStub();
private globalProcess: GlobalProcess = createProcessStub(); private electronEnvironmentDetector
: ElectronEnvironmentDetector = new ElectronEnvironmentDetectorStub();
private browserEnvironmentFactory private browserEnvironmentFactory
: BrowserRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub(); : BrowserRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub();
@@ -121,13 +113,13 @@ export class RuntimeEnvironmentFactoryTestSetup {
private nodeEnvironmentFactory private nodeEnvironmentFactory
: NodeRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub(); : NodeRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub();
public withGlobalWindow(globalWindow: GlobalWindow): this { public withGlobalWindow(globalWindow: Window | undefined | null): this {
this.globalWindow = globalWindow; this.globalWindow = globalWindow;
return this; return this;
} }
public withGlobalProcess(globalProcess: GlobalProcess): this { public withElectronEnvironmentDetector(detector: ElectronEnvironmentDetector): this {
this.globalProcess = globalProcess; this.electronEnvironmentDetector = detector;
return this; return this;
} }
@@ -147,16 +139,10 @@ export class RuntimeEnvironmentFactoryTestSetup {
public buildEnvironment(): ReturnType<typeof determineAndCreateRuntimeEnvironment> { public buildEnvironment(): ReturnType<typeof determineAndCreateRuntimeEnvironment> {
return determineAndCreateRuntimeEnvironment( return determineAndCreateRuntimeEnvironment(
{ this.globalWindow,
window: this.globalWindow, this.electronEnvironmentDetector,
process: this.globalProcess,
},
this.browserEnvironmentFactory, this.browserEnvironmentFactory,
this.nodeEnvironmentFactory, this.nodeEnvironmentFactory,
); );
} }
} }
type GlobalWindow = GlobalPropertiesAccessor['window'];
type GlobalProcess = GlobalPropertiesAccessor['process'];

View File

@@ -2,28 +2,18 @@ import { describe, it, expect } from 'vitest';
import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator'; import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { OperatingSystem } from '@/domain/OperatingSystem'; 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 { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub'; import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
import { PropertyKeys } from '@/TypeHelpers'; import { PropertyKeys } from '@/TypeHelpers';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub'; 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('WindowVariablesValidator', () => {
describe('validateWindowVariables', () => { describe('validateWindowVariables', () => {
describe('validates window type', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const expectedError = 'window is not an object';
const window: Partial<WindowVariables> = invalidObjectValue as never;
// act
const act = () => validateWindowVariables(window);
// assert
expect(act).to.throw(expectedError);
});
});
describe('property validations', () => {
it('throws an error with a description of all invalid properties', () => { it('throws an error with a description of all invalid properties', () => {
// arrange // arrange
const invalidOs = 'invalid' as unknown as OperatingSystem; const invalidOs = 'invalid' as unknown as OperatingSystem;
@@ -31,193 +21,219 @@ describe('WindowVariablesValidator', () => {
const expectedError = getExpectedError( const expectedError = getExpectedError(
{ {
name: 'os', name: 'os',
object: invalidOs, value: invalidOs,
}, },
{ {
name: 'isRunningAsDesktopApplication', name: 'isRunningAsDesktopApplication',
object: invalidIsRunningAsDesktopApplication, value: invalidIsRunningAsDesktopApplication,
}, },
); );
const input = new WindowVariablesStub() const input = new WindowVariablesStub()
.withOs(invalidOs) .withOs(invalidOs)
.withIsRunningAsDesktopApplication(invalidIsRunningAsDesktopApplication); .withIsRunningAsDesktopApplication(invalidIsRunningAsDesktopApplication);
const context = new ValidateWindowVariablesTestSetup()
.withWindowVariables(input);
// act // act
const act = () => validateWindowVariables(input); const act = () => context.validateWindowVariables();
// assert // assert
expect(act).to.throw(expectedError); expect(act).to.throw(expectedError);
}); });
describe('when not in Electron renderer process', () => {
describe('`os` property', () => { const testScenarios: ReadonlyArray<{
it('throws an error when os is not a number', () => { readonly description: string;
// arrange readonly environment: ElectronEnvironmentDetector;
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', () => {
// arrange
const invalidOs = Number.MAX_SAFE_INTEGER;
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('does not throw for a missing os value', () => {
// arrange
const input = new WindowVariablesStub()
.withIsRunningAsDesktopApplication(true)
.withOs(undefined);
// act
const act = () => validateWindowVariables(input);
// 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<Required<WindowVariables>>,
(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('`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();
});
});
});
function expectObjectOnDesktop<T>(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();
});
});
}
function itEachInvalidObjectValue<T>(runner: (invalidObjectValue: T) => void) {
const testCases: Array<{
readonly name: string;
readonly value: T;
}> = [ }> = [
{ {
name: 'given string', description: 'skips in non-Electron environments',
value: 'invalid object' as unknown as T, environment: new ElectronEnvironmentDetectorStub()
.withNonElectronEnvironment(),
}, },
{ {
name: 'given array of objects', description: 'skips in Electron main process',
value: [{}, {}] as unknown as T, 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 = 'invalid' as unknown as OperatingSystem;
const input = new WindowVariablesStub()
.withOs(invalidOs);
const context = new ValidateWindowVariablesTestSetup()
.withElectronDetector(environment)
.withWindowVariables(input);
// act
const act = () => context.validateWindowVariables();
// assert
expect(act).to.not.throw();
});
});
});
describe('does not throw when a property is valid', () => {
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, 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('throws an error when a property is invalid', () => {
interface InvalidValueTestCase {
readonly description: string;
readonly invalidValue: unknown;
}
const testScenarios: Record<
PropertyKeys<Required<WindowVariables>>,
ReadonlyArray<InvalidValueTestCase>> = {
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) => ({ ...getAbsentObjectTestCases().map((testCase) => ({
name: `given absent: ${testCase.valueName}`, description: `rejects absent: ${testCase.valueName}`,
value: testCase.absentValue as unknown as T, invalidValue: testCase.absentValue,
})), })),
]; ];
testCases.forEach((testCase) => { }
it(testCase.name, () => {
runner(testCase.value);
}); });
}); });
});
class ValidateWindowVariablesTestSetup {
private electronDetector: ElectronEnvironmentDetector = new ElectronEnvironmentDetectorStub()
.withElectronEnvironment('renderer');
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<{ function getExpectedError(...unexpectedObjects: Array<{
readonly name: keyof WindowVariables; readonly name: keyof WindowVariables;
readonly object: unknown; readonly value: unknown;
}>) { }>) {
const errors = unexpectedObjects const errors = unexpectedObjects
.map(({ name, object }) => `Unexpected ${name} (${typeof object})`); .map(({ name, value: object }) => `Unexpected ${name} (${typeof object})`);
return errors.join('\n'); return errors.join('\n');
} }

View File

@@ -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,
);
}
}

View File

@@ -20,6 +20,7 @@ describe('DependencyProvider', () => {
useLogger: createTransientTests(), useLogger: createTransientTests(),
useCodeRunner: createTransientTests(), useCodeRunner: createTransientTests(),
useDialog: createTransientTests(), useDialog: createTransientTests(),
useScriptDiagnosticsCollector: createTransientTests(),
}; };
Object.entries(testCases).forEach(([key, runTests]) => { Object.entries(testCases).forEach(([key, runTests]) => {
const registeredKey = InjectionKeys[key].key; const registeredKey = InjectionKeys[key].key;

View File

@@ -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<Dialog['showError']>): 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>): 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,
);
}
}

View File

@@ -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<WindowVariables> = {
scriptDiagnosticsCollector: expectedCollector,
} as Partial<WindowVariables>;
// act
const { scriptDiagnosticsCollector } = useScriptDiagnosticsCollector(windowWithVariable);
// assert
expect(scriptDiagnosticsCollector).to.equal(expectedCollector);
});
});

View File

@@ -2,12 +2,14 @@ import { describe, it, expect } from 'vitest';
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub'; import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
import { ChannelDefinitionKey, IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions'; import { ChannelDefinitionKey, IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions';
import { import {
CodeRunnerFactory, DialogFactory, IpcChannelRegistrar, registerAllIpcChannels, CodeRunnerFactory, DialogFactory, IpcChannelRegistrar,
ScriptDiagnosticsCollectorFactory, registerAllIpcChannels,
} from '@/presentation/electron/main/IpcRegistration'; } from '@/presentation/electron/main/IpcRegistration';
import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel'; import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel';
import { expectExists } from '@tests/shared/Assertions/ExpectExists'; import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector'; import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub'; import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
import { ScriptDiagnosticsCollectorStub } from '../../../shared/Stubs/ScriptDiagnosticsCollectorStub';
describe('IpcRegistration', () => { describe('IpcRegistration', () => {
describe('registerAllIpcChannels', () => { describe('registerAllIpcChannels', () => {
@@ -44,6 +46,13 @@ describe('IpcRegistration', () => {
expectedInstance, expectedInstance,
}; };
})(), })(),
ScriptDiagnosticsCollector: (() => {
const expectedInstance = new ScriptDiagnosticsCollectorStub();
return {
buildContext: (c) => c.witScriptDiagnosticsCollectorFactory(() => expectedInstance),
expectedInstance,
};
})(),
}; };
Object.entries(testScenarios).forEach(([ Object.entries(testScenarios).forEach(([
key, { buildContext, expectedInstance }, key, { buildContext, expectedInstance },
@@ -79,11 +88,14 @@ describe('IpcRegistration', () => {
}); });
class IpcRegistrationTestSetup { class IpcRegistrationTestSetup {
private registrar: IpcChannelRegistrar = () => { /* NOOP */ };
private codeRunnerFactory: CodeRunnerFactory = () => new CodeRunnerStub(); private codeRunnerFactory: CodeRunnerFactory = () => new CodeRunnerStub();
private dialogFactory: DialogFactory = () => new DialogStub(); private dialogFactory: DialogFactory = () => new DialogStub();
private registrar: IpcChannelRegistrar = () => { /* NOOP */ }; private scriptDiagnosticsCollectorFactory
: ScriptDiagnosticsCollectorFactory = () => new ScriptDiagnosticsCollectorStub();
public withRegistrar(registrar: IpcChannelRegistrar): this { public withRegistrar(registrar: IpcChannelRegistrar): this {
this.registrar = registrar; this.registrar = registrar;
@@ -100,11 +112,19 @@ class IpcRegistrationTestSetup {
return this; return this;
} }
public witScriptDiagnosticsCollectorFactory(
scriptDiagnosticsCollectorFactory: ScriptDiagnosticsCollectorFactory,
): this {
this.scriptDiagnosticsCollectorFactory = scriptDiagnosticsCollectorFactory;
return this;
}
public run() { public run() {
registerAllIpcChannels( registerAllIpcChannels(
this.registrar,
this.codeRunnerFactory, this.codeRunnerFactory,
this.dialogFactory, this.dialogFactory,
this.registrar, this.scriptDiagnosticsCollectorFactory,
); );
} }
} }

View File

@@ -15,7 +15,9 @@ describe('RendererApiProvider', () => {
setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext; setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext;
readonly expectedValue: unknown; readonly expectedValue: unknown;
} }
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, WindowVariableTestCase> = { const testScenarios: Record<
PropertyKeys<Required<WindowVariables>>,
WindowVariableTestCase> = {
isRunningAsDesktopApplication: { isRunningAsDesktopApplication: {
description: 'returns true', description: 'returns true',
setupContext: (context) => context, setupContext: (context) => context,
@@ -32,9 +34,12 @@ describe('RendererApiProvider', () => {
})(), })(),
log: expectFacade({ log: expectFacade({
instance: new LoggerStub(), instance: new LoggerStub(),
setupContext: (c, logger) => c.withLogger(logger), setupContext: (c, instance) => c.withLogger(instance),
}), }),
dialog: expectIpcConsumer(IpcChannelDefinitions.Dialog), dialog: expectIpcConsumer(IpcChannelDefinitions.Dialog),
scriptDiagnosticsCollector: expectIpcConsumer(
IpcChannelDefinitions.ScriptDiagnosticsCollector,
),
}; };
Object.entries(testScenarios).forEach(( Object.entries(testScenarios).forEach((
[property, { description, setupContext, expectedValue }], [property, { description, setupContext, expectedValue }],
@@ -109,10 +114,10 @@ class RendererApiProviderTestContext {
public provideWindowVariables() { public provideWindowVariables() {
return provideWindowVariables( return provideWindowVariables(
() => this.log,
() => this.os,
this.apiFacadeCreator, this.apiFacadeCreator,
this.ipcConsumerCreator, this.ipcConsumerCreator,
() => this.os,
() => this.log,
); );
} }
} }

View File

@@ -16,6 +16,10 @@ describe('IpcChannelDefinitions', () => {
expectedNamespace: 'dialogs', expectedNamespace: 'dialogs',
expectedAccessibleMembers: ['saveFile'], expectedAccessibleMembers: ['saveFile'],
}, },
ScriptDiagnosticsCollector: {
expectedNamespace: 'script-diagnostics-collector',
expectedAccessibleMembers: ['collectDiagnosticInformation'],
},
}; };
Object.entries(testScenarios).forEach(( Object.entries(testScenarios).forEach((
[definitionKey, { expectedNamespace, expectedAccessibleMembers }], [definitionKey, { expectedNamespace, expectedAccessibleMembers }],

View File

@@ -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;
}
}

View File

@@ -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<ScriptDiagnosticData> {
return Promise.resolve({
scriptsDirectoryAbsolutePath: this.scriptDirectoryPath,
currentOperatingSystem: this.operatingSystem,
});
}
}

View File

@@ -3,9 +3,11 @@ import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner'; import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog'; import { Dialog } from '@/presentation/common/Dialog';
import { ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import { LoggerStub } from './LoggerStub'; import { LoggerStub } from './LoggerStub';
import { CodeRunnerStub } from './CodeRunnerStub'; import { CodeRunnerStub } from './CodeRunnerStub';
import { DialogStub } from './DialogStub'; import { DialogStub } from './DialogStub';
import { ScriptDiagnosticsCollectorStub } from './ScriptDiagnosticsCollectorStub';
export class WindowVariablesStub implements WindowVariables { export class WindowVariablesStub implements WindowVariables {
public codeRunner?: CodeRunner = new CodeRunnerStub(); public codeRunner?: CodeRunner = new CodeRunnerStub();
@@ -18,6 +20,16 @@ export class WindowVariablesStub implements WindowVariables {
public dialog?: Dialog = new DialogStub(); 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 { public withLog(log: Logger): this {
this.log = log; this.log = log;
return this; return this;