Show native save dialogs in desktop app #50, #264

This commit introduces native operating system file dialogs in the
desktop application replacing the existing web-based dialogs.

It lays the foundation for future enhancements such as:

- Providing error messages when saving or executing files, addressing
  #264.
- Creating system restore points, addressing #50.

Documentation updates:

- Update `desktop-vs-web-features.md` with added functionality.
- Update `README.md` with security feature highlights.
- Update home page documentation to emphasize security features.

Other supporting changes include:

- Integrate IPC communication channels for secure Electron dialog API
  interactions.
- Refactor `IpcRegistration` for more type-safety and simplicity.
- Introduce a Vue hook to encapsulate dialog functionality.
- Improve errors during IPC registration for easier troubleshooting.
- Move `ClientLoggerFactory` for consistency in hooks organization and
  remove `LoggerFactory` interface for simplicity.
- Add tests for the save file dialog in the browser context.
- Add `Blob` polyfill in tests to compensate for the missing
  `blob.text()` function in `jsdom` (see jsdom/jsdom#2555).

Improve environment detection logic:

- Treat test environment as browser environments to correctly activate
  features based on the environment. This resolves issues where the
  environment is misidentified as desktop, but Electron preloader APIs
  are missing.
- Rename `isDesktop` environment identification variable to
  `isRunningAsDesktopApplication` for better clarity and to avoid
  confusion with desktop environments in web/browser/test environments.
- Simplify `BrowserRuntimeEnvironment` to consistently detect
  non-desktop application environments.
- Improve environment detection for Electron main process
  (electron/electron#2288).
This commit is contained in:
undergroundwires
2024-01-13 18:04:23 +01:00
parent da4be500da
commit c546a33eff
65 changed files with 1384 additions and 345 deletions

View File

@@ -137,6 +137,7 @@ For a detailed comparison of features between the desktop and web versions of pr
- **Transparent**. Have full visibility into what the tweaks do as you enable them.
- **Reversible**. Revert if something feels wrong.
- **Accessible**. No need to run any compiled software on your computer with web version.
- **Secure**: Security is a top priority at privacy.sexy with comprehensive safeguards in place. [Learn more](./SECURITY.md).
- **Open**. What you see as code in this repository is what you get. The application itself, its infrastructure and deployments are open-source and automated thanks to [bump-everywhere](https://github.com/undergroundwires/bump-everywhere).
- **Tested**. A lot of tests. Automated and manual. Community-testing and verification. Stability improvements comes before new features.
- **Extensible**. Effortlessly [extend scripts](./CONTRIBUTING.md#extend-scripts) with a custom designed [templating language](./docs/templating.md).

View File

@@ -53,9 +53,16 @@ Log file locations vary by operating system:
The desktop version of privacy.sexy enables direct script execution, providing a seamless and integrated experience.
This direct execution capability isn't available in the web version due to inherent browser restrictions.
**Logging and storage:**
For enhanced auditability and easier troubleshooting, the desktop version keeps a record of executed scripts in designated directories.
These locations vary based on the operating system:
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
- Linux: `$HOME/.config/privacy.sexy/runs`
- Windows: `%APPDATA%\privacy.sexy\runs`
**Native file system dialogs:**
The desktop version uses native system file save 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.

View File

@@ -0,0 +1,19 @@
import { Dialog, FileType } from '@/presentation/common/Dialog';
import { FileSaverDialog } from './FileSaverDialog';
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
export class BrowserDialog implements Dialog {
constructor(private readonly saveFileDialog: BrowserSaveFileDialog = new FileSaverDialog()) {
}
public saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
return Promise.resolve(
this.saveFileDialog.saveFile(fileContents, fileName, type),
);
}
}

View File

@@ -0,0 +1,9 @@
import { FileType } from '@/presentation/common/Dialog';
export interface BrowserSaveFileDialog {
saveFile(
fileContents: string,
fileName: string,
fileType: FileType,
): void;
}

View File

@@ -0,0 +1,39 @@
import fileSaver from 'file-saver';
import { FileType } from '@/presentation/common/Dialog';
import { BrowserSaveFileDialog } from './BrowserSaveFileDialog';
export type SaveAsFunction = (data: Blob, filename?: string) => void;
export type WindowOpenFunction = (url: string, target: string, features: string) => void;
export class FileSaverDialog implements BrowserSaveFileDialog {
constructor(
private readonly fileSaverSaveAs: SaveAsFunction = fileSaver.saveAs,
private readonly windowOpen: WindowOpenFunction = window.open.bind(window),
) { }
public saveFile(
fileContents: string,
fileName: string,
fileType: FileType,
): void {
const mimeType = MimeTypes[fileType];
this.saveBlob(fileContents, mimeType, fileName);
}
private saveBlob(file: BlobPart, mimeType: string, fileName: string): void {
try {
const blob = new Blob([file], { type: mimeType });
this.fileSaverSaveAs(blob, fileName);
} catch (e) {
this.windowOpen(`data:${mimeType},${encodeURIComponent(file.toString())}`, '_blank', '');
}
}
}
const MimeTypes: Record<FileType, string> = {
// Some browsers (including firefox + IE) require right mime type
// otherwise they ignore extension and save the file as text.
[FileType.BatchFile]: 'application/bat', // https://en.wikipedia.org/wiki/Batch_file
[FileType.ShellScript]: 'text/x-shellscript', // https://de.wikipedia.org/wiki/Shellskript#MIME-Typ
} as const;

View File

@@ -0,0 +1,17 @@
import { Dialog, FileType } from '@/presentation/common/Dialog';
import { NodeElectronSaveFileDialog } from './NodeElectronSaveFileDialog';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class ElectronDialog implements Dialog {
constructor(
private readonly fileSaveDialog: ElectronSaveFileDialog = new NodeElectronSaveFileDialog(),
) { }
public async saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
await this.fileSaveDialog.saveFile(fileContents, fileName, type);
}
}

View File

@@ -0,0 +1,9 @@
import { FileType } from '@/presentation/common/Dialog';
export interface ElectronSaveFileDialog {
saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void>;
}

View File

@@ -0,0 +1,98 @@
import { join } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { app, dialog } from 'electron/main';
import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { FileType } from '@/presentation/common/Dialog';
import { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export interface ElectronFileDialogOperations {
getUserDownloadsPath(): string;
showSaveDialog(options: Electron.SaveDialogOptions): Promise<Electron.SaveDialogReturnValue>;
}
export interface NodeFileOperations {
readonly join: typeof join;
writeFile(file: string, data: string): Promise<void>;
}
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
constructor(
private readonly logger: Logger = ElectronLogger,
private readonly electron: ElectronFileDialogOperations = {
getUserDownloadsPath: () => app.getPath('downloads'),
showSaveDialog: dialog.showSaveDialog.bind(dialog),
},
private readonly node: NodeFileOperations = {
join,
writeFile,
},
) { }
public async saveFile(
fileContents: string,
fileName: string,
type: FileType,
): Promise<void> {
const userSelectedFilePath = await this.showSaveFileDialog(fileName, type);
if (!userSelectedFilePath) {
this.logger.info(`File save cancelled by user: ${fileName}`);
return;
}
await this.writeFile(userSelectedFilePath, fileContents);
}
private async writeFile(filePath: string, fileContents: string): Promise<void> {
try {
this.logger.info(`Saving file: ${filePath}`);
await this.node.writeFile(filePath, fileContents);
this.logger.info(`File saved: ${filePath}`);
} catch (error) {
this.logger.error(`Error saving file: ${error.message}`);
}
}
private async showSaveFileDialog(fileName: string, type: FileType): Promise<string | undefined> {
const downloadsFolder = this.electron.getUserDownloadsPath();
const defaultFilePath = this.node.join(downloadsFolder, fileName);
const dialogResult = await this.electron.showSaveDialog({
title: fileName,
defaultPath: defaultFilePath,
filters: getDialogFileFilters(type),
properties: [
'createDirectory', // Enables directory creation on macOS.
'showOverwriteConfirmation', // Shows overwrite confirmation on Linux.
],
});
if (dialogResult.canceled) {
return undefined;
}
return dialogResult.filePath;
}
}
function getDialogFileFilters(fileType: FileType): Electron.FileFilter[] {
const filters = FileTypeSpecificFilters[fileType];
return [
...filters,
{
name: 'All Files',
extensions: ['*'],
},
];
}
const FileTypeSpecificFilters: Record<FileType, Electron.FileFilter[]> = {
[FileType.BatchFile]: [
{
name: 'Batch Files',
extensions: ['bat', 'cmd'],
},
],
[FileType.ShellScript]: [
{
name: 'Shell Scripts',
extensions: ['sh', 'bash', 'zsh'],
},
],
};

View File

@@ -84,4 +84,23 @@ export const BrowserConditions: readonly BrowserCondition[] = [
notExistingPartsInUserAgent: ['like Mac OS X'], // Eliminate iOS and iPadOS for Safari
touchSupport: TouchSupportExpectation.MustNotExist, // Distinguish from iPadOS for Safari
},
...generateJsdomBrowserConditions(),
] as const;
function generateJsdomBrowserConditions(): readonly BrowserCondition[] {
// jsdom user agent format: `Mozilla/5.0 (${process.platform || "unknown OS"}) ...` (https://archive.ph/2023.02.14-193200/https://github.com/jsdom/jsdom#advanced-configuration)
const operatingSystemPlatformMap: Partial<Record<
OperatingSystem,
NodeJS.Platform> // Enforce right platform constants at compile time
> = {
[OperatingSystem.Linux]: 'linux',
[OperatingSystem.Windows]: 'win32',
[OperatingSystem.macOS]: 'darwin',
} as const;
return Object
.entries(operatingSystemPlatformMap)
.map(([operatingSystemKey, platformString]): BrowserCondition => ({
operatingSystem: Number(operatingSystemKey),
existingPartsInSameUserAgent: ['jsdom', platformString],
}));
}

View File

@@ -1,5 +1,4 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { RuntimeEnvironment } from '../RuntimeEnvironment';
@@ -8,7 +7,7 @@ import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDete
import { isTouchEnabledDevice } from './TouchSupportDetection';
export class BrowserRuntimeEnvironment implements RuntimeEnvironment {
public readonly isDesktop: boolean;
public readonly isRunningAsDesktopApplication: boolean;
public readonly os: OperatingSystem | undefined;
@@ -18,31 +17,34 @@ export class BrowserRuntimeEnvironment implements RuntimeEnvironment {
window: Partial<Window>,
environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance,
browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(),
touchDetector = isTouchEnabledDevice,
touchDetector: TouchDetector = isTouchEnabledDevice,
) {
if (!window) { throw new Error('missing window'); } // do not trust strictNullChecks for global objects
this.isNonProduction = environmentVariables.isNonProduction;
this.isDesktop = isDesktop(window);
if (this.isDesktop) {
this.os = window?.os;
} else {
this.os = undefined;
const userAgent = getUserAgent(window);
if (userAgent) {
const browserEnvironment: BrowserEnvironment = {
userAgent,
isTouchSupported: touchDetector(),
};
this.os = browserOsDetector.detect(browserEnvironment);
}
}
this.isRunningAsDesktopApplication = isElectronRendererProcess(window);
this.os = determineOperatingSystem(window, touchDetector, browserOsDetector);
}
}
function getUserAgent(window: Partial<Window>): string | undefined {
return window?.navigator?.userAgent;
function isElectronRendererProcess(globalWindow: Partial<Window>): boolean {
return globalWindow.isRunningAsDesktopApplication === true; // Preloader injects this
// We could also do `globalWindow?.navigator?.userAgent?.includes('Electron') === true;`
}
function isDesktop(window: Partial<WindowVariables>): boolean {
return window?.isDesktop === true;
function determineOperatingSystem(
globalWindow: Partial<Window>,
touchDetector: TouchDetector,
browserOsDetector: BrowserOsDetector,
): OperatingSystem | undefined {
const userAgent = globalWindow?.navigator?.userAgent;
if (!userAgent) {
return undefined;
}
const browserEnvironment: BrowserEnvironment = {
userAgent,
isTouchSupported: touchDetector(),
};
return browserOsDetector.detect(browserEnvironment);
}
type TouchDetector = () => boolean;

View File

@@ -3,7 +3,7 @@ import { RuntimeEnvironment } from '../RuntimeEnvironment';
import { convertPlatformToOs } from './NodeOsMapper';
export class NodeRuntimeEnvironment implements RuntimeEnvironment {
public readonly isDesktop: boolean;
public readonly isRunningAsDesktopApplication: boolean;
public readonly os: OperatingSystem | undefined;
@@ -14,7 +14,7 @@ export class NodeRuntimeEnvironment implements RuntimeEnvironment {
convertToOs: PlatformToOperatingSystemConverter = convertPlatformToOs,
) {
if (!nodeProcess) { throw new Error('missing process'); } // do not trust strictNullChecks for global objects
this.isDesktop = true;
this.isRunningAsDesktopApplication = true;
this.os = convertToOs(nodeProcess.platform);
this.isNonProduction = nodeProcess.env.NODE_ENV !== 'production'; // populated by Vite
}

View File

@@ -1,7 +1,7 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface RuntimeEnvironment {
readonly isDesktop: boolean;
readonly isRunningAsDesktopApplication: boolean;
readonly os: OperatingSystem | undefined;
readonly isNonProduction: boolean;
}

View File

@@ -3,32 +3,47 @@ import { NodeRuntimeEnvironment } from './Node/NodeRuntimeEnvironment';
import { RuntimeEnvironment } from './RuntimeEnvironment';
export const CurrentEnvironment = determineAndCreateRuntimeEnvironment({
getGlobalWindow: () => globalThis.window,
getGlobalProcess: () => globalThis.process,
window: globalThis.window,
process: globalThis.process,
});
export function determineAndCreateRuntimeEnvironment(
globalAccessor: GlobalAccessor,
globalAccessor: GlobalPropertiesAccessor,
browserEnvironmentFactory: BrowserRuntimeEnvironmentFactory = (
window,
) => new BrowserRuntimeEnvironment(window),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = () => new NodeRuntimeEnvironment(),
nodeEnvironmentFactory: NodeRuntimeEnvironmentFactory = (
process: NodeJS.Process,
) => new NodeRuntimeEnvironment(process),
): RuntimeEnvironment {
if (globalAccessor.getGlobalProcess()) {
return nodeEnvironmentFactory();
if (isElectronMainProcess(globalAccessor.process)) {
return nodeEnvironmentFactory(globalAccessor.process);
}
const window = globalAccessor.getGlobalWindow();
if (window) {
return browserEnvironmentFactory(window);
const { window } = globalAccessor;
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 Node.js 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;
}
export type BrowserRuntimeEnvironmentFactory = (window: Window) => RuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = () => NodeRuntimeEnvironment;
export type NodeRuntimeEnvironmentFactory = (process: NodeJS.Process) => NodeRuntimeEnvironment;
export interface GlobalAccessor {
getGlobalWindow(): Window | undefined;
getGlobalProcess(): NodeJS.Process | undefined;
export interface GlobalPropertiesAccessor {
readonly window: Window | undefined;
readonly process: NodeJS.Process | undefined;
}

View File

@@ -1,33 +0,0 @@
import fileSaver from 'file-saver';
export enum FileType {
BatchFile,
ShellScript,
}
export class SaveFileDialog {
public static saveFile(
text: string,
fileName: string,
type: FileType,
): void {
const mimeType = this.mimeTypes[type];
this.saveBlob(text, mimeType, fileName);
}
private static readonly mimeTypes: Record<FileType, string> = {
// Some browsers (including firefox + IE) require right mime type
// otherwise they ignore extension and save the file as text.
[FileType.BatchFile]: 'application/bat', // https://en.wikipedia.org/wiki/Batch_file
[FileType.ShellScript]: 'text/x-shellscript', // https://de.wikipedia.org/wiki/Shellskript#MIME-Typ
};
private static saveBlob(file: BlobPart, fileType: string, fileName: string): void {
try {
const blob = new Blob([file], { type: fileType });
fileSaver.saveAs(blob, fileName);
} catch (e) {
window.open(`data:${fileType},${encodeURIComponent(file.toString())}`, '_blank', '');
}
}
}

View File

@@ -1,11 +1,13 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Logger } from '@/application/Common/Log/Logger';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
readonly isDesktop: boolean;
readonly isRunningAsDesktopApplication?: true;
readonly codeRunner?: CodeRunner;
readonly os?: OperatingSystem;
readonly log: Logger;
readonly log?: Logger;
readonly dialog?: Dialog;
}

View File

@@ -18,13 +18,14 @@ export function validateWindowVariables(variables: Partial<WindowVariables>) {
}
function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> {
const tests: {
[K in PropertyKeys<Required<WindowVariables>>]: boolean;
} = {
const tests: Record<PropertyKeys<Required<WindowVariables>>, boolean> = {
os: testOperatingSystem(variables.os),
isDesktop: testIsDesktop(variables.isDesktop),
isRunningAsDesktopApplication: testIsRunningAsDesktopApplication(
variables.isRunningAsDesktopApplication,
),
codeRunner: testCodeRunner(variables),
log: testLogger(variables),
dialog: testDialog(variables),
};
for (const [propertyName, testResult] of Object.entries(tests)) {
@@ -48,23 +49,30 @@ function testOperatingSystem(os: unknown): boolean {
}
function testLogger(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.log);
}
function testCodeRunner(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.codeRunner)
&& isFunction(variables.codeRunner.runCode);
}
function testIsDesktop(isDesktop: unknown): boolean {
if (isDesktop === undefined) {
function testIsRunningAsDesktopApplication(isRunningAsDesktopApplication: unknown): boolean {
if (isRunningAsDesktopApplication === undefined) {
return true;
}
return isBoolean(isDesktop);
return isBoolean(isRunningAsDesktopApplication);
}
function testDialog(variables: Partial<WindowVariables>): boolean {
if (!variables.isRunningAsDesktopApplication) {
return true;
}
return isPlainObject(variables.dialog);
}

View File

@@ -11,9 +11,10 @@ import {
} from '@/presentation/injectionSymbols';
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
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';
export function provideDependencies(
context: IApplicationContext,
@@ -67,6 +68,10 @@ export function provideDependencies(
InjectionKeys.useCodeRunner,
useCodeRunner,
),
useDialog: (di) => di.provide(
InjectionKeys.useDialog,
useDialog,
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -1,6 +1,6 @@
import { Logger } from '@/application/Common/Log/Logger';
import { ClientLoggerFactory } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory';
import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements Bootstrapper {
constructor(

View File

@@ -0,0 +1,8 @@
export interface Dialog {
saveFile(fileContents: string, fileName: string, type: FileType): Promise<void>;
}
export enum FileType {
BatchFile,
ShellScript,
}

View File

@@ -19,10 +19,14 @@ export default defineComponent({
},
setup() {
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { os, isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const canRun = computed<boolean>(() => getCanRunState(
currentState.value.os,
isRunningAsDesktopApplication,
os,
));
async function executeCode() {
if (!codeRunner) { throw new Error('missing code runner'); }
@@ -33,7 +37,6 @@ export default defineComponent({
}
return {
isDesktopVersion: isDesktop,
canRun,
executeCode,
};
@@ -42,10 +45,10 @@ export default defineComponent({
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
isRunningAsDesktopApplication: boolean,
hostOs: OperatingSystem | undefined,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
return isRunningAsDesktopApplication && isRunningOnSelectedOs;
}
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
:text="isRunningAsDesktopApplication ? 'Save' : 'Download'"
:icon-name="isRunningAsDesktopApplication ? 'floppy-disk' : 'file-arrow-down'"
@click="saveCode"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
@@ -16,12 +16,11 @@ import {
defineComponent, ref, computed,
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ScriptFileName } from '@/application/CodeRunner/ScriptFileName';
import { FileType } from '@/presentation/common/Dialog';
import IconButton from '../IconButton.vue';
import InstructionList from './Instructions/InstructionList.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
@@ -35,7 +34,8 @@ export default defineComponent({
},
setup() {
const { currentState } = injectKey((keys) => keys.useCollectionState);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const { dialog } = injectKey((keys) => keys.useDialog);
const areInstructionsVisible = ref(false);
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
@@ -44,13 +44,17 @@ export default defineComponent({
fileName.value,
));
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
async function saveCode() {
await dialog.saveFile(
currentState.value.code.current,
fileName.value,
getType(currentState.value.collection.scripting.language),
);
areInstructionsVisible.value = true;
}
return {
isDesktopVersion: isDesktop,
isRunningAsDesktopApplication,
instructions,
fileName,
areInstructionsVisible,
@@ -59,12 +63,6 @@ export default defineComponent({
},
});
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:

View File

@@ -188,6 +188,7 @@ function getDefaultCode(language: ScriptingLanguage): string {
.appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.')
.appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.')
.appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).')
.appendCommentLine(' ✔️ Committed to your safety with strong security measures.')
.toString();
}

View File

@@ -0,0 +1,25 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { Dialog } from '@/presentation/common/Dialog';
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
export function determineDialogBasedOnEnvironment(
environment: RuntimeEnvironment,
windowInjectedDialogFactory: WindowDialogCreationFunction = () => globalThis.window.dialog,
browserDialogFactory: BrowserDialogCreationFunction = () => new BrowserDialog(),
): Dialog {
if (!environment.isRunningAsDesktopApplication) {
return browserDialogFactory();
}
const dialog = windowInjectedDialogFactory();
if (!dialog) {
throw new Error([
'The Dialog API could not be retrieved from the window object.',
'This may indicate that the Dialog API is either not implemented or not correctly exposed in the current desktop environment.',
].join('\n'));
}
return dialog;
}
export type WindowDialogCreationFunction = () => Dialog | undefined;
export type BrowserDialogCreationFunction = () => Dialog;

View File

@@ -0,0 +1,14 @@
import { Dialog } from '@/presentation/common/Dialog';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { determineDialogBasedOnEnvironment } from './ClientDialogFactory';
export function useDialog(
factory: DialogFactory = () => determineDialogBasedOnEnvironment(CurrentEnvironment),
) {
const dialog = factory();
return {
dialog,
};
}
export type DialogFactory = () => Dialog;

View File

@@ -1,10 +1,10 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { LoggerFactory } from './LoggerFactory';
export class ClientLoggerFactory implements LoggerFactory {
public static readonly Current: LoggerFactory = new ClientLoggerFactory();
@@ -22,7 +22,7 @@ export class ClientLoggerFactory implements LoggerFactory {
this.logger = noopLoggerFactory(); // keep the test outputs clean
return;
}
if (environment.isDesktop) {
if (environment.isRunningAsDesktopApplication) {
this.logger = windowInjectedLoggerFactory();
return;
}
@@ -49,5 +49,5 @@ function isUnitOrIntegrationTests(
the global window object. If we're in a desktop (Node) environment and the logger isn't
injected, it indicates a testing environment.
*/
return environment.isDesktop && !windowAccessor()?.log;
return environment.isRunningAsDesktopApplication && !windowAccessor()?.log;
}

View File

@@ -0,0 +1,8 @@
import { ClientLoggerFactory } from './ClientLoggerFactory';
import { LoggerFactory } from './LoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -1,8 +0,0 @@
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -1,12 +1,12 @@
<template>
<div class="privacy-policy">
<div v-if="!isDesktop" class="line">
<div v-if="!isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
🚫🍪
</div>
<div>No cookies!</div>
</div>
<div v-if="isDesktop" class="line">
<div v-if="isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
🚫🌐
</div>
@@ -30,7 +30,7 @@
of the <a :href="repositoryUrl" target="_blank" rel="noopener noreferrer">source code</a> with no changes.
</div>
</div>
<div v-if="!isDesktop" class="line">
<div v-if="!isRunningAsDesktopApplication" class="line">
<div class="line__emoji">
📈
</div>
@@ -60,7 +60,7 @@ import { injectKey } from '@/presentation/injectionSymbols';
export default defineComponent({
setup() {
const { info } = injectKey((keys) => keys.useApplication);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const repositoryUrl = computed<string>(() => info.repositoryUrl);
const feedbackUrl = computed<string>(() => info.feedbackUrl);
@@ -68,7 +68,7 @@ export default defineComponent({
return {
repositoryUrl,
feedbackUrl,
isDesktop,
isRunningAsDesktopApplication,
};
},
});

View File

@@ -2,7 +2,7 @@
<div>
<div class="footer">
<div class="footer__section">
<span v-if="isDesktop" class="footer__section__item">
<span v-if="isRunningAsDesktopApplication" class="footer__section__item">
<AppIcon class="icon" icon="globe" />
<span>
Online version at <a :href="homepageUrl" target="_blank" rel="noopener noreferrer">{{ homepageUrl }}</a>
@@ -68,7 +68,7 @@ export default defineComponent({
},
setup() {
const { info } = injectKey((keys) => keys.useApplication);
const { isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
const isPrivacyDialogVisible = ref(false);
@@ -87,7 +87,7 @@ export default defineComponent({
}
return {
isDesktop,
isRunningAsDesktopApplication,
isPrivacyDialogVisible,
showPrivacyDialog,
version,

View File

@@ -1,24 +1,37 @@
import { ScriptFileCodeRunner } from '@/infrastructure/CodeRunner/ScriptFileCodeRunner';
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 { registerIpcChannel } from '../shared/IpcBridging/IpcProxy';
import { IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions';
import { ChannelDefinitionKey, IpcChannelDefinitions } from '../shared/IpcBridging/IpcChannelDefinitions';
export function registerAllIpcChannels(
createCodeRunner: CodeRunnerFactory = () => new ScriptFileCodeRunner(),
registrar: IpcRegistrar = registerIpcChannel,
createDialog: DialogFactory = () => new ElectronDialog(),
registrar: IpcChannelRegistrar = registerIpcChannel,
) {
const registrars: Record<keyof typeof IpcChannelDefinitions, () => void> = {
CodeRunner: () => registrar(IpcChannelDefinitions.CodeRunner, createCodeRunner()),
const ipcInstanceCreators: IpcChannelRegistrars = {
CodeRunner: () => createCodeRunner(),
Dialog: () => createDialog(),
};
Object.entries(registrars).forEach(([name, register]) => {
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
try {
register();
const definition = IpcChannelDefinitions[name];
const instance = instanceFactory();
registrar(definition, instance);
} catch (err) {
throw new AggregateError(`main: Failed to register IPC channel "${name}"`, err);
throw new AggregateError([err], `main: Failed to register IPC channel "${name}":\n${err.message}`);
}
});
}
export type CodeRunnerFactory = () => CodeRunner;
export type DialogFactory = () => Dialog;
export type IpcChannelRegistrar = typeof registerIpcChannel;
export type IpcRegistrar = typeof registerIpcChannel;
type RegistrationChannel<T extends ChannelDefinitionKey> = (typeof IpcChannelDefinitions)[T];
type ExtractChannelServiceType<T> = T extends IpcChannel<infer U> ? U : never;
type IpcChannelRegistrars = {
[K in ChannelDefinitionKey]: () => ExtractChannelServiceType<RegistrationChannel<K>>;
};

View File

@@ -12,14 +12,20 @@ export function provideWindowVariables(
createApiFacade: ApiFacadeFactory = createSecureFacade,
ipcConsumerCreator: IpcConsumerProxyCreator = createIpcConsumerProxy,
): WindowVariables {
return {
isDesktop: true,
// Enforces mandatory variable availability at compile time
const variables: RequiredWindowVariables = {
isRunningAsDesktopApplication: true,
log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']),
os: convertToOs(process.platform),
codeRunner: ipcConsumerCreator(IpcChannelDefinitions.CodeRunner),
dialog: ipcConsumerCreator(IpcChannelDefinitions.Dialog),
};
return variables;
}
type RequiredWindowVariables = PartiallyRequired<WindowVariables, 'os' /* | 'anotherOptionalKey'.. */>;
type PartiallyRequired<T, K extends keyof T> = Required<Omit<T, K>> & Pick<T, K>;
export type LoggerFactory = () => Logger;
export type ApiFacadeFactory = typeof createSecureFacade;

View File

@@ -1,11 +1,15 @@
import { FunctionKeys } from '@/TypeHelpers';
import { CodeRunner } from '@/application/CodeRunner/CodeRunner';
import { Dialog } from '@/presentation/common/Dialog';
import { IpcChannel } from './IpcChannel';
export const IpcChannelDefinitions = {
CodeRunner: defineElectronIpcChannel<CodeRunner>('code-run', ['runCode']),
Dialog: defineElectronIpcChannel<Dialog>('dialogs', ['saveFile']),
} as const;
export type ChannelDefinitionKey = keyof typeof IpcChannelDefinitions;
function defineElectronIpcChannel<T>(
name: string,
functionNames: readonly FunctionKeys<T>[],

View File

@@ -1,6 +1,6 @@
import { ipcMain } from 'electron/main';
import { ipcRenderer } from 'electron/renderer';
import { isFunction } from '@/TypeHelpers';
import { FunctionKeys, isFunction } from '@/TypeHelpers';
import { IpcChannel } from './IpcChannel';
export function createIpcConsumerProxy<T>(
@@ -25,9 +25,7 @@ export function registerIpcChannel<T>(
) {
channel.accessibleMembers.forEach((functionKey) => {
const originalFunction = originalObject[functionKey];
if (!isFunction(originalFunction)) {
throw new Error('Non-function members are not yet supported');
}
validateIpcFunction(functionKey, originalFunction, channel);
const ipcChannel = getIpcChannelIdentifier(channel.namespace, functionKey as string);
electronIpcMain.handle(ipcChannel, (_event, ...args: unknown[]) => {
return originalFunction.apply(originalObject, args);
@@ -35,6 +33,28 @@ export function registerIpcChannel<T>(
});
}
function validateIpcFunction<T>(
functionKey: FunctionKeys<T>,
functionValue: T[FunctionKeys<T>],
channel: IpcChannel<T>,
): asserts functionValue is T[FunctionKeys<T>] & ((...args: unknown[]) => unknown) {
const functionName = functionKey.toString();
if (functionValue === undefined) {
throwErrorWithContext(`The function "${functionName}" is not found on the target object.`);
}
if (!isFunction(functionValue)) {
throwErrorWithContext('Non-function members are not yet supported.');
}
function throwErrorWithContext(message: string): never {
throw new Error([
message,
`Channel: ${JSON.stringify(channel)}.`,
`Function key: ${functionName}.`,
`Value: ${JSON.stringify(functionValue)}`,
].join('\n'));
}
}
function getIpcChannelIdentifier(namespace: string, key: string) {
return `proxy:${namespace}:${key}`;
}

View File

@@ -6,8 +6,9 @@ import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipbo
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
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';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -19,6 +20,7 @@ export const InjectionKeys = {
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
useDialog: defineTransientKey<ReturnType<typeof useDialog>>('useDialog'),
};
export interface InjectionKeyWithLifetime<T> {

View File

@@ -17,6 +17,7 @@ describe('ScriptFileCodeRunner', () => {
const currentOperatingSystem = CurrentEnvironment.os;
if (await shouldSkipTest(currentOperatingSystem)) {
skip();
return;
}
const temporaryDirectoryProvider = createTemporaryDirectoryProvider();
const codeRunner = createCodeRunner(temporaryDirectoryProvider);
@@ -61,10 +62,14 @@ function getPlatformSpecificArguments(
function shouldSkipTest(
os: OperatingSystem | undefined,
): Promise<boolean> {
if (os !== OperatingSystem.Linux) {
return Promise.resolve(false);
}
return isLinuxTerminalEmulatorSupported();
}
function isLinuxTerminalEmulatorSupported(): Promise<boolean> {
return new Promise((resolve) => {
if (os !== OperatingSystem.Linux) {
resolve(false);
}
exec(`which ${LinuxTerminalEmulator}`).on('close', (exitCode) => {
resolve(exitCode !== 0);
});

View File

@@ -39,6 +39,8 @@ export const BrowserOsTestCases: ReadonlyArray<BrowserOsTestCase> = [
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 UBrowser/6.0.1308.1016 Safari/537.36',
// Electron:
'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',
// jsdom:
'Mozilla/5.0 (Windows) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/22.1.0',
],
}),
...createTests({
@@ -60,6 +62,8 @@ export const BrowserOsTestCases: ReadonlyArray<BrowserOsTestCase> = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0',
// Electron:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.54 Electron/27.0.0 Safari/537.36',
// jsdom:
'Mozilla/5.0 (darwin) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/22.1.0',
],
}),
...createTests({
@@ -74,6 +78,8 @@ export const BrowserOsTestCases: ReadonlyArray<BrowserOsTestCase> = [
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188',
// Electron:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.5993.54 Electron/27.0.0 Safari/537.36',
// jsdom:
'Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/22.1.0',
],
}),
...createTests({

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
describe('RuntimeEnvironmentFactory', () => {
describe('CurrentEnvironment', () => {
it('identifies as browser in test environment', () => { // Ensures test independence from Electron IPC
// arrange
const expectedDesktopAppState = false;
// act
const isRunningAsDesktop = CurrentEnvironment.isRunningAsDesktopApplication;
// assert
expect(isRunningAsDesktop).to.equal(expectedDesktopAppState);
});
it('identifies as non-production in test environment', () => {
// arrange
const expectedNonProductionState = true;
// act
const isNonProductionEnvironment = CurrentEnvironment.isNonProduction;
// assert
expect(isNonProductionEnvironment).to.equal(expectedNonProductionState);
});
});
});

View File

@@ -29,7 +29,7 @@ describe('MobileSafariActivePseudoClassEnabler', () => {
`User agent\t\t: ${navigator.userAgent}`,
`Touch supported\t\t: ${supportsTouch}`,
`Current OS\t\t: ${patchedEnvironment.os === undefined ? 'unknown' : OperatingSystem[patchedEnvironment.os]}`,
`Is desktop?\t\t: ${patchedEnvironment.isDesktop ? 'Yes (Desktop app)' : 'No (Browser)'}`,
`Is desktop?\t\t: ${patchedEnvironment.isRunningAsDesktopApplication ? 'Yes (Desktop app)' : 'No (Browser)'}`,
`Listeners (${currentListeners.length})\t\t: ${JSON.stringify(currentListeners)}`,
]));
});

View File

@@ -0,0 +1,7 @@
import { Blob as BlobPolyfill } from 'node:buffer';
export function polyfillBlob() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
global.Blob = BlobPolyfill as any;
// Workaround as `blob.text()` is not available in jsdom (https://github.com/jsdom/jsdom/issues/2555)
}

View File

@@ -1,4 +1,6 @@
import { afterEach } from 'vitest';
import { enableAutoUnmount } from '@vue/test-utils';
import { polyfillBlob } from './BlobPolyfill';
enableAutoUnmount(afterEach);
polyfillBlob();

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { FileType } from '@/presentation/common/Dialog';
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
import { BrowserSaveFileDialog } from '@/infrastructure/Dialog/Browser/BrowserSaveFileDialog';
describe('BrowserDialog', () => {
describe('saveFile', () => {
it('passes correct arguments', () => {
// arrange
const expectedFileContents = 'test content';
const expectedFileName = 'test.sh';
const expectedFileType = FileType.ShellScript;
let actualSaveFileArgs: Parameters<BrowserSaveFileDialog['saveFile']> | undefined;
const fileSaverDialogSpy: BrowserSaveFileDialog = {
saveFile: (...args) => {
actualSaveFileArgs = args;
},
};
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
// act
browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
// assert
expect(actualSaveFileArgs)
.to
.deep
.equal([expectedFileContents, expectedFileName, expectedFileType]);
});
});
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { FileSaverDialog, SaveAsFunction, WindowOpenFunction } from '@/infrastructure/Dialog/Browser/FileSaverDialog';
import { FileType } from '@/presentation/common/Dialog';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('FileSaverDialog', () => {
describe('saveFile', () => {
describe('saving file with correct mime type', () => {
const testCases: ReadonlyArray<{
readonly fileType: FileType;
readonly expectedMimeType: string;
}> = [
{ fileType: FileType.BatchFile, expectedMimeType: 'application/bat' },
{ fileType: FileType.ShellScript, expectedMimeType: 'text/x-shellscript' },
];
testCases.forEach(({ fileType, expectedMimeType }) => {
it(`correct mimeType for ${FileType[fileType]}`, () => {
// arrange
let actualMimeType: string | undefined;
const saveAsSpy: SaveAsFunction = (blob) => {
actualMimeType = blob.type;
};
// act
new SaveFileTestSetup()
.withFileType(fileType)
.withSaveAs(saveAsSpy)
.saveFile();
// assert
expect(actualMimeType).to.equal(expectedMimeType);
});
});
});
it('blob contains correct file contents', async () => {
// arrange
const expectedFileContents = 'expected file contents';
let actualBlob: Blob | undefined;
const saveAsSpy: SaveAsFunction = (blob) => {
actualBlob = blob;
};
// act
new SaveFileTestSetup()
.withSaveAs(saveAsSpy)
.withFileContents(expectedFileContents)
.saveFile();
// assert
expectExists(actualBlob);
const actualFileContents = await actualBlob.text();
expect(actualFileContents).to.equal(expectedFileContents);
});
it('opens new window on save failure', () => {
// arrange
const fileContents = 'test file contents';
const failingSaveAs: SaveAsFunction = () => {
throw new Error('injected fail');
};
let calledArgs: Parameters<WindowOpenFunction> | undefined;
const windowOpenSpy: WindowOpenFunction = (...args) => {
calledArgs = args;
};
// act
new SaveFileTestSetup()
.withSaveAs(failingSaveAs)
.withFileType(FileType.BatchFile)
.withFileContents(fileContents)
.withWindowOpen(windowOpenSpy)
.saveFile();
// assert
expectExists(calledArgs);
const [url, target, features] = calledArgs;
const mimeType = 'application/bat';
expect(url).to.equal(`data:${mimeType},${encodeURIComponent(fileContents)}`);
expect(target).to.equal('_blank');
expect(features).to.equal('');
});
});
});
class SaveFileTestSetup {
private saveAs: SaveAsFunction = () => {};
private windowOpen: WindowOpenFunction = () => {};
private fileContents: string = `${SaveFileTestSetup.name} file contents`;
private fileName: string = `${SaveFileTestSetup.name} file name`;
private fileType: FileType = FileType.BatchFile;
public withSaveAs(saveAs: SaveAsFunction): this {
this.saveAs = saveAs;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public withFileType(fileType: FileType): this {
this.fileType = fileType;
return this;
}
public withWindowOpen(windowOpen: WindowOpenFunction): this {
this.windowOpen = windowOpen;
return this;
}
public saveFile() {
const dialog = new FileSaverDialog(
this.saveAs,
this.windowOpen,
);
return dialog.saveFile(
this.fileContents,
this.fileName,
this.fileType,
);
}
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { FileType } from '@/presentation/common/Dialog';
import { BrowserDialog } from '@/infrastructure/Dialog/Browser/BrowserDialog';
import { ElectronSaveFileDialog } from '@/infrastructure/Dialog/Electron/ElectronSaveFileDialog';
describe('BrowserDialog', () => {
describe('saveFile', () => {
it('passes correct arguments', async () => {
// arrange
const expectedFileContents = 'test content';
const expectedFileName = 'test.sh';
const expectedFileType = FileType.ShellScript;
let actualSaveFileArgs: Parameters<ElectronSaveFileDialog['saveFile']> | undefined;
const fileSaverDialogSpy: ElectronSaveFileDialog = {
saveFile: (...args) => {
actualSaveFileArgs = args;
return Promise.resolve();
},
};
const browserDialog = new BrowserDialog(fileSaverDialogSpy);
// act
await browserDialog.saveFile(expectedFileContents, expectedFileName, expectedFileType);
// assert
expect(actualSaveFileArgs)
.to
.deep
.equal([expectedFileContents, expectedFileName, expectedFileType]);
});
});
});

View File

@@ -0,0 +1,45 @@
import { ElectronFileDialogOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
export class ElectronFileDialogOperationsStub
extends StubWithObservableMethodCalls<ElectronFileDialogOperations>
implements ElectronFileDialogOperations {
private mimicUserCancel = false;
private userDownloadsPath = `[${ElectronFileDialogOperationsStub.name}] downloads path`;
private userSelectedFilePath = `${ElectronFileDialogOperationsStub.name} user selected file path`;
public withMimicUserCancel(isCancelled: boolean): this {
this.mimicUserCancel = isCancelled;
return this;
}
public withUserDownloadsPath(userDownloadsPath: string): this {
this.userDownloadsPath = userDownloadsPath;
return this;
}
public withUserSelectedFilePath(userSelectedFilePath: string): this {
this.userSelectedFilePath = userSelectedFilePath;
return this;
}
public getUserDownloadsPath(): string {
return this.userDownloadsPath;
}
public showSaveDialog(
options: Electron.SaveDialogOptions,
): Promise<Electron.SaveDialogReturnValue> {
this.registerMethodCall({
methodName: 'showSaveDialog',
args: [options],
});
const returnValue: Electron.SaveDialogReturnValue = {
canceled: this.mimicUserCancel,
filePath: this.userSelectedFilePath,
};
return Promise.resolve(returnValue);
}
}

View File

@@ -0,0 +1,268 @@
import { describe, it, expect } from 'vitest';
import { FileType } from '@/presentation/common/Dialog';
import { ElectronFileDialogOperations, NodeElectronSaveFileDialog, NodeFileOperations } from '@/infrastructure/Dialog/Electron/NodeElectronSaveFileDialog';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { ElectronFileDialogOperationsStub } from './ElectronFileDialogOperationsStub';
import { NodeFileOperationsStub } from './NodeFileOperationsStub';
describe('NodeElectronSaveFileDialog', () => {
describe('shows dialog with correct options', () => {
it('correct title', async () => {
// arrange
const expectedFileName = 'expected-file-name';
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileName(expectedFileName);
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(expectedFileName, (opts) => opts.title, electronMock);
});
it('correct properties', async () => {
// arrange
const expectedProperties = [
'createDirectory',
'showOverwriteConfirmation',
];
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock);
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(
expectedProperties,
(opts) => opts.properties,
electronMock,
);
});
it('correct default path', async () => {
// arrange
const pathSegmentSeparator = '_{test-separator}_';
const expectedFileName = 'expected-file-name';
const expectedParentDirectory = 'expected-downloads-path';
const expectedFilePath = [
expectedParentDirectory,
expectedFileName,
].join(pathSegmentSeparator);
const electronMock = new ElectronFileDialogOperationsStub()
.withUserDownloadsPath(expectedParentDirectory);
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileName(expectedFileName)
.withNode(new NodeFileOperationsStub().withPathSegmentSeparator(pathSegmentSeparator));
// act
await context.saveFile();
// assert
assertDialogOptionMatchesExpectedValue(
expectedFilePath,
(opts) => opts.defaultPath,
electronMock,
);
});
describe('correct filters', () => {
const defaultFilter: Electron.FileFilter = {
name: 'All Files',
extensions: ['*'],
};
const testScenarios: Record<FileType, Electron.FileFilter[]> = {
[FileType.BatchFile]: [
defaultFilter,
{
name: 'Batch Files',
extensions: ['bat', 'cmd'],
},
],
[FileType.ShellScript]: [
defaultFilter,
{
name: 'Shell Scripts',
extensions: ['sh', 'bash', 'zsh'],
},
],
};
Object.entries(testScenarios).forEach(([fileTypeKey, expectedFilters]) => {
const fileType = Number(fileTypeKey) as FileType;
it(`applies correct filters for ${FileType[fileType]}`, async () => {
// arrange
const electronMock = new ElectronFileDialogOperationsStub();
const context = new SaveFileDialogTestSetup()
.withFileType(fileType)
.withElectron(electronMock);
// act
await context.saveFile();
// assert
const sortFilters = (
filters: Electron.FileFilter[],
) => filters.sort((a, b) => a.name.localeCompare(b.name));
const expectedSortedFilters = sortFilters(expectedFilters);
assertDialogOptionMatchesExpectedValue(
expectedSortedFilters,
(opts) => sortFilters(opts.filters ?? []),
electronMock,
);
});
});
});
});
describe('saves the file when the dialog is not canceled', () => {
it('writes to the selected file path', async () => {
// arrange
const expectedFilePath = 'expected-file-path';
const isCancelled = false;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled)
.withUserSelectedFilePath(expectedFilePath);
const nodeMock = new NodeFileOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withNode(nodeMock);
// act
await context.saveFile();
// assert
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
expect(saveFileCalls).to.have.lengthOf(1);
const [actualFilePath] = saveFileCalls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
});
it('writes the correct file contents', async () => {
// arrange
const expectedFileContents = 'expected-file-contents';
const isCancelled = false;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const nodeMock = new NodeFileOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withFileContents(expectedFileContents)
.withNode(nodeMock);
// act
await context.saveFile();
// assert
const saveFileCalls = nodeMock.callHistory.filter((c) => c.methodName === 'writeFile');
expect(saveFileCalls).to.have.lengthOf(1);
const [,actualFileContents] = saveFileCalls[0].args;
expect(actualFileContents).to.equal(expectedFileContents);
});
});
it('does not save file when dialog is canceled', async () => {
// arrange
const isCancelled = true;
const electronMock = new ElectronFileDialogOperationsStub()
.withMimicUserCancel(isCancelled);
const nodeMock = new NodeFileOperationsStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withNode(nodeMock);
// act
await context.saveFile();
// assert
const saveFileCall = nodeMock.callHistory.find((c) => c.methodName === 'writeFile');
expect(saveFileCall).to.equal(undefined);
});
describe('logging', () => {
it('logs an error if writing the file fails', async () => {
// arrange
const expectedErrorMessage = 'Injected write error';
const electronMock = new ElectronFileDialogOperationsStub().withMimicUserCancel(false);
const nodeMock = new NodeFileOperationsStub();
nodeMock.writeFile = () => Promise.reject(new Error(expectedErrorMessage));
const loggerStub = new LoggerStub();
const context = new SaveFileDialogTestSetup()
.withElectron(electronMock)
.withNode(nodeMock)
.withLogger(loggerStub);
// act
await context.saveFile();
// assert
const errorCalls = loggerStub.callHistory.filter((c) => c.methodName === 'error');
expect(errorCalls.length).to.equal(1);
const errorCall = errorCalls[0];
const [errorMessage] = errorCall.args;
expect(errorMessage).to.include(expectedErrorMessage);
});
});
});
class SaveFileDialogTestSetup {
private fileContents = `${SaveFileDialogTestSetup.name} file contents`;
private fileName = `${SaveFileDialogTestSetup.name} file name`;
private fileType = FileType.BatchFile;
private logger: Logger = new LoggerStub();
private electron: ElectronFileDialogOperations = new ElectronFileDialogOperationsStub();
private node: NodeFileOperations = new NodeFileOperationsStub();
public withElectron(electron: ElectronFileDialogOperations): this {
this.electron = electron;
return this;
}
public withNode(node: NodeFileOperations): this {
this.node = node;
return this;
}
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
public withFileName(fileName: string): this {
this.fileName = fileName;
return this;
}
public withFileContents(fileContents: string): this {
this.fileContents = fileContents;
return this;
}
public withFileType(fileType: FileType): this {
this.fileType = fileType;
return this;
}
public saveFile() {
const dialog = new NodeElectronSaveFileDialog(this.logger, this.electron, this.node);
return dialog.saveFile(
this.fileContents,
this.fileName,
this.fileType,
);
}
}
function assertDialogOptionMatchesExpectedValue<T>(
expectedValue: T,
getActualOption: (opts: Electron.SaveDialogOptions) => T | undefined,
electronMock: ElectronFileDialogOperationsStub,
): void {
const showDialogCalls = electronMock.callHistory.filter((c) => c.methodName === 'showSaveDialog');
expect(showDialogCalls).to.have.lengthOf(1);
const showDialogCall = showDialogCalls[0];
expectExists(showDialogCall);
const [options] = showDialogCall.args;
expectExists(options);
const actualValue = getActualOption(options);
expect(actualValue).to.deep.equal(expectedValue);
}

View File

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

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line max-classes-per-file
import { describe, it, expect } from 'vitest';
import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/Browser/BrowserOs/BrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
@@ -42,31 +41,32 @@ describe('BrowserRuntimeEnvironment', () => {
expect(actualTouchSupport).to.equal(expectedTouchSupport);
});
});
describe('isDesktop', () => {
it('returns true when window property isDesktop is true', () => {
describe('isRunningAsDesktopApplication', () => {
it('returns true when window property `isRunningAsDesktopApplication` is true', () => {
// arrange
const desktopWindow = {
isDesktop: true,
const expectedValue = true;
const desktopWindow: Partial<Window> = {
isRunningAsDesktopApplication: true,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(desktopWindow)
.build();
// assert
expect(sut.isDesktop).to.equal(true);
expect(sut.isRunningAsDesktopApplication).to.equal(expectedValue);
});
it('returns false when window property isDesktop is false', () => {
it('returns false when window property `isRunningAsDesktopApplication` is undefined', () => {
// arrange
const expectedValue = false;
const browserWindow = {
isDesktop: false,
const browserWindow: Partial<Window> = {
isRunningAsDesktopApplication: undefined,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(browserWindow)
.build();
// assert
expect(sut.isDesktop).to.equal(expectedValue);
expect(sut.isRunningAsDesktopApplication).to.equal(expectedValue);
});
});
describe('os', () => {
@@ -112,47 +112,6 @@ describe('BrowserRuntimeEnvironment', () => {
// assert
expect(actual).to.equal(expected);
});
describe('desktop os', () => {
describe('returns from window property `os`', () => {
const testValues = [
OperatingSystem.macOS,
OperatingSystem.Windows,
OperatingSystem.Linux,
];
testValues.forEach((testValue) => {
it(`given ${OperatingSystem[testValue]}`, () => {
// arrange
const expectedOs = testValue;
const desktopWindowWithOs = {
isDesktop: true,
os: expectedOs,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(desktopWindowWithOs)
.build();
// assert
const actualOs = sut.os;
expect(actualOs).to.equal(expectedOs);
});
});
});
describe('returns undefined when window property `os` is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedValue = undefined;
const windowWithAbsentOs = {
os: absentValue as never,
};
// act
const sut = new BrowserRuntimeEnvironmentBuilder()
.withWindow(windowWithAbsentOs)
.build();
// assert
expect(sut.os).to.equal(expectedValue);
});
});
});
});
describe('isNonProduction', () => {
[true, false].forEach((value) => {

View File

@@ -62,14 +62,14 @@ describe('NodeRuntimeEnvironment', () => {
expect(environment.os).to.equal(expectedOs);
});
});
describe('isDesktop', () => {
describe('isRunningAsDesktopApplication', () => {
it('is always true', () => {
// arrange
const expectedDesktopCondition = true;
// act
const environment = new NodeRuntimeEnvironment();
/// assert
expect(environment.isDesktop).to.equal(expectedDesktopCondition);
expect(environment.isRunningAsDesktopApplication).to.equal(expectedDesktopCondition);
});
});
describe('isNonProduction', () => {

View File

@@ -1,73 +1,95 @@
import { describe, it, expect } from 'vitest';
import {
BrowserRuntimeEnvironmentFactory, NodeRuntimeEnvironmentFactory,
GlobalAccessor as GlobalPropertiesAccessor, determineAndCreateRuntimeEnvironment,
BrowserRuntimeEnvironmentFactory, GlobalPropertiesAccessor, NodeRuntimeEnvironmentFactory,
determineAndCreateRuntimeEnvironment,
} from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
describe('RuntimeEnvironmentFactory', () => {
describe('determineAndCreateRuntimeEnvironment', () => {
it('uses browser environment when window exists', () => {
// arrange
const expectedEnvironment = new RuntimeEnvironmentStub();
const context = new RuntimeEnvironmentFactoryTestSetup()
.withWindowAccessor(() => createWindowStub())
.withBrowserEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
expect(actualEnvironment).to.equal(expectedEnvironment);
describe('Node environment creation', () => {
it('selects Node environment if Electron main process detected', () => {
// arrange
const processStub = createProcessStub({
versions: {
electron: '28.1.3',
} as NodeJS.ProcessVersions,
});
const expectedEnvironment = new RuntimeEnvironmentStub();
const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(processStub)
.withNodeEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
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);
});
});
it('passes correct window to browser environment', () => {
// arrange
const expectedWindow = createWindowStub();
let actualWindow: Window | undefined;
const browserEnvironmentFactoryMock: BrowserRuntimeEnvironmentFactory = (providedWindow) => {
actualWindow = providedWindow;
return new RuntimeEnvironmentStub();
};
const context = new RuntimeEnvironmentFactoryTestSetup()
.withWindowAccessor(() => expectedWindow)
.withBrowserEnvironmentFactory(browserEnvironmentFactoryMock);
// act
context.buildEnvironment();
// assert
expect(actualWindow).to.equal(expectedWindow);
describe('browser environment creation', () => {
it('selects browser environment if Electron main process not detected', () => {
// arrange
const expectedEnvironment = new RuntimeEnvironmentStub();
const undefinedProcess: GlobalProcess = undefined;
const windowStub = createWindowStub();
const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalProcess(undefinedProcess)
.withGlobalWindow(windowStub)
.withBrowserEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
expect(actualEnvironment).to.equal(expectedEnvironment);
});
it('passes correct window to browser environment factory', () => {
// arrange
const expectedWindow = createWindowStub({
isRunningAsDesktopApplication: undefined,
});
let actualWindow: GlobalWindow;
const browserEnvironmentFactoryMock
: BrowserRuntimeEnvironmentFactory = (providedWindow) => {
actualWindow = providedWindow;
return new RuntimeEnvironmentStub();
};
const undefinedProcess: GlobalProcess = undefined;
const context = new RuntimeEnvironmentFactoryTestSetup()
.withGlobalWindow(expectedWindow)
.withGlobalProcess(undefinedProcess)
.withBrowserEnvironmentFactory(browserEnvironmentFactoryMock);
// act
context.buildEnvironment();
// assert
expect(actualWindow).to.equal(expectedWindow);
});
});
it('uses node environment when window is absent', () => {
it('throws error when both window and process are undefined', () => {
// arrange
const expectedEnvironment = new RuntimeEnvironmentStub();
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 context = new RuntimeEnvironmentFactoryTestSetup()
.withWindowAccessor(() => undefined)
.withProcessAccessor(() => createProcessStub())
.withNodeEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
expect(actualEnvironment).to.equal(expectedEnvironment);
});
it('uses node environment when window is present too', () => { // This allows running integration tests
// arrange
const expectedEnvironment = new RuntimeEnvironmentStub();
const context = new RuntimeEnvironmentFactoryTestSetup()
.withWindowAccessor(() => createWindowStub())
.withProcessAccessor(() => createProcessStub())
.withNodeEnvironmentFactory(() => expectedEnvironment);
// act
const actualEnvironment = context.buildEnvironment();
// assert
expect(actualEnvironment).to.equal(expectedEnvironment);
});
it('throws if both node and window are missing', () => {
// arrange
const expectedError = 'Unsupported runtime environment: The current context is neither a recognized browser nor a Node.js environment.';
const context = new RuntimeEnvironmentFactoryTestSetup()
.withWindowAccessor(() => undefined)
.withProcessAccessor(() => undefined);
.withGlobalProcess(undefinedProcess)
.withGlobalWindow(undefinedWindow);
// act
const act = () => context.buildEnvironment();
// assert
@@ -76,22 +98,22 @@ describe('RuntimeEnvironmentFactory', () => {
});
});
function createWindowStub(): Window {
return {} as Window;
function createWindowStub(partialWindowProperties?: Partial<Window>): Window {
return {
...partialWindowProperties,
} as Window;
}
function createProcessStub(): NodeJS.Process {
return {} as NodeJS.Process;
function createProcessStub(partialProcessProperties?: Partial<NodeJS.Process>): NodeJS.Process {
return {
...partialProcessProperties,
} as NodeJS.Process;
}
type WindowAccessor = GlobalPropertiesAccessor['getGlobalWindow'];
type ProcessAccessor = GlobalPropertiesAccessor['getGlobalProcess'];
export class RuntimeEnvironmentFactoryTestSetup {
private windowAccessor: WindowAccessor = () => undefined;
private globalWindow: GlobalWindow = createWindowStub();
private processAccessor: ProcessAccessor = () => undefined;
private globalProcess: GlobalProcess = createProcessStub();
private browserEnvironmentFactory
: BrowserRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub();
@@ -99,13 +121,13 @@ export class RuntimeEnvironmentFactoryTestSetup {
private nodeEnvironmentFactory
: NodeRuntimeEnvironmentFactory = () => new RuntimeEnvironmentStub();
public withWindowAccessor(windowAccessor: WindowAccessor): this {
this.windowAccessor = windowAccessor;
public withGlobalWindow(globalWindow: GlobalWindow): this {
this.globalWindow = globalWindow;
return this;
}
public withProcessAccessor(processAccessor: ProcessAccessor): this {
this.processAccessor = processAccessor;
public withGlobalProcess(globalProcess: GlobalProcess): this {
this.globalProcess = globalProcess;
return this;
}
@@ -126,11 +148,15 @@ export class RuntimeEnvironmentFactoryTestSetup {
public buildEnvironment(): ReturnType<typeof determineAndCreateRuntimeEnvironment> {
return determineAndCreateRuntimeEnvironment(
{
getGlobalProcess: this.processAccessor,
getGlobalWindow: this.windowAccessor,
window: this.globalWindow,
process: this.globalProcess,
},
this.browserEnvironmentFactory,
this.nodeEnvironmentFactory,
);
}
}
type GlobalWindow = GlobalPropertiesAccessor['window'];
type GlobalProcess = GlobalPropertiesAccessor['process'];

View File

@@ -5,6 +5,9 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { getAbsentObjectTestCases, itEachAbsentObjectValue } 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';
describe('WindowVariablesValidator', () => {
describe('validateWindowVariables', () => {
@@ -24,20 +27,20 @@ describe('WindowVariablesValidator', () => {
it('throws an error with a description of all invalid properties', () => {
// arrange
const invalidOs = 'invalid' as unknown as OperatingSystem;
const invalidIsDesktop = 'not a boolean' as unknown as boolean;
const invalidIsRunningAsDesktopApplication = 'not a boolean' as never;
const expectedError = getExpectedError(
{
name: 'os',
object: invalidOs,
},
{
name: 'isDesktop',
object: invalidIsDesktop,
name: 'isRunningAsDesktopApplication',
object: invalidIsRunningAsDesktopApplication,
},
);
const input = new WindowVariablesStub()
.withOs(invalidOs)
.withIsDesktop(invalidIsDesktop);
.withIsRunningAsDesktopApplication(invalidIsRunningAsDesktopApplication);
// act
const act = () => validateWindowVariables(input);
// assert
@@ -82,7 +85,7 @@ describe('WindowVariablesValidator', () => {
it('does not throw for a missing os value', () => {
// arrange
const input = new WindowVariablesStub()
.withIsDesktop(true)
.withIsRunningAsDesktopApplication(true)
.withOs(undefined);
// act
const act = () => validateWindowVariables(input);
@@ -91,15 +94,24 @@ describe('WindowVariablesValidator', () => {
});
});
describe('`isDesktop` property', () => {
describe('`isRunningAsDesktopApplication` property', () => {
it('does not throw when true with valid services', () => {
// arrange
const validCodeRunner = new CodeRunnerStub();
const input = new WindowVariablesStub()
.withIsDesktop(true)
.withCodeRunner(validCodeRunner);
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(input);
const act = () => validateWindowVariables(windowVariables);
// assert
expect(act).to.not.throw();
});
@@ -109,7 +121,7 @@ describe('WindowVariablesValidator', () => {
// arrange
const absentCodeRunner = absentValue;
const input = new WindowVariablesStub()
.withIsDesktop(false)
.withIsRunningAsDesktopApplication(undefined)
.withCodeRunner(absentCodeRunner);
// act
const act = () => validateWindowVariables(input);
@@ -150,7 +162,7 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
});
const input: WindowVariables = {
...new WindowVariablesStub(),
isDesktop: isOnDesktop,
isRunningAsDesktopApplication: isOnDesktop,
[key]: invalidObject,
};
// act
@@ -159,14 +171,13 @@ function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
expect(act).to.throw(expectedError);
});
});
describe('does not object type when not on desktop', () => {
describe('does not validate object type when not on desktop', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const isOnDesktop = false;
const invalidObject = invalidObjectValue as T;
const input: WindowVariables = {
...new WindowVariablesStub(),
isDesktop: isOnDesktop,
isRunningAsDesktopApplication: undefined,
[key]: invalidObject,
};
// act

View File

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

View File

@@ -245,7 +245,7 @@ function initializeDragHandlerWithMocks(mocks?: {
}
function createMockPointerEvent(...args: ConstructorArguments<typeof PointerEvent>): PointerEvent {
return new MouseEvent(...args) as PointerEvent; // JSDom does not support `PointerEvent` constructor, https://github.com/jsdom/jsdom/issues/2527
return new MouseEvent(...args) as PointerEvent; // jsdom does not support `PointerEvent` constructor, https://github.com/jsdom/jsdom/issues/2527
}
class DragDomModifierMock implements DragDomModifier {

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { determineDialogBasedOnEnvironment, WindowDialogCreationFunction, BrowserDialogCreationFunction } from '@/presentation/components/Shared/Hooks/Dialog/ClientDialogFactory';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
describe('ClientDialogFactory', () => {
describe('determineDialogBasedOnEnvironment', () => {
describe('non-desktop environment', () => {
it('returns browser dialog', () => {
// arrange
const expectedDialog = new DialogStub();
const context = new DialogCreationTestSetup()
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(false))
.withBrowserDialogFactory(() => expectedDialog);
// act
const actualDialog = context.createDialogForTest();
// assert
expect(expectedDialog).to.equal(actualDialog);
});
});
describe('desktop environment', () => {
it('returns window-injected dialog', () => {
// arrange
const expectedDialog = new DialogStub();
const context = new DialogCreationTestSetup()
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
.withWindowInjectedDialogFactory(() => expectedDialog);
// act
const actualDialog = context.createDialogForTest();
// assert
expect(expectedDialog).to.equal(actualDialog);
});
it('throws error when window-injected dialog is unavailable', () => {
// arrange
const expectedError = 'The Dialog API could not be retrieved from the window object.';
const context = new DialogCreationTestSetup()
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
.withWindowInjectedDialogFactory(() => undefined);
// act
const act = () => context.createDialogForTest();
// assert
const actualError = collectExceptionMessage(act);
expect(actualError).to.include(expectedError);
});
});
});
});
class DialogCreationTestSetup {
private environment: RuntimeEnvironment = new RuntimeEnvironmentStub();
private browserDialogFactory: BrowserDialogCreationFunction = () => new DialogStub();
private windowInjectedDialogFactory: WindowDialogCreationFunction = () => new DialogStub();
public withEnvironment(environment: RuntimeEnvironment): this {
this.environment = environment;
return this;
}
public withBrowserDialogFactory(browserDialogFactory: BrowserDialogCreationFunction): this {
this.browserDialogFactory = browserDialogFactory;
return this;
}
public withWindowInjectedDialogFactory(
windowInjectedDialogFactory: WindowDialogCreationFunction,
): this {
this.windowInjectedDialogFactory = windowInjectedDialogFactory;
return this;
}
public createDialogForTest() {
return determineDialogBasedOnEnvironment(
this.environment,
this.windowInjectedDialogFactory,
this.browserDialogFactory,
);
}
}

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { DialogFactory, useDialog } from '@/presentation/components/Shared/Hooks/Dialog/UseDialog';
import { DialogStub } from '@tests/unit/shared/Stubs/DialogStub';
describe('UseDialog', () => {
describe('useDialog', () => {
it('returns provided dialog instance', () => {
// arrange
const expectedDialog = new DialogStub();
const factoryMock: DialogFactory = () => expectedDialog;
// act
const { dialog } = useDialog(factoryMock);
// assert
expect(dialog).to.equal(expectedDialog);
});
});
});

View File

@@ -1,11 +1,11 @@
// eslint-disable-next-line max-classes-per-file
import { describe, it } from 'vitest';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { ClientLoggerFactory, LoggerCreationFunction, WindowAccessor } from '@/presentation/bootstrapping/ClientLoggerFactory';
import { Logger } from '@/application/Common/Log/Logger';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { ClientLoggerFactory, LoggerCreationFunction, WindowAccessor } from '@/presentation/components/Shared/Hooks/Log/ClientLoggerFactory';
describe('ClientLoggerFactory', () => {
describe('Current', () => {
@@ -29,7 +29,7 @@ describe('ClientLoggerFactory', () => {
build: (b, expectedLogger) => b
.withWindowInjectedLoggerFactory(() => expectedLogger)
.withEnvironment(new RuntimeEnvironmentStub()
.withIsDesktop(true)
.withIsRunningAsDesktopApplication(true)
.withIsNonProduction(false))
.withWindowAccessor(() => createWindowWithLogger())
.build(),
@@ -39,7 +39,7 @@ describe('ClientLoggerFactory', () => {
build: (b, expectedLogger) => b
.withWindowInjectedLoggerFactory(() => expectedLogger)
.withEnvironment(new RuntimeEnvironmentStub()
.withIsDesktop(true)
.withIsRunningAsDesktopApplication(true)
.withIsNonProduction(true))
.withWindowAccessor(() => createWindowWithLogger())
.build(),
@@ -49,7 +49,7 @@ describe('ClientLoggerFactory', () => {
build: (b, expectedLogger) => b
.withConsoleLoggerFactory(() => expectedLogger)
.withEnvironment(new RuntimeEnvironmentStub()
.withIsDesktop(false)
.withIsRunningAsDesktopApplication(false)
.withIsNonProduction(true))
.withWindowAccessor(() => createWindowWithLogger())
.build(),
@@ -59,7 +59,7 @@ describe('ClientLoggerFactory', () => {
build: (b, expectedLogger) => b
.withNoopLoggerFactory(() => expectedLogger)
.withEnvironment(new RuntimeEnvironmentStub()
.withIsDesktop(false)
.withIsRunningAsDesktopApplication(false)
.withIsNonProduction(false))
.withWindowAccessor(() => createWindowWithLogger())
.build(),
@@ -68,7 +68,7 @@ describe('ClientLoggerFactory', () => {
description: 'unit/integration tests environment',
build: (b, expectedLogger) => b
.withNoopLoggerFactory(() => expectedLogger)
.withEnvironment(new RuntimeEnvironmentStub().withIsDesktop(true))
.withEnvironment(new RuntimeEnvironmentStub().withIsRunningAsDesktopApplication(true))
.withWindowAccessor(() => createWindowWithLogger(null))
.build(),
},

View File

@@ -1,16 +1,17 @@
import { describe, it, expect } from 'vitest';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { LoggerFactoryStub } from '@tests/unit/shared/Stubs/LoggerFactoryStub';
import { useLogger } from '@/presentation/components/Shared/Hooks/Log/UseLogger';
import { LoggerFactory } from '@/presentation/components/Shared/Hooks/Log/LoggerFactory';
describe('UseLogger', () => {
it('returns expected logger from factory', () => {
// arrange
const expectedLogger = new LoggerStub();
const factory = new LoggerFactoryStub()
.withLogger(expectedLogger);
const factoryMock: LoggerFactory = {
logger: expectedLogger,
};
// act
const { log: actualLogger } = useLogger(factory);
const { log: actualLogger } = useLogger(factoryMock);
// assert
expect(actualLogger).to.equal(expectedLogger);
});

View File

@@ -1,10 +1,13 @@
import { describe, it, expect } from 'vitest';
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
import { IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions';
import { CodeRunnerFactory, IpcRegistrar, registerAllIpcChannels } from '@/presentation/electron/main/IpcRegistration';
import { ChannelDefinitionKey, IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions';
import {
CodeRunnerFactory, DialogFactory, IpcChannelRegistrar, 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';
describe('IpcRegistration', () => {
describe('registerAllIpcChannels', () => {
@@ -23,7 +26,7 @@ describe('IpcRegistration', () => {
});
});
describe('registers expected instances', () => {
const testScenarios: Record<keyof typeof IpcChannelDefinitions, {
const testScenarios: Record<ChannelDefinitionKey, {
buildContext: (context: IpcRegistrationTestSetup) => IpcRegistrationTestSetup,
expectedInstance: object,
}> = {
@@ -34,6 +37,13 @@ describe('IpcRegistration', () => {
expectedInstance,
};
})(),
Dialog: (() => {
const expectedInstance = new DialogStub();
return {
buildContext: (c) => c.withDialogFactory(() => expectedInstance),
expectedInstance,
};
})(),
};
Object.entries(testScenarios).forEach(([
key, { buildContext, expectedInstance },
@@ -46,7 +56,8 @@ describe('IpcRegistration', () => {
// act
context.run();
// assert
const actualInstance = getRegisteredInstance(IpcChannelDefinitions.CodeRunner);
const channel = IpcChannelDefinitions[key];
const actualInstance = getRegisteredInstance(channel);
expect(actualInstance).to.equal(expectedInstance);
});
});
@@ -54,7 +65,7 @@ describe('IpcRegistration', () => {
it('throws an error if registration fails', () => {
// arrange
const expectedError = 'registrar error';
const registrarMock: IpcRegistrar = () => {
const registrarMock: IpcChannelRegistrar = () => {
throw new Error(expectedError);
};
const context = new IpcRegistrationTestSetup()
@@ -70,9 +81,11 @@ describe('IpcRegistration', () => {
class IpcRegistrationTestSetup {
private codeRunnerFactory: CodeRunnerFactory = () => new CodeRunnerStub();
private registrar: IpcRegistrar = () => { /* NOOP */ };
private dialogFactory: DialogFactory = () => new DialogStub();
public withRegistrar(registrar: IpcRegistrar): this {
private registrar: IpcChannelRegistrar = () => { /* NOOP */ };
public withRegistrar(registrar: IpcChannelRegistrar): this {
this.registrar = registrar;
return this;
}
@@ -82,26 +95,37 @@ class IpcRegistrationTestSetup {
return this;
}
public withDialogFactory(dialogFactory: DialogFactory): this {
this.dialogFactory = dialogFactory;
return this;
}
public run() {
registerAllIpcChannels(
this.codeRunnerFactory,
this.dialogFactory,
this.registrar,
);
}
}
type DefinedIpcChannelTypes = {
[K in ChannelDefinitionKey]: (typeof IpcChannelDefinitions)[K]
}[ChannelDefinitionKey];
function createIpcRegistrarMock() {
const registeredChannels = new Array<Parameters<IpcRegistrar>>();
const registrarMock: IpcRegistrar = <T>(channel: IpcChannel<T>, obj: T) => {
const registeredChannels = new Array<Parameters<IpcChannelRegistrar>>();
const registrarMock: IpcChannelRegistrar = <T>(channel: IpcChannel<T>, obj: T) => {
registeredChannels.push([channel as IpcChannel<unknown>, obj]);
};
const isChannelRegistered = <T>(channel: IpcChannel<T>): boolean => {
const isChannelRegistered = (channel: DefinedIpcChannelTypes): boolean => {
return registeredChannels.some((i) => i[0] === channel);
};
const getRegisteredInstance = <T>(channel: IpcChannel<T>): unknown => {
const getRegisteredInstance = <T>(channel: IpcChannel<T>): T => {
const registration = registeredChannels.find((i) => i[0] === channel);
expectExists(registration);
return registration[1];
const [, registeredInstance] = registration;
return registeredInstance as T;
};
return {
registrarMock,

View File

@@ -16,7 +16,7 @@ describe('RendererApiProvider', () => {
readonly expectedValue: unknown;
}
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, WindowVariableTestCase> = {
isDesktop: {
isRunningAsDesktopApplication: {
description: 'returns true',
setupContext: (context) => context,
expectedValue: true,
@@ -34,6 +34,7 @@ describe('RendererApiProvider', () => {
instance: new LoggerStub(),
setupContext: (c, logger) => c.withLogger(logger),
}),
dialog: expectIpcConsumer(IpcChannelDefinitions.Dialog),
};
Object.entries(testScenarios).forEach((
[property, { description, setupContext, expectedValue }],

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { IpcChannel } from '@/presentation/electron/shared/IpcBridging/IpcChannel';
import { IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions';
import { ChannelDefinitionKey, IpcChannelDefinitions } from '@/presentation/electron/shared/IpcBridging/IpcChannelDefinitions';
describe('IpcChannelDefinitions', () => {
it('defines IPC channels correctly', () => {
const testScenarios: Record<keyof typeof IpcChannelDefinitions, {
const testScenarios: Record<ChannelDefinitionKey, {
readonly expectedNamespace: string;
readonly expectedAccessibleMembers: readonly string[];
}> = {
@@ -12,6 +12,10 @@ describe('IpcChannelDefinitions', () => {
expectedNamespace: 'code-run',
expectedAccessibleMembers: ['runCode'],
},
Dialog: {
expectedNamespace: 'dialogs',
expectedAccessibleMembers: ['saveFile'],
},
};
Object.entries(testScenarios).forEach((
[definitionKey, { expectedNamespace, expectedAccessibleMembers }],
@@ -49,7 +53,7 @@ describe('IpcChannelDefinitions', () => {
it('has unique accessible members within each channel', () => {
Object.values(IpcChannelDefinitions).forEach((channel) => {
// arrange
const { accessibleMembers: accessibleMembersOfChannel } = channel;
const accessibleMembersOfChannel = channel.accessibleMembers as string[];
// act
const repeatedAccessibleMembersInChannel = accessibleMembersOfChannel
.filter((item, index) => accessibleMembersOfChannel.indexOf(item) !== index);

View File

@@ -215,19 +215,35 @@ describe('IpcProxy', () => {
expect(actualChannelNames).to.have.lengthOf(expectedChannelNames.length);
expect(actualChannelNames).to.have.members(expectedChannelNames);
});
it('throws error for non-function members', () => {
// arrange
const expectedError = 'Non-function members are not yet supported';
const propertyName = 'propertyKey';
const testObject = { [`${propertyName}`]: 123 };
const testIpcChannel: IpcChannel<typeof testObject> = {
namespace: 'testNamespace',
accessibleMembers: [propertyName] as never,
};
// act
const act = () => registerIpcChannel(testIpcChannel, testObject, mockIpcMain().ipcMainMock);
// assert
expect(act).to.throw(expectedError);
describe('validation', () => {
it('throws error for non-function members', () => {
// arrange
const expectedError = 'Non-function members are not yet supported';
const propertyName = 'propertyKey';
const testObject = { [`${propertyName}`]: 123 };
const testIpcChannel: IpcChannel<typeof testObject> = {
namespace: 'testNamespace',
accessibleMembers: [propertyName] as never,
};
// act
const act = () => registerIpcChannel(testIpcChannel, testObject, mockIpcMain().ipcMainMock);
// assert
expect(act).to.throw(expectedError);
});
it('throws error for undefined members', () => {
// arrange
const nonExistingFunctionName = 'nonExistingFunction';
const expectedError = `The function "${nonExistingFunctionName}" is not found on the target object.`;
const testObject = { };
const testIpcChannel: IpcChannel<typeof testObject> = {
namespace: 'testNamespace',
accessibleMembers: [nonExistingFunctionName] as never,
};
// act
const act = () => registerIpcChannel(testIpcChannel, testObject, mockIpcMain().ipcMainMock);
// assert
expect(act).to.throw(expectedError);
});
});
});
});

View File

@@ -0,0 +1,7 @@
import { Dialog } from '@/presentation/common/Dialog';
export class DialogStub implements Dialog {
public saveFile(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -1,12 +0,0 @@
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { LoggerStub } from './LoggerStub';
export class LoggerFactoryStub implements LoggerFactory {
public logger: Logger = new LoggerStub();
public withLogger(logger: Logger): this {
this.logger = logger;
return this;
}
}

View File

@@ -4,7 +4,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
export class RuntimeEnvironmentStub implements RuntimeEnvironment {
public isNonProduction = true;
public isDesktop = true;
public isRunningAsDesktopApplication = false;
public os: OperatingSystem | undefined = OperatingSystem.Windows;
@@ -13,8 +13,8 @@ export class RuntimeEnvironmentStub implements RuntimeEnvironment {
return this;
}
public withIsDesktop(isDesktop: boolean): this {
this.isDesktop = isDesktop;
public withIsRunningAsDesktopApplication(isRunningAsDesktopApplication: boolean): this {
this.isRunningAsDesktopApplication = isRunningAsDesktopApplication;
return this;
}

View File

@@ -2,25 +2,34 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
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 { LoggerStub } from './LoggerStub';
import { CodeRunnerStub } from './CodeRunnerStub';
import { DialogStub } from './DialogStub';
export class WindowVariablesStub implements WindowVariables {
public codeRunner?: CodeRunner = new CodeRunnerStub();
public isDesktop = false;
public isRunningAsDesktopApplication: true | undefined = true;
public os?: OperatingSystem = OperatingSystem.BlackBerryOS;
public log: Logger = new LoggerStub();
public log?: Logger = new LoggerStub();
public dialog?: Dialog = new DialogStub();
public withLog(log: Logger): this {
this.log = log;
return this;
}
public withIsDesktop(value: boolean): this {
this.isDesktop = value;
public withDialog(dialog: Dialog): this {
this.dialog = dialog;
return this;
}
public withIsRunningAsDesktopApplication(isRunningAsDesktopApplication: true | undefined): this {
this.isRunningAsDesktopApplication = isRunningAsDesktopApplication;
return this;
}